mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
88db398aca
commit
80ad0b8851
2 files changed
+148
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user