Loading…
Both tokens stored in sessionStorage. Similar to localStorage but clears when tab/browser closes. Provides tab isolation but still vulnerable to XSS.
While sessionStorage provides tab isolation, it is still accessible to JavaScript. If an XSS attack occurs in the same tab, tokens can be stolen. The only advantage over localStorage is that tokens are limited to the current tab.
// localStorage vs sessionStorage
// localStorage: Persists forever
localStorage.setItem('key', 'value');
// ❌ Still there after browser restart
// ❌ Still there after tab close
// ✅ Shared across all tabs
// sessionStorage: Clears when tab closes
sessionStorage.setItem('key', 'value');
// ✅ Gone after tab/browser close
// ❌ Still there on page refresh (same tab)
// ❌ Separate per tab (no cross-tab sync)// Tab Isolation: Each tab has its own sessionStorage
// Tab 1: User logs in → token stored in Tab 1's sessionStorage
// Tab 2: Opened later → no token (separate storage)
// Tab 1: Refreshes → token still there
// Tab 1: Closes → token gone!
// Tab 2: Still no token
// This is DIFFERENT from localStorage:
// localStorage: All tabs share the same storage
// sessionStorage: Each tab has isolated storageUser submits credentials. Server returns both tokens in response body.
Access and refresh tokens stored in sessionStorage (current tab only).
Access token added to Authorization header from sessionStorage.
Tokens automatically cleared when tab or browser closes.
/**
* Strategy: both tokens in sessionStorage.
*
* Simple and works with any backend that returns tokens in the response body,
* but any script running on the page (e.g. via an XSS bug or a compromised
* dependency) can read the tokens. Prefer the cookie strategies for production.
* Tokens live only as long as this tab; other tabs need their own sign-in.
*/
export const TOKEN_STRATEGY = 'sessionStorage';
export const USES_COOKIES = false;
const ACCESS_KEY = 'auth.accessToken';
const REFRESH_KEY = 'auth.refreshToken';
const storage = (): Storage | null => {
try {
return typeof window === 'undefined' ? null : window.sessionStorage;
} catch {
return null; // storage disabled (e.g. privacy mode)
}
};
export const getAccessToken = (): string | null => storage()?.getItem(ACCESS_KEY) ?? null;
export const getRefreshToken = (): string | null => storage()?.getItem(REFRESH_KEY) ?? null;
export const setTokens = (tokens: { accessToken?: string; refreshToken?: string }): void => {
const s = storage();
if (!s) return;
if (tokens.accessToken) s.setItem(ACCESS_KEY, tokens.accessToken);
if (tokens.refreshToken) s.setItem(REFRESH_KEY, tokens.refreshToken);
};
export const clearTokens = (): void => {
const s = storage();
s?.removeItem(ACCESS_KEY);
s?.removeItem(REFRESH_KEY);
};
/** sessionStorage is per tab — there is nothing to sync. */
export const subscribe = (_listener: () => void): (() => void) => () => {};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;
}