From e53cd7f86e09046b2345891d4a6f2dccadacd5d7 Mon Sep 17 00:00:00 2001 From: OpenInspect Date: Thu, 25 Jun 2026 06:07:55 +0000 Subject: [PATCH] fix(types): validate auth token responses --- .../control-plane/src/auth/github.test.ts | 101 ++++++++++++++++++ packages/control-plane/src/auth/github.ts | 45 ++++---- .../control-plane/src/auth/openai.test.ts | 32 +++++- packages/control-plane/src/auth/openai.ts | 29 +++-- packages/control-plane/src/types.ts | 17 +-- 5 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 packages/control-plane/src/auth/github.test.ts diff --git a/packages/control-plane/src/auth/github.test.ts b/packages/control-plane/src/auth/github.test.ts new file mode 100644 index 000000000..4bd17cd80 --- /dev/null +++ b/packages/control-plane/src/auth/github.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { exchangeCodeForToken, refreshAccessToken } from "./github"; +import type { GitHubOAuthConfig } from "./github"; +import type { GitHubTokenResponse } from "../types"; + +describe("github auth", () => { + const originalFetch = globalThis.fetch; + const config: GitHubOAuthConfig = { + clientId: "client-id", + clientSecret: "client-secret", + encryptionKey: "unused", + }; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe("exchangeCodeForToken", () => { + it("parses a valid token response", async () => { + const tokenResponse: GitHubTokenResponse = { + access_token: "gho_token", + token_type: "bearer", + scope: "repo,user", + refresh_token: "ghr_refresh", + expires_in: 28800, + }; + + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(tokenResponse), + } as unknown as Response); + + await expect(exchangeCodeForToken("code", config)).resolves.toEqual(tokenResponse); + }); + + it("parses a valid token response with optional fields omitted", async () => { + const tokenResponse: GitHubTokenResponse = { + access_token: "gho_token", + token_type: "bearer", + scope: "repo", + }; + + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(tokenResponse), + } as unknown as Response); + + await expect(exchangeCodeForToken("code", config)).resolves.toEqual(tokenResponse); + }); + + it("rejects a malformed token response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ access_token: "gho_token", token_type: "bearer" }), + } as unknown as Response); + + await expect(exchangeCodeForToken("code", config)).rejects.toThrow( + "Invalid GitHub token response" + ); + }); + + it("preserves GitHub OAuth error handling", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + error: "bad_verification_code", + error_description: "The code passed is incorrect or expired.", + }), + } as unknown as Response); + + await expect(exchangeCodeForToken("code", config)).rejects.toThrow( + "The code passed is incorrect or expired." + ); + }); + }); + + describe("refreshAccessToken", () => { + it("parses a valid refresh response", async () => { + const tokenResponse: GitHubTokenResponse = { + access_token: "gho_new", + token_type: "bearer", + scope: "repo,user", + refresh_token: "ghr_new", + expires_in: 28800, + }; + + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(tokenResponse), + } as unknown as Response); + + await expect(refreshAccessToken("old-refresh", config)).resolves.toEqual(tokenResponse); + }); + + it("rejects a malformed refresh response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ access_token: "gho_new", token_type: "bearer" }), + } as unknown as Response); + + await expect(refreshAccessToken("old-refresh", config)).rejects.toThrow( + "Invalid GitHub token response" + ); + }); + }); +}); diff --git a/packages/control-plane/src/auth/github.ts b/packages/control-plane/src/auth/github.ts index 4cad9df91..4c889f7ba 100644 --- a/packages/control-plane/src/auth/github.ts +++ b/packages/control-plane/src/auth/github.ts @@ -3,8 +3,29 @@ */ import { DEFAULT_APP_NAME } from "@open-inspect/shared"; +import { z } from "zod"; import { decryptToken, encryptToken } from "./crypto"; -import type { GitHubUser, GitHubTokenResponse } from "../types"; +import { githubTokenResponseSchema, type GitHubUser, type GitHubTokenResponse } from "../types"; + +const githubOAuthErrorSchema = z.object({ + error: z.string().optional(), + error_description: z.string().optional(), +}); + +async function parseGitHubTokenResponse(response: Response): Promise { + const data: unknown = await response.json(); + const errorResult = githubOAuthErrorSchema.safeParse(data); + if (errorResult.success && errorResult.data.error) { + throw new Error(errorResult.data.error_description ?? errorResult.data.error); + } + + const tokenResult = githubTokenResponseSchema.safeParse(data); + if (!tokenResult.success) { + throw new Error("Invalid GitHub token response"); + } + + return tokenResult.data; +} /** * GitHub OAuth configuration. @@ -45,16 +66,7 @@ export async function exchangeCodeForToken( }), }); - const data = (await response.json()) as GitHubTokenResponse & { - error?: string; - error_description?: string; - }; - - if ("error" in data && data.error) { - throw new Error(data.error_description ?? data.error); - } - - return data; + return parseGitHubTokenResponse(response); } /** @@ -78,16 +90,7 @@ export async function refreshAccessToken( }), }); - const data = (await response.json()) as GitHubTokenResponse & { - error?: string; - error_description?: string; - }; - - if ("error" in data && data.error) { - throw new Error(data.error_description ?? data.error); - } - - return data; + return parseGitHubTokenResponse(response); } /** diff --git a/packages/control-plane/src/auth/openai.test.ts b/packages/control-plane/src/auth/openai.test.ts index 53443b95a..f4037669d 100644 --- a/packages/control-plane/src/auth/openai.test.ts +++ b/packages/control-plane/src/auth/openai.test.ts @@ -20,7 +20,8 @@ describe("openai", () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve(mockTokens), + status: 200, + text: () => Promise.resolve(JSON.stringify(mockTokens)), } as unknown as Response); const result = await refreshOpenAIToken("rt_old"); @@ -37,6 +38,35 @@ describe("openai", () => { expect(init.body).toContain("client_id=app_EMoamEEZ73f0CkXaXp7hrann"); }); + it("returns tokens when optional expires_in is omitted", async () => { + const mockTokens: OpenAITokenResponse = { + id_token: "id.jwt.token", + access_token: "acc_123", + refresh_token: "rt_new", + }; + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(mockTokens)), + } as unknown as Response); + + await expect(refreshOpenAIToken("rt_old")).resolves.toEqual(mockTokens); + }); + + it("throws OpenAITokenRefreshError on malformed success response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve('{"access_token":"acc_123"}'), + } as unknown as Response); + + const err = await refreshOpenAIToken("rt_old").catch((e) => e); + expect(err).toBeInstanceOf(OpenAITokenRefreshError); + expect(err.status).toBe(200); + expect(err.body).toBe('{"access_token":"acc_123"}'); + }); + it("throws OpenAITokenRefreshError on 401 with status and body", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/packages/control-plane/src/auth/openai.ts b/packages/control-plane/src/auth/openai.ts index bc525b508..2e996541e 100644 --- a/packages/control-plane/src/auth/openai.ts +++ b/packages/control-plane/src/auth/openai.ts @@ -2,15 +2,19 @@ * OpenAI OAuth token refresh utilities. */ +import { z } from "zod"; + const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token"; const OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -export interface OpenAITokenResponse { - id_token: string; - access_token: string; - refresh_token: string; - expires_in?: number; -} +export const openAITokenResponseSchema = z.object({ + id_token: z.string(), + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.number().optional(), +}); + +export type OpenAITokenResponse = z.infer; export class OpenAITokenRefreshError extends Error { constructor( @@ -47,7 +51,18 @@ export async function refreshOpenAIToken(refreshToken: string): Promise; + const body = await response.text(); + const parsed: unknown = JSON.parse(body); + const tokenResult = openAITokenResponseSchema.safeParse(parsed); + if (!tokenResult.success) { + throw new OpenAITokenRefreshError( + `OpenAI token refresh returned invalid response: ${response.status}`, + response.status, + body + ); + } + + return tokenResult.data; } /** diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index c09959db5..bc730785d 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -9,6 +9,7 @@ import type { ParticipantRole, SessionStatus, } from "@open-inspect/shared"; +import { z } from "zod"; export type { ArtifactType, @@ -178,10 +179,12 @@ export interface GitHubUser { avatar_url: string; } -export interface GitHubTokenResponse { - access_token: string; - token_type: string; - scope: string; - refresh_token?: string; - expires_in?: number; -} +export const githubTokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), + scope: z.string(), + refresh_token: z.string().optional(), + expires_in: z.number().optional(), +}); + +export type GitHubTokenResponse = z.infer;