diff --git a/src/app/api/v2/ai/agent/github-token/route.test.ts b/src/app/api/v2/ai/agent/github-token/route.test.ts new file mode 100644 index 0000000..2dc88f2 --- /dev/null +++ b/src/app/api/v2/ai/agent/github-token/route.test.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +jest.mock('server/lib/get-user', () => ({ + getUser: jest.fn(), + getRequestUserIdentity: jest.fn(), +})); + +jest.mock('server/lib/agentSession/githubToken', () => ({ + fetchGitHubAuthenticatedUser: jest.fn(), + resolveRequestGitHubUserToken: jest.fn(), +})); + +import { fetchGitHubAuthenticatedUser, resolveRequestGitHubUserToken } from 'server/lib/agentSession/githubToken'; +import { getRequestUserIdentity, getUser } from 'server/lib/get-user'; +import { GET } from './route'; + +const mockGetUser = getUser as jest.Mock; +const mockGetRequestUserIdentity = getRequestUserIdentity as jest.Mock; +const mockResolveRequestGitHubUserToken = resolveRequestGitHubUserToken as jest.Mock; +const mockFetchGitHubAuthenticatedUser = fetchGitHubAuthenticatedUser as jest.Mock; + +function makeRequest(): NextRequest { + return { + headers: new Headers([['x-request-id', 'req-test']]), + nextUrl: new URL('http://localhost/api/v2/ai/agent/github-token'), + } as unknown as NextRequest; +} + +describe('GET /api/v2/ai/agent/github-token', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetUser.mockReturnValue({ + sub: 'user-123', + realm_access: { + roles: ['admin'], + }, + }); + mockGetRequestUserIdentity.mockReturnValue({ + userId: 'user-123', + githubUsername: 'sample-user', + }); + }); + + it('returns 401 when no user is available', async () => { + mockGetUser.mockReturnValue(null); + mockGetRequestUserIdentity.mockReturnValue(null); + + const response = await GET(makeRequest()); + + expect(response.status).toBe(401); + }); + + it('returns 403 when the user is not an admin', async () => { + mockGetUser.mockReturnValue({ + sub: 'user-123', + realm_access: { + roles: ['user'], + }, + }); + + const response = await GET(makeRequest()); + + expect(response.status).toBe(403); + expect(mockResolveRequestGitHubUserToken).not.toHaveBeenCalled(); + expect(mockFetchGitHubAuthenticatedUser).not.toHaveBeenCalled(); + }); + + it('returns a safe failed check when no GitHub token can be fetched', async () => { + mockResolveRequestGitHubUserToken.mockResolvedValue({ + githubUsername: 'sample-user', + githubToken: null, + }); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.data).toEqual({ + keycloakGithubUsername: 'sample-user', + tokenFetched: false, + tokenUsable: false, + githubUserId: null, + githubLogin: null, + matchesKeycloakUsername: null, + githubStatus: null, + scopes: [], + rateLimitRemaining: null, + }); + expect(mockFetchGitHubAuthenticatedUser).not.toHaveBeenCalled(); + }); + + it('probes GitHub and reports a usable token without returning the token', async () => { + mockResolveRequestGitHubUserToken.mockResolvedValue({ + githubUsername: 'sample-user', + githubToken: 'gho_secret_token', + }); + mockFetchGitHubAuthenticatedUser.mockResolvedValue({ + ok: true, + id: 12_345, + login: 'sample-user', + status: 200, + scopes: ['read:user'], + rateLimitRemaining: '57', + }); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mockFetchGitHubAuthenticatedUser).toHaveBeenCalledWith('gho_secret_token'); + expect(JSON.stringify(body)).not.toContain('gho_secret_token'); + expect(body.data).toEqual({ + keycloakGithubUsername: 'sample-user', + tokenFetched: true, + tokenUsable: true, + githubUserId: 12_345, + githubLogin: 'sample-user', + matchesKeycloakUsername: true, + githubStatus: 200, + scopes: ['read:user'], + rateLimitRemaining: '57', + }); + }); +}); diff --git a/src/app/api/v2/ai/agent/github-token/route.ts b/src/app/api/v2/ai/agent/github-token/route.ts new file mode 100644 index 0000000..6c4267c --- /dev/null +++ b/src/app/api/v2/ai/agent/github-token/route.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { fetchGitHubAuthenticatedUser, resolveRequestGitHubUserToken } from 'server/lib/agentSession/githubToken'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { getRequestUserIdentity } from 'server/lib/get-user'; +import { errorResponse, successResponse } from 'server/lib/response'; + +export const dynamic = 'force-dynamic'; + +interface GitHubTokenCheck { + keycloakGithubUsername: string | null; + tokenFetched: boolean; + tokenUsable: boolean; + githubUserId: number | null; + githubLogin: string | null; + matchesKeycloakUsername: boolean | null; + githubStatus: number | null; + scopes: string[]; + rateLimitRemaining: string | null; +} + +/** + * @openapi + * /api/v2/ai/agent/github-token: + * get: + * summary: Check the current user's Keycloak-backed GitHub token + * tags: + * - Agent Sessions + * responses: + * '200': + * description: GitHub token check result + * '401': + * description: Unauthorized + * '403': + * description: Forbidden + */ +const getHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) { + return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + } + + const { githubUsername, githubToken } = await resolveRequestGitHubUserToken(req); + + const baseResult: GitHubTokenCheck = { + keycloakGithubUsername: githubUsername, + tokenFetched: Boolean(githubToken), + tokenUsable: false, + githubUserId: null, + githubLogin: null, + matchesKeycloakUsername: null, + githubStatus: null, + scopes: [], + rateLimitRemaining: null, + }; + + if (!githubToken) { + return successResponse(baseResult, { status: 200 }, req); + } + + const probe = await fetchGitHubAuthenticatedUser(githubToken); + const githubLogin = probe.login; + const matchesKeycloakUsername = + githubUsername && githubLogin ? githubUsername.toLowerCase() === githubLogin.toLowerCase() : null; + + return successResponse( + { + ...baseResult, + tokenUsable: probe.ok, + githubUserId: probe.id, + githubLogin, + matchesKeycloakUsername, + githubStatus: probe.status, + scopes: probe.scopes, + rateLimitRemaining: probe.rateLimitRemaining, + }, + { status: 200 }, + req + ); +}; + +export const GET = createApiHandler(getHandler, { roles: ['admin'] }); diff --git a/src/server/lib/agentSession/__tests__/githubToken.test.ts b/src/server/lib/agentSession/__tests__/githubToken.test.ts index 9f8556a..daa1cb6 100644 --- a/src/server/lib/agentSession/__tests__/githubToken.test.ts +++ b/src/server/lib/agentSession/__tests__/githubToken.test.ts @@ -15,7 +15,13 @@ */ import { NextRequest } from 'next/server'; -import { fetchGitHubBrokerToken, resolveRequestGitHubToken } from '../githubToken'; +import { + fetchGitHubAuthenticatedUser, + fetchGitHubBrokerToken, + getGitHubUsernameFromKeycloakAccessToken, + resolveRequestGitHubToken, + resolveRequestGitHubUserToken, +} from '../githubToken'; const mockGetGithubClientToken = jest.fn(); @@ -34,24 +40,32 @@ jest.mock('server/services/globalConfig', () => ({ }, })); +function makeJwt(claims: Record): string { + return [ + Buffer.from(JSON.stringify({ alg: 'none' }), 'utf8').toString('base64url'), + Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url'), + 'signature', + ].join('.'); +} + describe('githubToken', () => { const originalEnableAuth = process.env.ENABLE_AUTH; const originalIssuer = process.env.KEYCLOAK_ISSUER; const originalInternalIssuer = process.env.KEYCLOAK_ISSUER_INTERNAL; - const originalFetch = global.fetch; + const originalFetch = globalThis.fetch; beforeEach(() => { jest.clearAllMocks(); process.env.KEYCLOAK_ISSUER = 'https://keycloak.example.com/realms/test'; delete process.env.KEYCLOAK_ISSUER_INTERNAL; - global.fetch = jest.fn(); + globalThis.fetch = jest.fn() as unknown as typeof fetch; }); afterAll(() => { process.env.ENABLE_AUTH = originalEnableAuth; process.env.KEYCLOAK_ISSUER = originalIssuer; process.env.KEYCLOAK_ISSUER_INTERNAL = originalInternalIssuer; - global.fetch = originalFetch; + globalThis.fetch = originalFetch; }); it('returns the cached GitHub app token when auth is disabled', async () => { @@ -62,7 +76,7 @@ describe('githubToken', () => { expect(mockGetGithubClientToken).toHaveBeenCalledTimes(1); expect(token).toBe('ghs_cached_app_token'); - expect(global.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); it('returns null when auth is disabled and cached GitHub app token lookup fails', async () => { @@ -73,12 +87,12 @@ describe('githubToken', () => { expect(mockGetGithubClientToken).toHaveBeenCalledTimes(1); expect(token).toBeNull(); - expect(global.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); it('fetches broker token from Keycloak when auth is enabled', async () => { process.env.ENABLE_AUTH = 'true'; - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(JSON.stringify({ access_token: 'gho_broker_token' })), }); @@ -91,7 +105,7 @@ describe('githubToken', () => { }) ); - expect(global.fetch).toHaveBeenCalledWith('https://keycloak.example.com/realms/test/broker/github/token', { + expect(globalThis.fetch).toHaveBeenCalledWith('https://keycloak.example.com/realms/test/broker/github/token', { method: 'GET', headers: { Authorization: 'Bearer keycloak-access-token', @@ -100,10 +114,49 @@ describe('githubToken', () => { expect(token).toBe('gho_broker_token'); }); + it('extracts the GitHub username from a Keycloak access token', () => { + const keycloakAccessToken = makeJwt({ + sub: 'user-123', + github_username: 'sample-user', + }); + + expect(getGitHubUsernameFromKeycloakAccessToken(keycloakAccessToken)).toBe('sample-user'); + }); + + it('resolves the GitHub username and broker token for the request', async () => { + process.env.ENABLE_AUTH = 'true'; + (globalThis.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(JSON.stringify({ access_token: 'gho_broker_token' })), + }); + + const keycloakAccessToken = makeJwt({ + sub: 'user-123', + github_username: 'sample-user', + }); + const req = new NextRequest('http://localhost/api', { + headers: { + authorization: `Bearer ${keycloakAccessToken}`, + 'x-user': Buffer.from( + JSON.stringify({ + sub: 'user-123', + github_username: 'sample-user', + }), + 'utf8' + ).toString('base64url'), + }, + }); + + await expect(resolveRequestGitHubUserToken(req)).resolves.toEqual({ + githubUsername: 'sample-user', + githubToken: 'gho_broker_token', + }); + }); + it('prefers the internal issuer when it is configured', async () => { process.env.ENABLE_AUTH = 'true'; process.env.KEYCLOAK_ISSUER_INTERNAL = 'http://keycloak.internal/realms/test'; - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(JSON.stringify({ access_token: 'gho_internal_token' })), }); @@ -116,7 +169,7 @@ describe('githubToken', () => { }) ); - expect(global.fetch).toHaveBeenCalledWith('http://keycloak.internal/realms/test/broker/github/token', { + expect(globalThis.fetch).toHaveBeenCalledWith('http://keycloak.internal/realms/test/broker/github/token', { method: 'GET', headers: { Authorization: 'Bearer keycloak-access-token', @@ -126,7 +179,7 @@ describe('githubToken', () => { }); it('parses query string token responses from Keycloak', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue('access_token=gho_query_token&expires_in=300'), }); @@ -134,10 +187,40 @@ describe('githubToken', () => { await expect(fetchGitHubBrokerToken('keycloak-access-token')).resolves.toBe('gho_query_token'); }); + it('probes the fetched GitHub token with the authenticated user endpoint', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers([ + ['x-oauth-scopes', 'read:user, repo'], + ['x-ratelimit-remaining', '42'], + ]), + json: jest.fn().mockResolvedValue({ id: 12_345, login: 'sample-user' }), + }); + + await expect(fetchGitHubAuthenticatedUser('gho_broker_token')).resolves.toEqual({ + ok: true, + id: 12_345, + login: 'sample-user', + status: 200, + scopes: ['read:user', 'repo'], + rateLimitRemaining: '42', + }); + expect(globalThis.fetch).toHaveBeenCalledWith('https://api.github.com/user', { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + Authorization: 'Bearer gho_broker_token', + 'User-Agent': 'lifecycle-github-token-check', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + }); + it('returns null when auth is enabled but no bearer token is present', async () => { process.env.ENABLE_AUTH = 'true'; await expect(resolveRequestGitHubToken(new NextRequest('http://localhost/api'))).resolves.toBeNull(); - expect(global.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); diff --git a/src/server/lib/agentSession/githubToken.ts b/src/server/lib/agentSession/githubToken.ts index 6a07016..3ccfdc4 100644 --- a/src/server/lib/agentSession/githubToken.ts +++ b/src/server/lib/agentSession/githubToken.ts @@ -15,11 +15,52 @@ */ import type { NextRequest } from 'next/server'; +import { getRequestUserIdentity } from 'server/lib/get-user'; import { getLogger } from 'server/lib/logger'; import GlobalConfigService from 'server/services/globalConfig'; const logger = () => getLogger(); +interface GitHubAuthenticatedUserResponse { + id?: unknown; + login?: unknown; +} + +export interface RequestGitHubUserToken { + // GitHub handle from the authenticated request, or from the Keycloak access + // token claims when the request identity has not been hydrated yet. + githubUsername: string | null; + // GitHub access token fetched through Keycloak's GitHub identity broker. + // Treat this as sensitive: never log it or return it to the client. + githubToken: string | null; +} + +export interface GitHubAuthenticatedUserProbe { + ok: boolean; + id: number | null; + login: string | null; + status: number; + scopes: string[]; + rateLimitRemaining: string | null; +} + +function normalizeClaim(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + return normalized || null; +} + +function normalizeGitHubUserId(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + return null; + } + + return value; +} + function getBearerToken(req: NextRequest): string | null { const authHeader = req.headers.get('authorization') || req.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { @@ -30,6 +71,30 @@ function getBearerToken(req: NextRequest): string | null { return token || null; } +function decodeJwtPayload(token: string | null | undefined): Record | null { + if (!token) { + return null; + } + + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + try { + return JSON.parse(Buffer.from(payload, 'base64url' as BufferEncoding).toString('utf8')) as Record; + } catch { + return null; + } +} + +export function getGitHubUsernameFromKeycloakAccessToken( + keycloakAccessToken: string | null | undefined +): string | null { + const payload = decodeJwtPayload(keycloakAccessToken); + return normalizeClaim(payload?.github_username) || normalizeClaim(payload?.githubUsername); +} + function parseBrokerTokenResponse(body: string): string | null { const trimmed = body.trim(); if (!trimmed) { @@ -101,3 +166,98 @@ export async function resolveRequestGitHubToken(req: NextRequest): Promise` header. + * + * What this does: + * - Reads the user's GitHub handle from request identity when available. + * - Falls back to the `github_username` / `githubUsername` claim in the + * Keycloak access token. + * - Fetches the GitHub broker token through Keycloak via + * `resolveRequestGitHubToken`. With auth enabled, Keycloak owns the external + * token lookup/refresh flow. + * + * What this does not do: + * - It does not verify the token against GitHub. Use + * `fetchGitHubAuthenticatedUser` when you need to prove the token is usable. + * - It does not expose the token to the browser. Keep the token server-side. + */ +export async function resolveRequestGitHubUserToken(req: NextRequest): Promise { + const keycloakAccessToken = getBearerToken(req); + const userIdentity = getRequestUserIdentity(req); + const githubUsername = userIdentity?.githubUsername || getGitHubUsernameFromKeycloakAccessToken(keycloakAccessToken); + + return { + githubUsername, + githubToken: await resolveRequestGitHubToken(req), + }; +} + +function splitHeaderValues(value: string | null): string[] { + if (!value) { + return []; + } + + return value + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean); +} + +function getResponseHeader(response: Response, name: string): string | null { + const headers = (response as Response & { headers?: Headers }).headers; + if (!headers || typeof headers.get !== 'function') { + return null; + } + + return headers.get(name); +} + +export async function fetchGitHubAuthenticatedUser(githubToken: string): Promise { + const response = await fetch('https://api.github.com/user', { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${githubToken}`, + 'User-Agent': 'lifecycle-github-token-check', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + + const baseProbe = { + status: response.status, + scopes: splitHeaderValues(getResponseHeader(response, 'x-oauth-scopes')), + rateLimitRemaining: getResponseHeader(response, 'x-ratelimit-remaining'), + }; + + if (!response.ok) { + return { + ...baseProbe, + ok: false, + id: null, + login: null, + }; + } + + let body: GitHubAuthenticatedUserResponse | null = null; + try { + body = (await response.json()) as GitHubAuthenticatedUserResponse; + } catch { + body = null; + } + + return { + ...baseProbe, + ok: Boolean(normalizeGitHubUserId(body?.id)), + id: normalizeGitHubUserId(body?.id), + login: normalizeClaim(body?.login), + }; +}