diff --git a/src/lib/ai/__tests__/codex-oauth.test.ts b/src/lib/ai/__tests__/codex-oauth.test.ts new file mode 100644 index 000000000..008758cfe --- /dev/null +++ b/src/lib/ai/__tests__/codex-oauth.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { generatePKCE, generateState, buildAuthorizationUrl } from "../codex-oauth"; + +describe("codex-oauth", () => { + describe("generatePKCE", () => { + it("generates verifier and challenge of correct format", () => { + const { verifier, challenge } = generatePKCE(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(challenge.length).toBeGreaterThanOrEqual(43); + expect(verifier).not.toContain("="); + expect(challenge).not.toContain("="); + expect(verifier).not.toContain("+"); + expect(challenge).not.toContain("+"); + }); + + it("generates different values each time", () => { + const a = generatePKCE(); + const b = generatePKCE(); + expect(a.verifier).not.toBe(b.verifier); + }); + }); + + describe("generateState", () => { + it("generates a non-empty base64url string", () => { + const state = generateState(); + expect(state.length).toBeGreaterThan(20); + expect(state).not.toContain("="); + }); + }); + + describe("buildAuthorizationUrl", () => { + it("builds correct URL with all params", () => { + const url = buildAuthorizationUrl({ + codeChallenge: "test-challenge", + state: "test-state", + redirectUri: "https://example.com/callback", + }); + + const parsed = new URL(url); + expect(parsed.origin).toBe("https://chatgpt.com"); + expect(parsed.pathname).toBe("/authorize"); + expect(parsed.searchParams.get("response_type")).toBe("code"); + expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge"); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + expect(parsed.searchParams.get("state")).toBe("test-state"); + expect(parsed.searchParams.get("redirect_uri")).toBe("https://example.com/callback"); + }); + }); +}); diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts new file mode 100644 index 000000000..8aaa30d37 --- /dev/null +++ b/src/lib/ai/codex-oauth.ts @@ -0,0 +1,99 @@ +import { createHash, randomBytes } from "node:crypto"; +import { encrypt, decrypt } from "@/lib/crypto"; + +function base64url(buffer: Buffer): string { + return buffer.toString("base64url"); +} + +export function generatePKCE(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(96)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +export function generateState(): string { + return base64url(randomBytes(32)); +} + +export function buildAuthorizationUrl(params: { + codeChallenge: string; + state: string; + redirectUri: string; +}): string { + const url = new URL("https://chatgpt.com/authorize"); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", params.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("redirect_uri", params.redirectUri); + return url.toString(); +} + +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; +} + +export async function exchangeCodeForTokens(params: { + code: string; + codeVerifier: string; + redirectUri: string; +}): Promise { + const res = await fetch("https://chatgpt.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code: params.code, + code_verifier: params.codeVerifier, + redirect_uri: params.redirectUri, + }), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Codex token exchange failed (${res.status}): ${body}`); + } + + return res.json(); +} + +export async function refreshAccessToken(refreshToken: string): Promise { + const res = await fetch("https://chatgpt.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Codex token refresh failed (${res.status}): ${body}`); + } + + return res.json(); +} + +export function encryptTokens(tokens: { + accessToken: string; + refreshToken: string; +}): { accessEncrypted: string; refreshEncrypted: string } { + return { + accessEncrypted: encrypt(tokens.accessToken), + refreshEncrypted: encrypt(tokens.refreshToken), + }; +} + +export function decryptTokens(encrypted: { + accessEncrypted: string; + refreshEncrypted: string; +}): { accessToken: string; refreshToken: string } { + return { + accessToken: decrypt(encrypted.accessEncrypted), + refreshToken: decrypt(encrypted.refreshEncrypted), + }; +}