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
23 changes: 23 additions & 0 deletions src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,26 @@ export const getEnvironment = (): 'development' | 'staging' | 'production' => {
if (nodeEnv === 'staging' || nodeEnv === 'test') return 'staging';
return 'development';
};

export interface AuthConfig {
/** Endpoint used to exchange a refresh token for a new access token. */
refreshEndpoint: string;
/** Milliseconds before `exp` at which a token becomes due for refresh. */
refreshSkewMs: number;
}

/**
* Resolves the authentication token-lifecycle configuration, allowing the
* refresh endpoint and skew to be overridden per environment via
* `NEXT_PUBLIC_AUTH_REFRESH_ENDPOINT` / `NEXT_PUBLIC_AUTH_REFRESH_SKEW_MS`.
*/
export const getAuthConfig = (): AuthConfig => {
const endpoint = process.env.NEXT_PUBLIC_AUTH_REFRESH_ENDPOINT;
const skewRaw = process.env.NEXT_PUBLIC_AUTH_REFRESH_SKEW_MS;
const skew = skewRaw ? Number.parseInt(skewRaw, 10) : NaN;

return {
refreshEndpoint: endpoint && endpoint.length > 0 ? endpoint : '/api/auth/refresh',
refreshSkewMs: Number.isFinite(skew) && skew >= 0 ? skew : 60_000,
};
};
11 changes: 11 additions & 0 deletions src/constants/app.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,19 @@ export const STARKNET_NETWORKS = {
export const STORAGE_KEYS = {
PERF_TRENDS: 'teachlink:perf:trends',
AUTH_TOKEN: 'token',
REFRESH_TOKEN: 'refresh_token',
};

/**
* How long before an access token's `exp` the token manager treats it as due
* for a silent refresh. A generous skew keeps long-lived sockets and queued
* offline operations from ever carrying a token that lapses mid-flight.
*/
export const AUTH_REFRESH_SKEW_MS = 60_000;

/** Default endpoint used to exchange a refresh token for a new access token. */
export const AUTH_REFRESH_ENDPOINT = '/api/auth/refresh';

/**
* Domains permitted in sanitized HTML links and sanitizeUrl().
* Subdomains are automatically permitted (e.g. www.youtube.com matches youtube.com).
Expand Down
26 changes: 24 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
API_CACHE_TTL_DEFAULT,
} from '@/constants/app.constants';
import { logContextStorage } from './logging/context';
import { tokenManager } from '@/lib/auth/tokenManager';

export type { ErrorInfo };

Expand Down Expand Up @@ -45,6 +46,7 @@ export interface RequestConfig extends RequestInit {
schema?: z.ZodSchema;
useCache?: boolean;
_bypassCacheRead?: boolean;
_authRetried?: boolean;
ttl?: number;
}

Expand Down Expand Up @@ -109,6 +111,12 @@ class ApiClientImpl {
}

private getToken(): string | null {
// The token manager is the single source of truth; it hydrates from
// localStorage under STORAGE_KEYS.AUTH_TOKEN, so the value is identical to
// the previous direct read but is now shared with sockets and the offline
// queue and kept fresh by silent refresh.
const managed = tokenManager.getAccessTokenSync();
if (managed) return managed;
if (typeof window === 'undefined') return null;
return localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
}
Expand All @@ -131,7 +139,9 @@ class ApiClientImpl {
}

private async requestWithRetry<T>(config: RequestConfig, attempt = 1): Promise<T> {
const token = this.getToken();
// Proactively ensure a non-expired access token before sending. Concurrent
// requests share a single refresh; falls back to the cached token.
const token = (await tokenManager.getValidAccessToken()) ?? this.getToken();

const baseURL = this.config.baseURL.replace(/\/+$/, '');
const resolvedUrl = getVersionedApiPath(config.url);
Expand Down Expand Up @@ -175,6 +185,18 @@ class ApiClientImpl {
clearTimeout(timer);

if (!response.ok) {
// A 401 triggers one coordinated refresh + replay. The single-flight
// refresh in the token manager collapses concurrent 401s into a single
// network round-trip; `_authRetried` prevents an infinite loop.
if (response.status === 401 && !config._authRetried) {
try {
await tokenManager.refresh();
return this.requestWithRetry<T>({ ...config, _authRetried: true }, 1);
} catch {
// Refresh failed — fall through to the normal 401 error path below.
}
}

if (shouldRetry(response.status, attempt, this.config.maxRetries)) {
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, this.config.retryDelay)));
return this.requestWithRetry<T>(config, attempt + 1);
Expand Down Expand Up @@ -284,4 +306,4 @@ class ApiClientImpl {

// Singleton
export const apiClient = new ApiClientImpl();
export type { ApiClientImpl };
export type { ApiClientImpl };
31 changes: 28 additions & 3 deletions src/lib/apiInterceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './api';

import { createLogger } from '@/lib/logging';
import { tokenManager } from '@/lib/auth/tokenManager';

declare global {
interface Window {
Expand Down Expand Up @@ -62,12 +63,35 @@ export const loggingErrorInterceptor: ErrorInterceptor = async (error: Error) =>
apiLogger.error('API request failed', { error });
};

/**
* Attaches a valid (silently refreshed if needed) access token to outgoing
* requests via the shared token manager, so the REST client, sockets and the
* offline queue all present the same, non-expired credential.
*/
export const authRequestInterceptor: RequestInterceptor = async (config) => {
const token = await tokenManager.getValidAccessToken();
if (token) {
const headers: Record<string, string> = (config.headers as Record<string, string>) || {};
headers['Authorization'] = `Bearer ${token}`;
config.headers = headers;
}
return config;
};

export const authRefreshInterceptor: ErrorInterceptor = async (error: Error) => {
const isUnauthorized = error.message?.includes('401') || error.message?.includes('Unauthorized');
if (!isUnauthorized) return;

if (isUnauthorized && typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
window.location.href = '/login';
// Route the failure through the token manager so a single coordinated refresh
// happens (single-flight). Only if the refresh itself fails do we hard-logout.
try {
await tokenManager.refresh();
} catch {
tokenManager.forceLogout('rest_401');
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
window.location.href = '/login';
}
}
};

Expand Down Expand Up @@ -105,6 +129,7 @@ export const headerEnhancementInterceptor: RequestInterceptor = async (config) =

export function setupApiInterceptors(): void {
apiClient.addRequestInterceptor(loggingRequestInterceptor);
apiClient.addRequestInterceptor(authRequestInterceptor);
apiClient.addRequestInterceptor(timeoutInterceptor);
apiClient.addRequestInterceptor(headerEnhancementInterceptor);

Expand Down
Loading
Loading