Skip to content

Authorization Code + PKCE

All client apps authenticate using the OAuth2 Authorization Code flow with PKCE (RFC 7636). This is the most secure browser-based OAuth2 flow — it prevents authorization code interception attacks without requiring client secrets in the browser.

┌──────────┐ ┌──────────────┐
│ Browser │ │ auth.beshoy │
└────┬─────┘ └──────┬───────┘
│ │
│ 1. GET /oauth/authorize │
│ ?client_id=proj_xxx │
│ &redirect_uri=https://... │
│ &response_type=code │
│ &code_challenge=BASE64URL(SHA256(v)) │
│ &code_challenge_method=S256 │
│ &state=ENCRYPTED(verifier+returnTo) │
│ ─────────────────────────────────────────▶│
│ │
│ 2. Login page (branded) │
│ ◀─────────────────────────────────────────│
│ │
│ 3. User submits credentials │
│ ─────────────────────────────────────────▶│
│ │
│ 4. 302 → redirect_uri?code=xxx&state=yyy │
│ ◀─────────────────────────────────────────│
│ │
┌────┴─────┐ ┌──────┴───────┐
│ App │ 5. POST /oauth/token │ auth.beshoy │
│ Server │ {code, code_verifier, │ │
│ │ client_id, client_secret}│ │
│ │ ─────────────────────────────▶│ │
│ │ │ │
│ │ 6. {access_token, │ │
│ │ refresh_token} │ │
│ │ ◀─────────────────────────────│ │
└──────────┘ └──────────────┘

Redirect the user to the authorization endpoint with the required parameters.

ParameterRequiredDescription
client_idYesProject ID (e.g., proj_gym)
redirect_uriYesMust exactly match a registered URI
response_typeYesMust be code
code_challengeYesBASE64URL(SHA256(code_verifier))
code_challenge_methodYesMust be S256
stateYesOpaque string returned unchanged after auth
// 1. Generate random code verifier (32 bytes → 43 chars base64url)
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// 2. Compute S256 challenge
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');

We recommend encrypting the state parameter with AES-GCM to securely carry the code verifier and return URL through the redirect:

async function encryptState(
data: { v: string; r: string },
secret: Uint8Array,
): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
secret.slice(0, 32),
'AES-GCM',
false,
['encrypt'],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(data));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext,
);
const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
GET https://auth.beshoy.ai/oauth/authorize
?client_id=proj_gym
&redirect_uri=https://gym.beshoy.ai/api/auth/callback
&response_type=code
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=eyJhbGciOi...

The auth service renders a branded login page with the methods enabled for the project. The page is server-rendered HTML — no JavaScript framework required on the auth side.

On successful authentication, the auth service:

  1. Generates a one-time authorization code (stored in KV, 5-minute TTL)
  2. Associates the PKCE challenge with the code
  3. Redirects to redirect_uri with code and state parameters
HTTP/1.1 302 Found
Location: https://gym.beshoy.ai/api/auth/callback?code=abc123&state=eyJhbGciOi...

See Token Exchange for the full POST /oauth/token documentation.

The auth service validates redirect URIs with exact string matching against the project’s registered URIs. No wildcards, no pattern matching.

// Registered URIs for proj_gym:
[
"https://gym.beshoy.ai/api/auth/callback",
"http://localhost:3001/api/auth/callback"
]

A request with redirect_uri=https://gym.beshoy.ai/api/auth/callback/evil would be rejected.

ErrorStatusCause
Missing or invalid parameters400Missing required query params
Only S256 code_challenge_method is supported400Plain PKCE attempted
Invalid client_id400Project not found
Invalid redirect_uri400URI not in project’s allowlist