Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions packages/control-plane/src/auth/github.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
});
45 changes: 24 additions & 21 deletions packages/control-plane/src/auth/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubTokenResponse> {
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.
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down
32 changes: 31 additions & 1 deletion packages/control-plane/src/auth/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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,
Expand Down
29 changes: 22 additions & 7 deletions packages/control-plane/src/auth/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof openAITokenResponseSchema>;

export class OpenAITokenRefreshError extends Error {
constructor(
Expand Down Expand Up @@ -47,7 +51,18 @@ export async function refreshOpenAIToken(refreshToken: string): Promise<OpenAITo
);
}

return response.json() as Promise<OpenAITokenResponse>;
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;
}

/**
Expand Down
17 changes: 10 additions & 7 deletions packages/control-plane/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ParticipantRole,
SessionStatus,
} from "@open-inspect/shared";
import { z } from "zod";

export type {
ArtifactType,
Expand Down Expand Up @@ -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<typeof githubTokenResponseSchema>;
Loading