Loading…
Access token stored in React state (in-memory), refresh token in HttpOnly cookie. Provides XSS protection for the refresh token while allowing client-side token access.
User submits credentials. Server validates and generates both tokens.
Refresh token in HttpOnly cookie, access token in response body.
Access token stored in React state or module variable (in-memory).
Access token in Authorization header, refresh cookie auto-sent.
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:4000/**
* Strategy: access token in memory + refresh token in an HttpOnly cookie.
*
* The short-lived access token only ever lives in this module's memory, so
* it disappears on reload; the AuthProvider then calls /auth/refresh (the
* browser sends the HttpOnly refresh cookie) to get a new one. XSS cannot
* read the refresh token.
*
* Backend checklist: /auth/login and /auth/refresh return { accessToken }
* in the body and set the refresh token as an HttpOnly cookie; CORS allows
* credentials for this origin.
*/
export const TOKEN_STRATEGY = 'memory-cookie';
export const USES_COOKIES = true;
let accessToken: string | null = null;
const listeners = new Set<() => void>();
export const getAccessToken = (): string | null => accessToken;
/** The refresh token is an HttpOnly cookie, invisible to JavaScript. */
export const getRefreshToken = (): string | null => null;
export const setTokens = (tokens: { accessToken?: string; refreshToken?: string }): void => {
if (tokens.accessToken) {
accessToken = tokens.accessToken;
listeners.forEach((l) => l());
}
};
export const clearTokens = (): void => {
accessToken = null;
listeners.forEach((l) => l());
};
export const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};import { ApiError } from '@/lib/http/api-error';
import { buildUrl, request, type RequestOptions } from '@/lib/http/request';
import { AUTH_API_URL, AUTH_ENDPOINTS, SESSION_EXPIRED_EVENT } from './config';
import {
USES_COOKIES,
clearTokens,
getAccessToken,
getRefreshToken,
setTokens,
} from './token-storage';
import type {
AuthResponse,
ChangePasswordRequest,
LoginRequest,
RefreshResponse,
RegisterRequest,
ResetPasswordRequest,
UpdateProfileRequest,
User,
} from './types';
/** Endpoints where a 401 means "wrong credentials", never "expired session". */
const NO_REFRESH = [AUTH_ENDPOINTS.login, AUTH_ENDPOINTS.register, AUTH_ENDPOINTS.refresh, AUTH_ENDPOINTS.forgotPassword, AUTH_ENDPOINTS.resetPassword];
const urlOf = (input: RequestInfo | URL) =>
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const isNoRefreshUrl = (url: string) => {
const path = url.split('?')[0];
return NO_REFRESH.some((endpoint) => path.endsWith(endpoint));
};
async function performRefresh(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!USES_COOKIES && !refreshToken) return false;
try {
const res = await fetch(buildUrl(AUTH_API_URL, AUTH_ENDPOINTS.refresh), {
method: 'POST',
headers: { Accept: 'application/json', ...(refreshToken ? { 'Content-Type': 'application/json' } : {}) },
body: refreshToken ? JSON.stringify({ refreshToken }) : undefined,
credentials: USES_COOKIES ? 'include' : 'same-origin',
});
if (!res.ok) return false;
const data = (await res.json().catch(() => ({}))) as RefreshResponse;
setTokens(data);
return true;
} catch {
return false;
}
}
let inFlight: Promise<boolean> | null = null;
/**
* Refresh the session once, however many requests hit 401 at the same time.
* With the Web Locks API the refresh is also serialized across browser tabs,
* and a tab that waited while another tab refreshed reuses the new token.
*/
export function refreshSession(): Promise<boolean> {
if (inFlight) return inFlight;
const tokenBefore = getAccessToken();
const run = async () => {
const current = getAccessToken();
if (current && current !== tokenBefore) return true; // another tab already refreshed
return performRefresh();
};
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
// .then() flattens the lock's Promise<Promise<boolean>> type (runtime is already flat).
const pending: Promise<boolean> = locks ? locks.request('auth-refresh', run).then((ok) => ok) : run();
const shared = pending.finally(() => {
inFlight = null;
});
inFlight = shared;
return shared;
}
function notifySessionExpired() {
clearTokens();
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
}
/**
* Drop-in `fetch` for calls to your API: attaches the access token (or sends
* cookies), and on 401 refreshes the session once and retries the request.
*/
export const authFetch: typeof fetch = async (input, init = {}) => {
const send = () => {
const headers = new Headers(init.headers);
const token = getAccessToken();
if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
return fetch(input, { ...init, headers, credentials: USES_COOKIES ? 'include' : init.credentials });
};
const res = await send();
if (res.status !== 401 || isNoRefreshUrl(urlOf(input))) return res;
if (await refreshSession()) {
const retried = await send();
if (retried.status !== 401) return retried;
}
notifySessionExpired();
return res;
};
const call = <T>(path: string, options?: RequestOptions) =>
request<T>(buildUrl(AUTH_API_URL, path), { ...options, fetcher: authFetch });
/** Typed calls for every endpoint in AUTH_ENDPOINTS. All throw ApiError on failure. */
export const authApi = {
login: (body: LoginRequest) => call<AuthResponse>(AUTH_ENDPOINTS.login, { method: 'POST', body }),
register: (body: RegisterRequest) => call<AuthResponse>(AUTH_ENDPOINTS.register, { method: 'POST', body }),
logout: () => {
const refreshToken = getRefreshToken();
return call<void>(AUTH_ENDPOINTS.logout, { method: 'POST', body: refreshToken ? { refreshToken } : undefined });
},
me: () => call<User>(AUTH_ENDPOINTS.me),
forgotPassword: (email: string) => call<void>(AUTH_ENDPOINTS.forgotPassword, { method: 'POST', body: { email } }),
resetPassword: (body: ResetPasswordRequest) => call<void>(AUTH_ENDPOINTS.resetPassword, { method: 'POST', body }),
changePassword: (body: ChangePasswordRequest) => call<void>(AUTH_ENDPOINTS.changePassword, { method: 'POST', body }),
updateProfile: (body: UpdateProfileRequest) => call<User>(AUTH_ENDPOINTS.profile, { method: 'PUT', body }),
deleteAccount: () => call<void>(AUTH_ENDPOINTS.profile, { method: 'DELETE' }),
};
export { ApiError };'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { isApiError } from '@/lib/http/api-error';
import { authApi, refreshSession } from '@/auth/jwt/client';
import { authApi } from '@/auth/jwt/client';
import { SESSION_EXPIRED_EVENT } from '@/auth/jwt/config';
import { clearTokens, getAccessToken, getRefreshToken, setTokens, subscribe } from '@/auth/jwt/token-storage';
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/auth/jwt/token-storage';
import { clearTokens, getAccessToken, setTokens } from '@/auth/jwt/token-storage';
import { clearTokens, setTokens } from '@/auth/jwt/token-storage';
import type { AuthResponse, AuthStatus, LoginRequest, RegisterRequest, User } from '@/auth/jwt/types';
interface AuthContextValue {
user: User | null;
status: AuthStatus;
isAuthenticated: boolean;
/** Sign in; resolves with the user, rejects with ApiError (e.g. 401 wrong credentials). */
login: (credentials: LoginRequest) => Promise<User>;
/** Create an account and sign in. */
register: (data: RegisterRequest) => Promise<User>;
/** Sign out on the server (best effort) and clear local session state. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile update). */
refreshUser: () => Promise<User | null>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const signedOut = useCallback(() => {
setUser(null);
setStatus('unauthenticated');
}, []);
const applySession = useCallback((res: AuthResponse) => {
setTokens({ accessToken: res.accessToken, refreshToken: res.refreshToken });
setUser(res.user);
setStatus('authenticated');
return res.user;
}, []);
const refreshUser = useCallback(async () => {
// The access token only lives in memory: after a reload, trade the refresh cookie for a new one.
if (!getAccessToken()) await refreshSession();
if (!getAccessToken() && !getRefreshToken()) {
signedOut();
return null;
}
try {
const me = await authApi.me();
setUser(me);
setStatus('authenticated');
return me;
} catch (error) {
if (isApiError(error) && (error.status === 401 || error.status === 403)) clearTokens();
signedOut();
return null;
}
}, [signedOut]);
// Restore the session on first load.
useEffect(() => {
void refreshUser();
}, [refreshUser]);
// authFetch fires this when a refresh fails — the session is over.
useEffect(() => {
window.addEventListener(SESSION_EXPIRED_EVENT, signedOut);
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, signedOut);
}, [signedOut]);
// Signing in or out in another tab updates this one.
useEffect(() => subscribe(() => void refreshUser()), [refreshUser]);
const login = useCallback(async (credentials: LoginRequest) => applySession(await authApi.login(credentials)), [applySession]);
const register = useCallback(async (data: RegisterRequest) => applySession(await authApi.register(data)), [applySession]);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch {
// The local session is cleared regardless of what the server says.
} finally {
clearTokens();
signedOut();
}
}, [signedOut]);
const value = useMemo<AuthContextValue>(
() => ({ user, status, isAuthenticated: status === 'authenticated', login, register, logout, refreshUser }),
[user, status, login, register, logout, refreshUser],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}