feat(oauth): add PKCE S256 code challenge to OpenRouter OAuth flow (#2654)

Implements RFC 7636 PKCE with S256 code challenge method for the
OpenRouter OAuth authorization flow. This prevents authorization code
interception attacks by binding the code to a cryptographic verifier.

Changes:
- Generate code_verifier (32 random bytes, base64url-encoded)
- Derive code_challenge via SHA-256 + base64url
- Send code_challenge + code_challenge_method=S256 in auth URL
- Send code_verifier + code_challenge_method in token exchange POST
- Add test suite with RFC 7636 Appendix B test vector validation

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
A 2026-03-15 10:14:48 -07:00 committed by GitHub
parent df14acf8df
commit 8d3d7e4619
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 94 additions and 1 deletions

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "bun:test";
import { generateCodeChallenge, generateCodeVerifier } from "../shared/oauth";
describe("PKCE S256", () => {
it("generateCodeVerifier returns a 43-char base64url string", () => {
const verifier = generateCodeVerifier();
// 32 bytes → 43 base64url chars (no padding)
expect(verifier).toHaveLength(43);
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("generateCodeVerifier produces unique values", () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toBe(b);
});
it("generateCodeChallenge produces a valid base64url SHA-256 hash", async () => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// SHA-256 → 32 bytes → 43 base64url chars
expect(challenge).toHaveLength(43);
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("generateCodeChallenge is deterministic for the same verifier", async () => {
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const c1 = await generateCodeChallenge(verifier);
const c2 = await generateCodeChallenge(verifier);
expect(c1).toBe(c2);
});
it("matches the RFC 7636 Appendix B test vector", async () => {
// RFC 7636 Appendix B test vector:
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = await generateCodeChallenge(verifier);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
it("challenge differs for different verifiers", async () => {
const v1 = generateCodeVerifier();
const v2 = generateCodeVerifier();
const c1 = await generateCodeChallenge(v1);
const c2 = await generateCodeChallenge(v2);
expect(c1).not.toBe(c2);
});
it("challenge contains no padding characters", async () => {
// Run multiple times to increase confidence padding is stripped
for (let i = 0; i < 10; i++) {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
expect(challenge).not.toContain("=");
}
});
it("challenge contains no standard base64 characters (+, /)", async () => {
for (let i = 0; i < 10; i++) {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
expect(challenge).not.toContain("+");
expect(challenge).not.toContain("/");
}
});
});

View file

@ -46,6 +46,28 @@ async function verifyOpenrouterKey(apiKey: string): Promise<boolean> {
return result.ok ? result.data : true; // network error = skip validation
}
// ─── PKCE (S256) ────────────────────────────────────────────────────────────
/** Base64url-encode a Uint8Array (RFC 7636 Appendix A). */
function base64UrlEncode(bytes: Uint8Array): string {
const binStr = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
return btoa(binStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** Generate a cryptographically random code verifier (43 chars, URL-safe). */
export function generateCodeVerifier(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/** Derive the S256 code challenge: BASE64URL(SHA-256(verifier)). */
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoded = new TextEncoder().encode(verifier);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
return base64UrlEncode(digest);
}
// ─── OAuth Flow via Bun.serve ────────────────────────────────────────────────
export function generateCsrfState(): string {
@ -80,6 +102,8 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
}
const csrfState = generateCsrfState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
@ -162,7 +186,7 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
logInfo(`OAuth server listening on port ${actualPort}`);
const callbackUrl = `http://localhost:${actualPort}/callback`;
let authUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(callbackUrl)}&state=${csrfState}`;
let authUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(callbackUrl)}&state=${csrfState}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
if (agentSlug) {
authUrl += `&spawn_agent=${encodeURIComponent(agentSlug)}`;
}
@ -205,6 +229,8 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
},
body: JSON.stringify({
code: oauthCode,
code_verifier: codeVerifier,
code_challenge_method: "S256",
}),
signal: AbortSignal.timeout(30_000),
});