Skip to content

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:

  1. A Go Bananas! server instance (self-hosted or cloud)
  2. Your application's redirect URI (where users return after authorization)
  3. 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) ​

bash
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) ​

bash
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 ​

json
{
  "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 ​

javascript
// 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 ​

python
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:

javascript
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:

javascript
// 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=xyz123

Extract and Validate ​

javascript
// 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:

javascript
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) ​

javascript
// 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 ​

javascript
// 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.

javascript
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:

javascript
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:

javascript
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:

ScopePermission
images:generateGenerate new images
images:editEdit existing images
images:readView image metadata and thumbnails
images:deleteDelete images
characters:manageCreate, update, delete characters
characters:readView character library
products:manageCreate, update, delete product references
products:readView product library
styles:manageCreate, update, delete style presets
styles:readView style presets
sessions:readView session history
analytics:readView usage analytics and quotas

Complete Example: React Hook ​

jsx
// 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 ​

Released under the MIT License.