OAuth 2.1 Setup Guide
Step-by-step guide to integrating with Go Bananas! using OAuth 2.1.
When to Use OAuth
Choose OAuth 2.1 over API keys when:
- Building web applications that run in browsers
- Creating desktop applications (like Claude Desktop integration)
- Developing third-party integrations for end users
- Needing fine-grained permissions with scopes
- Requiring automatic token refresh without user intervention
Quick Decision
Use API Keys for server-to-server integrations, scripts, and CLI tools. Use OAuth for applications where users authenticate interactively.
Prerequisites
Before starting, you'll need:
- A Go Bananas! server instance (self-hosted or cloud)
- Your application's redirect URI (where users return after authorization)
- Understanding of which scopes your app needs
Step 1: Register Your Client
First, register your application using the Dynamic Client Registration endpoint.
For Web Applications (Public Client)
curl -X POST "https://gobananasai.com/oauth/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Image App",
"redirect_uris": [
"https://myapp.com/callback",
"http://localhost:3000/callback"
],
"scope": "images:generate images:read characters:read"
}'For Server Applications (Confidential Client)
curl -X POST "https://gobananasai.com/oauth/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Backend Service",
"redirect_uris": ["https://myserver.com/oauth/callback"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "images:generate images:read"
}'Response
{
"client_id": "gobananas_a1b2c3d4e5f6",
"client_name": "My Image App",
"redirect_uris": ["https://myapp.com/callback", "http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "images:generate images:read characters:read",
"client_id_issued_at": 1703001234
}Store Credentials Securely
For confidential clients, the client_secret is only shown once. Store it securely in environment variables or a secrets manager.
Step 2: Implement PKCE
PKCE (Proof Key for Code Exchange) is required for all authorization requests.
Generate PKCE Values
// Helper function for base64url encoding
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Generate a random code verifier (43-128 characters)
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// Generate code challenge from verifier
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(hash);
}
// Usage
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store verifier securely for later use
sessionStorage.setItem('oauth_code_verifier', codeVerifier);Python Implementation
import secrets
import hashlib
import base64
def generate_code_verifier():
return base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode('utf-8')
def generate_code_challenge(verifier):
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('utf-8')
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)Step 3: Redirect to Authorization
Build the authorization URL and redirect the user:
function buildAuthUrl(clientId, redirectUri, codeChallenge, scopes) {
const url = new URL('https://gobananasai.com/oauth/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('scope', scopes.join(' '));
url.searchParams.set('state', crypto.randomUUID()); // CSRF protection
return url.toString();
}
// Redirect user to authorization
const authUrl = buildAuthUrl(
'gobananas_abc123',
'https://myapp.com/callback',
codeChallenge,
['images:generate', 'images:read', 'characters:read']
);
window.location.href = authUrl;If the user is not logged in, the authorization endpoint redirects browsers to the console login at /?return_to=.... Non-HTML clients receive login_required JSON with a login_url.
State Parameter
The state parameter protects against CSRF attacks:
// Before redirect
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
// In callback handler
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) {
throw new Error('State mismatch - possible CSRF attack');
}Step 4: Handle the Callback
After the user authorizes, they're redirected to your redirect_uri with an authorization code:
https://myapp.com/callback?code=AUTH_CODE&state=xyz123Extract and Validate
// callback.js or callback route handler
function handleCallback() {
const params = new URLSearchParams(window.location.search);
// Check for errors
if (params.has('error')) {
const error = params.get('error');
const description = params.get('error_description');
throw new Error(`Authorization failed: ${error} - ${description}`);
}
// Validate state
const state = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('State mismatch');
}
// Get authorization code
const code = params.get('code');
if (!code) {
throw new Error('No authorization code received');
}
return code;
}Step 5: Exchange Code for Tokens
Exchange the authorization code for access and refresh tokens:
async function exchangeCodeForTokens(code, clientId, redirectUri) {
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const response = await fetch('https://gobananasai.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
code_verifier: codeVerifier,
client_id: clientId,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error_description}`);
}
const tokens = await response.json();
// Clean up PKCE values
sessionStorage.removeItem('oauth_code_verifier');
sessionStorage.removeItem('oauth_state');
return tokens;
}
// Result:
// {
// access_token: "...",
// token_type: "Bearer",
// expires_in: 3600,
// refresh_token: "...",
// scope: "images:generate images:read characters:read"
// }Step 6: Store Tokens Securely
Store tokens appropriately for your application type:
Web Applications (Browser)
// For SPAs - use memory + sessionStorage
class TokenStore {
constructor() {
this.accessToken = null;
}
setTokens(tokens) {
this.accessToken = tokens.access_token;
// Store refresh token in sessionStorage (cleared on tab close)
sessionStorage.setItem('refresh_token', tokens.refresh_token);
}
getAccessToken() {
return this.accessToken;
}
getRefreshToken() {
return sessionStorage.getItem('refresh_token');
}
clear() {
this.accessToken = null;
sessionStorage.removeItem('refresh_token');
}
}Server Applications
// Store in encrypted database or Redis
async function storeUserTokens(userId, tokens) {
await db.userTokens.upsert({
where: { userId },
update: {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
},
create: {
userId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
},
});
}Step 7: Make Authenticated Requests
Use the access token with the MCP endpoint /mcp (the legacy /sse transport accepts it too, but use /mcp). REST /api endpoints require API keys.
async function apiRequest(path, options = {}) {
const accessToken = tokenStore.getAccessToken();
const response = await fetch(`https://gobananasai.com${path}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
// Handle token expiration
if (response.status === 401) {
await refreshTokens();
return apiRequest(path, options); // Retry with new token
}
return response;
}
// Example usage (MCP tools/list)
const tools = await apiRequest('/mcp', {
method: 'POST',
body: JSON.stringify({ method: 'tools/list', params: {} }),
}).then(r => r.json());Step 8: Implement Token Refresh
Access tokens expire after 1 hour. Refresh them automatically:
async function refreshTokens() {
const refreshToken = tokenStore.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available - user must re-authenticate');
}
const response = await fetch('https://gobananasai.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!response.ok) {
const error = await response.json();
if (error.error === 'invalid_grant') {
// Refresh token expired or revoked - user must re-authenticate
tokenStore.clear();
window.location.href = '/login';
return;
}
throw new Error(`Token refresh failed: ${error.error_description}`);
}
const tokens = await response.json();
tokenStore.setTokens(tokens);
return tokens;
}Proactive Refresh
Refresh tokens before they expire to avoid request failures:
function scheduleTokenRefresh(expiresIn) {
// Refresh 5 minutes before expiration
const refreshIn = (expiresIn - 300) * 1000;
setTimeout(async () => {
try {
const newTokens = await refreshTokens();
scheduleTokenRefresh(newTokens.expires_in);
} catch (error) {
console.error('Background token refresh failed:', error);
}
}, refreshIn);
}
// Call after initial token acquisition
scheduleTokenRefresh(tokens.expires_in);Available Scopes
Request only the scopes your application needs:
| Scope | Permission |
|---|---|
images:generate | Generate new images |
images:edit | Edit existing images |
images:read | View image metadata and thumbnails |
images:delete | Delete images |
characters:manage | Create, update, delete characters |
characters:read | View character library |
products:manage | Create, update, delete product references |
products:read | View product library |
styles:manage | Create, update, delete style presets |
styles:read | View style presets |
sessions:read | View session history |
analytics:read | View usage analytics and quotas |
Complete Example: React Hook
// useOAuth.js
import { useState, useEffect, useCallback } from 'react';
const CLIENT_ID = 'gobananas_abc123';
const REDIRECT_URI = 'http://localhost:3000/callback';
const SCOPES = ['images:generate', 'images:read'];
export function useOAuth() {
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
// Check for existing session on mount
useEffect(() => {
const token = sessionStorage.getItem('access_token');
if (token) {
setAccessToken(token);
}
setLoading(false);
}, []);
// Initiate login flow
const login = useCallback(async () => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const url = new URL('https://gobananasai.com/oauth/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('scope', SCOPES.join(' '));
url.searchParams.set('state', state);
window.location.href = url.toString();
}, []);
// Handle callback after authorization
const handleCallback = useCallback(async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch');
}
const verifier = sessionStorage.getItem('oauth_verifier');
const response = await fetch('https://gobananasai.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: verifier,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
}),
});
const tokens = await response.json();
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('refresh_token', tokens.refresh_token);
setAccessToken(tokens.access_token);
// Clean up
sessionStorage.removeItem('oauth_verifier');
sessionStorage.removeItem('oauth_state');
// Redirect to app
window.history.replaceState({}, '', '/');
}, []);
// Logout
const logout = useCallback(() => {
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token');
setAccessToken(null);
}, []);
return {
isAuthenticated: !!accessToken,
accessToken,
loading,
login,
logout,
handleCallback,
};
}Troubleshooting
"invalid_grant" Error
Causes:
- Authorization code expired (10 minute limit)
- Code already used (codes are single-use)
- Code verifier doesn't match code challenge
- Wrong redirect_uri in token request
Solutions:
- Ensure code exchange happens immediately after callback
- Verify PKCE implementation matches specification
- Use exact same redirect_uri in both requests
"invalid_client" Error
Causes:
- Client ID doesn't exist
- Client was deactivated
- Wrong client credentials
Solutions:
- Verify client ID is correct
- Re-register the client if needed
- Check client status in admin panel
Token Refresh Fails
Causes:
- Refresh token expired (30 day limit)
- Refresh token already used (rotation)
- Token family revoked due to security event
Solutions:
- Implement proper token storage
- Handle re-authentication gracefully
- Check for concurrent token usage
Next Steps
- OAuth 2.1 API Reference - Complete endpoint documentation
- Security Architecture - Understand the security model
- API Authentication - API key alternative