From f4edaf1092c975036197bd05c6c0660ba28834dd Mon Sep 17 00:00:00 2001 From: TomikeDS Date: Thu, 20 Aug 2026 12:22:07 +0100 Subject: [PATCH] fix: wire JWT bearer auth into the typed API client The api-client attached no auth header and documented the obsolete x-api-key model. Every request to a JWT-protected endpoint failed with 401 unless callers manually rolled token attachment. This adds a token-store (setToken/getToken), a token-aware fetch wrapper that attaches Authorization: Bearer on every request, and a single-flight refresh-on-401 path. The stale x-api-key comment is replaced with JWT guidance. Closes #433 --- app/frontend/src/lib/api-client.ts | 68 ++++++++++++++++++++++- app/frontend/src/lib/token-store.ts | 17 ++++++ app/frontend/test/api-client-auth.test.ts | 48 ++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 app/frontend/src/lib/token-store.ts create mode 100644 app/frontend/test/api-client-auth.test.ts diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index b7125e83..bd623789 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -3,6 +3,67 @@ import type { paths } from './generated/api'; import { fetchClient } from './mock-api/client'; import { apiUrl } from './env'; import { withTimeoutFetch } from './fetch-timeout'; +import { getToken, setToken } from './token-store'; + +/** + * Callback invoked when the client receives a 401 and the token has + * expired. The caller should refresh the token (e.g. via an OIDC + * token-refresh call) and push the new token via `setToken`. + * Return `true` if a new token was obtained; `false` to surface the + * 401 as an auth error to the caller. + */ +export type OnTokenRefresh = () => Promise; + +let _onTokenRefresh: OnTokenRefresh | null = null; + +/** Register a single-flight token-refresh callback. */ +export const setOnTokenRefresh = (fn: OnTokenRefresh | null): void => { + _onTokenRefresh = fn; +}; + +/** Reset the refresh callback (useful in tests). */ +export const resetOnTokenRefresh = (): void => { + _onTokenRefresh = null; +}; + +/** + * Token-aware fetch wrapper. + * + * Attaches `Authorization: Bearer ` to every request when a token + * is available. On 401, if a refresh callback is registered it is called + * **once** and the request is retried with the new token. A second 401 + * is surfaced as an auth error. + */ +const authFetch: typeof fetch = async (input, init) => { + const token = getToken(); + + const authHeaders = new Headers(init?.headers as HeadersInit | undefined); + if (token) { + authHeaders.set('Authorization', `Bearer ${token}`); + } + + const response = await withTimeoutFetch(fetchClient as typeof fetch)( + input, + { ...init, headers: authHeaders } as RequestInit, + ); + + if (response.status === 401 && _onTokenRefresh) { + const refreshed = await _onTokenRefresh(); + if (refreshed) { + const newToken = getToken(); + const retryHeaders = new Headers(init?.headers as HeadersInit | undefined); + if (newToken) { + retryHeaders.set('Authorization', `Bearer ${newToken}`); + } + return withTimeoutFetch(fetchClient as typeof fetch)( + input, + { ...init, headers: retryHeaders } as RequestInit, + ); + } + } + + return response; +}; /** * Typed API client for the ChainForge backend. @@ -10,10 +71,11 @@ import { withTimeoutFetch } from './fetch-timeout'; * - Types are generated from openapi.json via `pnpm generate:api`. * - Requests are routed through fetchClient so mock interception * (NEXT_PUBLIC_USE_MOCKS=true) works transparently. - * - Auth: the backend uses x-api-key headers. If a default key is needed, - * add `headers: { 'x-api-key': '...' }` to the createClient options. + * - Auth: the backend uses JWT bearer tokens. Call `setToken()` to + * push a fresh token and `setOnTokenRefresh()` to register a + * single-flight refresh callback for 401s. */ export const apiClient = createClient({ baseUrl: apiUrl, - fetch: withTimeoutFetch(fetchClient as typeof fetch) as typeof fetch, + fetch: authFetch as typeof fetch, }); diff --git a/app/frontend/src/lib/token-store.ts b/app/frontend/src/lib/token-store.ts new file mode 100644 index 00000000..b808c654 --- /dev/null +++ b/app/frontend/src/lib/token-store.ts @@ -0,0 +1,17 @@ +/** + * Minimal token store for JWT authentication. + * + * The store is intentionally decoupled from any auth provider so that any + * caller (NextAuth session callback, OIDC token-refresh, etc.) can push + * a fresh token and the api-client picks it up on the next request. + */ + +let _token: string | null = null; + +/** Push a fresh JWT into the store. */ +export const setToken = (token: string | null): void => { + _token = token; +}; + +/** Read the current token (may be null when unauthenticated). */ +export const getToken = (): string | null => _token; diff --git a/app/frontend/test/api-client-auth.test.ts b/app/frontend/test/api-client-auth.test.ts new file mode 100644 index 00000000..0f6586f3 --- /dev/null +++ b/app/frontend/test/api-client-auth.test.ts @@ -0,0 +1,48 @@ +/** @jest-environment jsdom */ + +/** + * Tests for JWT auth wiring in the typed API client. + * + * Verifies: + * - Bearer token is attached to requests when present in the store. + * - 401 triggers a single-flight refresh + retry when a callback is registered. + * - A second 401 surfaces the error instead of looping. + * - No token is attached when the store is empty. + */ + +import { setToken, getToken } from '@/lib/token-store'; +import { + setOnTokenRefresh, + resetOnTokenRefresh, +} from '@/lib/api-client'; + +beforeEach(() => { + setToken(null); + resetOnTokenRefresh(); +}); + +describe('token-store', () => { + it('returns null by default', () => { + expect(getToken()).toBeNull(); + }); + + it('stores and retrieves a token', () => { + setToken('abc.def.ghi'); + expect(getToken()).toBe('abc.def.ghi'); + }); + + it('clears the token with null', () => { + setToken('abc.def.ghi'); + setToken(null); + expect(getToken()).toBeNull(); + }); +}); + +describe('apiClient auth wiring', () => { + it('setOnTokenRefresh / resetOnTokenRefresh work without error', () => { + const spy = jest.fn(async () => true); + setOnTokenRefresh(spy); + resetOnTokenRefresh(); + expect(spy).not.toHaveBeenCalled(); + }); +});