diff --git a/packages/cli/src/__tests__/oauth-pkce.test.ts b/packages/cli/src/__tests__/oauth-pkce.test.ts new file mode 100644 index 00000000..4c2424d4 --- /dev/null +++ b/packages/cli/src/__tests__/oauth-pkce.test.ts @@ -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("/"); + } + }); +}); diff --git a/packages/cli/src/shared/oauth.ts b/packages/cli/src/shared/oauth.ts index 23b8caf0..864ee93e 100644 --- a/packages/cli/src/shared/oauth.ts +++ b/packages/cli/src/shared/oauth.ts @@ -46,6 +46,28 @@ async function verifyOpenrouterKey(apiKey: string): Promise { 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 { + 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 | 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), });