OAuth PKCE Flow

Introduction

Buddo uses OAuth 2.0 Authorization Code with PKCE (Proof Key for Code Exchange, RFC 7636) for all operator applications that need to act on behalf of a user.

PKCE was designed for public clients — mobile apps, single-page applications, CLI tools, and any environment where a client secret cannot be stored securely. Instead of relying on a shared secret, PKCE uses a one-time cryptographic challenge that proves the same application that started the authorization flow is the one finishing it.

S256 only. Buddo requires the S256 challenge method. Plain code challenges are not supported.
App registration. Before starting the PKCE flow, you need an OAuth app with a client_id and registered redirect URIs. App registration is done through the operator dashboard or the admin API. You can verify your app exists by querying GET /api/oauth/apps/{client_id}.

Flow Overview

The complete PKCE authorization flow involves six steps between three parties:

Your App                          Buddo API                      User
   |                                  |                            |
   |  1. Generate code_verifier       |                            |
   |     + code_challenge (S256)      |                            |
   |                                  |                            |
   |  2. Redirect user ──────────────>|                            |
   |     GET /api/oauth/authorize     |                            |
   |     ?client_id=...               |  3. Authenticate ────────>|
   |     &code_challenge=...          |     User logs in & grants  |
   |     &code_challenge_method=S256  |     consent                |
   |     &redirect_uri=...            |                            |
   |     &scope=...                   |  4. Redirect back          |
   |     &state=...                   |<────────────────────────── |
   |                                  |                            |
   |  4. Receive callback             |                            |
   |     ?code=AUTH_CODE&state=...    |                            |
   |                                  |                            |
   |  5. Exchange code ──────────────>|                            |
   |     POST /api/oauth/token        |                            |
   |     { code, code_verifier,       |                            |
   |       client_id, redirect_uri }  |                            |
   |                                  |                            |
   |  <── access_token + refresh ─── |                            |
   |                                  |                            |
   |  6. Call API with Bearer token   |                            |
   |     Authorization: Bearer ...    |                            |
   |                                  |                            |
   |  7. Refresh when expired         |                            |
   |     POST /api/oauth/token        |                            |
   |     { grant_type: refresh_token }|                            |
   |                                  |                            |

Step 1: Generate PKCE Challenge

Before redirecting the user, generate a random code verifier (43–128 characters, URL-safe) and derive a code challenge from it using SHA-256.

Bash / OpenSSL

# Generate a random 32-byte code verifier (URL-safe base64, 43 chars)
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-' | tr -d '\n')

# Derive the S256 code challenge
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64 \
  | tr -d '=' | tr '/+' '_-' | tr -d '\n')

echo "code_verifier:  $CODE_VERIFIER"
echo "code_challenge: $CODE_CHALLENGE"

JavaScript

async function generatePKCE() {
  // Generate 32 random bytes for the verifier
  const buffer = crypto.getRandomValues(new Uint8Array(32));
  const verifier = btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

  // SHA-256 hash the verifier to create the challenge
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier)
  );
  const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

  return { verifier, challenge };
}

const { verifier, challenge } = await generatePKCE();
Store the verifier. Keep code_verifier in memory (or session storage for SPAs). You will need it in Step 4. Never send it to the authorization endpoint — only the challenge goes there.

Step 2: Redirect to Authorization

Redirect the user's browser to the Buddo authorization endpoint with your PKCE challenge and app details.

Endpoint

GET /api/oauth/authorize

Query Parameters

ParameterRequiredDescription
client_id Yes Your app's client ID
response_type Yes Must be code
redirect_uri No Must match one of your app's registered redirect URIs. If omitted, defaults to the first registered URI.
code_challenge Yes BASE64URL(SHA256(code_verifier)) from Step 1
code_challenge_method Yes Must be S256
scope No Space-separated list of requested scopes. Must be a subset of the app's allowed_scopes. See Scopes Reference.
state No Opaque value for CSRF protection. Returned unchanged in the callback. Strongly recommended.

Example URL

https://api.buddo.xyz/api/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &response_type=code
  &redirect_uri=https://yourapp.com/callback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &scope=profile:read points:read
  &state=xyzABC123
User must be authenticated. The authorize endpoint requires a valid JWT Bearer token. In a browser flow, the user must be logged in to Buddo before reaching this endpoint. If the user is not authenticated, the API returns 401 Unauthorized.

Error Responses

StatusCause
400Invalid response_type, unknown client_id, unregistered redirect_uri, invalid scopes, or code_challenge_method is not S256
401Missing or invalid JWT
403App is not approved
422Validation errors

Step 3: Handle the Callback

After the user authenticates and grants consent, Buddo redirects back to your redirect_uri with an authorization code and your state value.

Callback URL

https://yourapp.com/callback?code=AUTH_CODE_HERE&state=xyzABC123

What to do

  1. Validate the state parameter — confirm it matches the value you sent in Step 2. If it does not match, abort the flow. This prevents CSRF attacks.
  2. Extract the code from the query string.
  3. Proceed to Step 4 immediately — authorization codes expire after 600 seconds (10 minutes) and can only be used once.

Example (JavaScript)

// Parse the callback URL
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const returnedState = params.get('state');

// Validate state matches what we sent
if (returnedState !== savedState) {
  throw new Error('State mismatch — possible CSRF attack');
}

// Proceed to token exchange with the code...

Step 4: Exchange Code for Tokens

Exchange the authorization code and your original code_verifier for an access token, refresh token, and session token.

Endpoint

POST /api/oauth/token

Request Body (JSON)

FieldRequiredDescription
grant_type Yes authorization_code
code Yes The authorization code from the callback
redirect_uri Yes Must match the redirect_uri used in Step 2
client_id Yes Your app's client ID
code_verifier Yes* The original PKCE verifier from Step 1. Required for public clients (alternative: client_secret for confidential clients).

cURL Example

curl -X POST https://api.buddo.xyz/api/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "AUTH_CODE_HERE",
    "redirect_uri": "https://yourapp.com/callback",
    "client_id": "YOUR_CLIENT_ID",
    "code_verifier": "YOUR_CODE_VERIFIER"
  }'

Success Response 200 OK

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
  "session_token": "c2Vzc2lvbi10b2tlbi0x...",
  "session_token_expires_at": "2026-05-21T14:30:00Z"
}
FieldTypeDescription
access_token string Bearer token for API requests
token_type string Always Bearer
expires_in integer Token lifetime in seconds
refresh_token string Use to obtain a new access token when the current one expires
session_token string Short-lived session token (only present on authorization_code grants)
session_token_expires_at datetime ISO 8601 expiry of the session token

Error Responses

Token exchange errors follow the OAuth 2.0 error format:

{
  "error": "invalid_grant",
  "error_description": "Authorization code has expired"
}
Error CodeCause
invalid_clientUnknown client_id
invalid_grantCode expired, already used, redirect_uri mismatch, or invalid code_verifier
invalid_requestPKCE verifier required but not provided

Step 5: Use the Access Token

Include the access token in the Authorization header on every API request.

curl https://api.buddo.xyz/api/external/user \
  -H "Authorization: Bearer ACCESS_TOKEN_HERE"

Example: Get User Profile

curl https://api.buddo.xyz/api/external/user \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

Example: Read Point Balance

curl https://api.buddo.xyz/api/external/points \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

Example: Spend Points

curl -X POST https://api.buddo.xyz/api/external/points/spend \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{"amount": 100, "description": "Game entry fee"}'
Scope enforcement. Each endpoint requires specific scopes. If your access token does not include the required scope, the API returns 401 Unauthorized. See the Scopes Reference below.

Step 6: Refresh Tokens

When the access token expires, use the refresh token to obtain a new one without requiring the user to re-authenticate.

Endpoint

POST /api/oauth/token

Request Body (JSON)

FieldRequiredDescription
grant_type Yes refresh_token
refresh_token Yes The refresh token from the original token exchange
client_id Yes Your app's client ID
client_secret Yes Your app's client secret

cURL Example

curl -X POST https://api.buddo.xyz/api/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET"
  }'

The response is the same token response format as Step 4, with a new access_token and refresh_token. Note that refresh grants do not include session_token.

Rotate refresh tokens. Each refresh response includes a new refresh token. Always store and use the latest one — the previous token may be invalidated.

Token Introspection

You can verify whether an access token is still active and inspect its metadata using the introspection endpoint. No authentication is required for this call.

Endpoint

POST /api/oauth/token/verify

Request Body (JSON)

{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

Response

{
  "active": true,
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "app_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "scopes": ["profile:read", "points:read"],
  "expires_at": "2026-05-21T15:30:00Z"
}

If the token is expired or invalid, the response still returns 200 OK with "active": false and a reason field (e.g., "expired").

Scopes Reference

Request scopes in the scope parameter of Step 2. Only scopes that are a subset of your app's allowed_scopes can be requested.

ScopeDescriptionUsed By
profile:read Read user profile and session information GET /api/external/user, GET /api/external/session/status, POST /api/external/session/end, GET /api/external/ads/serve, POST /api/external/ads/event
points:read Read user point balance GET /api/external/points
points:spend Spend user points (credits the operator account) POST /api/external/points/spend
points:transfer Transfer points between users POST /api/external/points/transfer
deploy:manage Deploy and manage hosted applications /api/deploy/* endpoints
app:balance:read Read the operator app's earned-point balance GET /api/external/app/balance, /api/operator/* endpoints
points:award Deprecated. Award is admin-only. Retained for existing token compatibility. N/A (admin only)

Need scopes that are not yet in your app's allowed_scopes? Submit a request via POST /api/oauth/my-apps/{id}/scope-request — it requires admin approval.

Error Handling

Standard API Errors

Most API endpoints return errors in this format:

{
  "error": "Description of what went wrong"
}

OAuth-Specific Errors

The /api/oauth/token endpoint uses RFC 6749 error format with error and error_description fields:

{
  "error": "invalid_grant",
  "error_description": "Code verifier does not match the code challenge"
}

Common Error Scenarios

ScenarioStatusError CodeFix
Unknown client_id 400 invalid_client Check your client_id. Verify your app exists with GET /api/oauth/apps/{client_id}.
Authorization code expired 400 invalid_grant Codes expire after 600 seconds (10 min). Restart the flow.
Code already used 400 invalid_grant Authorization codes are single-use. Restart the flow.
PKCE verifier mismatch 400 invalid_grant Ensure you are sending the original code_verifier, not the challenge.
redirect_uri mismatch 400 invalid_grant The redirect_uri in the token request must match the one used in the authorize request exactly.
Missing PKCE verifier 400 invalid_request Public clients must include code_verifier. Confidential clients can use client_secret instead.
Insufficient scopes 401 The access token lacks the scope required by the endpoint. Re-authorize with the correct scopes.
App not approved 403 Your app must be approved by an admin before users can authorize it.

Security Best Practices

  1. HTTPS only. All Buddo API traffic must use HTTPS. The production base URL is https://api.buddo.xyz. Never send tokens over plain HTTP.
  2. Always use the state parameter. Generate a cryptographically random value, store it before redirecting, and verify it in the callback. This prevents CSRF attacks.
  3. Never log tokens. Access tokens, refresh tokens, and session tokens are sensitive credentials. Do not write them to application logs, analytics, or error reporting systems.
  4. Store tokens securely. In browser apps, use sessionStorage or in-memory variables — never localStorage for access tokens. In server apps, use encrypted storage.
  5. Rotate refresh tokens. Each refresh response includes a new refresh token. Always replace the stored token with the latest one. The previous token may be invalidated.
  6. Use minimal scopes. Only request the scopes your application actually needs. Users are more likely to grant consent for focused permissions.
  7. Validate the state on every callback. Even if you "know" the request is legitimate, always check. Skipping validation opens a CSRF vector.
  8. Handle token expiry gracefully. Check expires_in and refresh proactively before the token expires, rather than waiting for a 401 response.
  9. Never embed tokens in URLs. Pass tokens in the Authorization header, not in query strings. URL parameters are logged by servers, proxies, and browser history.
  10. Revoke tokens when disconnecting. Users can revoke your app's access via DELETE /api/user/connected-apps/{app_id}. Handle 401 responses gracefully and prompt re-authorization when needed.

Further Reading