Skip to content

Commit b8b0197

Browse files
committed
Auth: verify PKCE challenge at the Worker edge with constant-time compare before backend round-trip.
1 parent 1a94f5e commit b8b0197

2 files changed

Lines changed: 38 additions & 0 deletions

File tree

workers/gh-store-app-oauth/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Responses:
8282
| 400 | `{ "error": "invalid_state" }` | `state` missing or not `^[A-Za-z0-9_-]{32,256}$` |
8383
| 400 | `{ "error": "invalid_verifier" }` | `code_verifier` not `^[A-Za-z0-9_-]{43,128}$` |
8484
| 400 | `{ "error": "invalid_challenge" }` | `code_challenge` not `^[A-Za-z0-9_-]{43}$` |
85+
| 400 | `{ "error": "challenge_mismatch" }` | `code_challenge``base64url(SHA-256(code_verifier))` |
8586
| 400 | `{ "error": "invalid_json" }` | Body unparseable or not an object |
8687
| 409 | `{ "error": "state_already_registered" }` | This `state` is already pinned in KV |
8788
| 413 | `{ "error": "payload_too_large" }` | Body exceeds 2 KiB |
@@ -293,6 +294,13 @@ a 60 s single-use verifier in KV — abuse there self-throttles via KV misses.
293294
Worker → backend) and is keyed in KV by the single-use `state`.
294295
- `state`, `code_verifier`, `code_challenge`, and `handoff_id` are each
295296
strictly format-validated before any KV access or backend call.
297+
- **PKCE challenge is verified at the edge** (defense in depth). After format
298+
validation, the Worker computes `base64url(SHA-256(code_verifier))` via
299+
`crypto.subtle.digest` and constant-time compares it with the supplied
300+
`code_challenge`. Mismatch → `400 challenge_mismatch` before any KV write or
301+
backend round-trip. This is independent of, and additional to, the backend's
302+
later verification at `/v1/oauth/exchange` — catches client bugs (and any
303+
pre-OAuth tampering) before the GitHub authorize hop, not after.
296304
- KV `oauth:verifier:<state>` TTL is 60 s and is deleted immediately on first
297305
read. KV has no atomic GETDEL — the read-then-delete pair is effectively
298306
single-use because subsequent reads will miss either due to the prior delete

workers/gh-store-app-oauth/src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ async function handleRegister(req: Request, env: Env): Promise<Response> {
8787
return jsonResponse({ error: "invalid_challenge" }, 400);
8888
}
8989

90+
if (!(await verifyPkceChallenge(code_verifier, code_challenge))) {
91+
return jsonResponse({ error: "challenge_mismatch" }, 400);
92+
}
93+
9094
const key = `${KV_PREFIX}${state}`;
9195
const existing = await env.OAUTH_STATE.get(key);
9296
if (existing !== null) {
@@ -252,6 +256,32 @@ function methodNotAllowed(allowed: string): Response {
252256
});
253257
}
254258

259+
async function verifyPkceChallenge(verifier: string, challenge: string): Promise<boolean> {
260+
// RFC 7636 §4.6: code_challenge MUST equal BASE64URL-ENCODE(SHA256(ASCII(code_verifier))).
261+
// VERIFIER_RE restricts verifier to base64url alphabet, so its UTF-8 encoding
262+
// is byte-identical to ASCII — TextEncoder is safe here.
263+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
264+
const expected = base64UrlEncode(new Uint8Array(digest));
265+
return constantTimeEqual(expected, challenge);
266+
}
267+
268+
function base64UrlEncode(bytes: Uint8Array): string {
269+
let binary = "";
270+
for (let i = 0; i < bytes.length; i++) {
271+
binary += String.fromCharCode(bytes[i]!);
272+
}
273+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
274+
}
275+
276+
function constantTimeEqual(a: string, b: string): boolean {
277+
if (a.length !== b.length) return false;
278+
let diff = 0;
279+
for (let i = 0; i < a.length; i++) {
280+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
281+
}
282+
return diff === 0;
283+
}
284+
255285
function sanitizeReason(raw: string): string {
256286
return REASON_RE.test(raw) ? raw : "github_error";
257287
}

0 commit comments

Comments
 (0)