mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
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:
parent
df14acf8df
commit
8d3d7e4619
2 changed files with 94 additions and 1 deletions
67
packages/cli/src/__tests__/oauth-pkce.test.ts
Normal file
67
packages/cli/src/__tests__/oauth-pkce.test.ts
Normal 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("/");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue