Skip to content
Open
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
68 changes: 65 additions & 3 deletions app/frontend/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,79 @@ 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<boolean>;

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 <token>` 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.
*
* - 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<paths>({
baseUrl: apiUrl,
fetch: withTimeoutFetch(fetchClient as typeof fetch) as typeof fetch,
fetch: authFetch as typeof fetch,
});
17 changes: 17 additions & 0 deletions app/frontend/src/lib/token-store.ts
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 48 additions & 0 deletions app/frontend/test/api-client-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading