feat(ai): add Codex OAuth PKCE utilities and token management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:42:46 +02:00
1 parent 88db398aca
commit 80ad0b8851
2 files changed
+148

No files matched your search

+49
View File
@@ -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");
});
});
});
+99
View File
@@ -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<TokenResponse> {
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<TokenResponse> {
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),
};
}