feat(frontend): refresh token handling (#54)
Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
@@ -8,9 +8,9 @@ type AuthContextValue = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Exchange a Google credential for a session, then load the user. */
|
/** Exchange a Google credential for a session, then load the user. */
|
||||||
login: (googleCredential: string) => Promise<void>;
|
login: (googleCredential: string) => Promise<void>;
|
||||||
/** End the session and clear the user. */
|
/** End the session and clear the user. The local session is cleared even if the server call fails. */
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
/** Re-fetch the current user (e.g. after a profile change). */
|
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,66 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { User } from "../utils/types.ts";
|
import type { User } from "../utils/types.ts";
|
||||||
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
||||||
import { AuthContext } from "./AuthContext.ts";
|
import { AuthContext } from "./AuthContext.ts";
|
||||||
import {registerUnauthorizedHandler} from "../utils/api/client.ts";
|
import {
|
||||||
|
refreshSession,
|
||||||
|
registerSessionRefreshedHandler,
|
||||||
|
registerUnauthorizedHandler,
|
||||||
|
} from "../utils/api/client.ts";
|
||||||
|
|
||||||
|
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
|
||||||
|
const REFRESH_MARGIN_MS = 60_000;
|
||||||
|
/** How soon to try again when a scheduled refresh fails because the network is down. */
|
||||||
|
const RETRY_DELAY_MS = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the single source of truth for the authenticated user. Everything that
|
* Owns the single source of truth for the authenticated user. Everything that
|
||||||
* needs to know who is logged in — the layout, the top bar, protected pages —
|
* needs to know who is logged in — the layout, the top bar, protected pages —
|
||||||
* reads it through the `useAuth` hook rather than fetching the user itself.
|
* reads it through the `useAuth` hook rather than fetching the user itself.
|
||||||
|
*
|
||||||
|
* It also keeps the session alive: the access token is refreshed shortly
|
||||||
|
* before it expires, rather than waiting for a request to fail. That matters
|
||||||
|
* for <video> elements, which load media straight from the API and can't
|
||||||
|
* retry on a 401 the way apiFetch does.
|
||||||
*/
|
*/
|
||||||
const AuthProvider = ({ children }: { children: ReactNode }) => {
|
const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
|
||||||
|
const refreshDueRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const cancelScheduledRefresh = useCallback(() => {
|
||||||
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
refreshDueRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const endSession = useCallback(() => {
|
||||||
|
cancelScheduledRefresh();
|
||||||
|
setUser(null);
|
||||||
|
}, [cancelScheduledRefresh]);
|
||||||
|
|
||||||
|
const scheduleRefreshIn = useCallback((delayMs: number) => {
|
||||||
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||||
|
refreshDueRef.current = Date.now() + delayMs;
|
||||||
|
refreshTimerRef.current = setTimeout(() => {
|
||||||
|
refreshSession()
|
||||||
|
.then((refreshed) => { if (!refreshed) endSession(); })
|
||||||
|
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
|
||||||
|
}, delayMs);
|
||||||
|
}, [endSession]);
|
||||||
|
|
||||||
|
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
|
||||||
|
// reports the new access token lifetime here, so the timer always tracks
|
||||||
|
// the live cookie.
|
||||||
|
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
|
||||||
|
const lifetimeMs = accessTokenExpiresIn * 1000;
|
||||||
|
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
|
||||||
|
}, [scheduleRefreshIn]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setUser(await getUser());
|
setUser(await getUser());
|
||||||
@@ -24,22 +71,50 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh().finally(() => setLoading(false));
|
registerUnauthorizedHandler(endSession);
|
||||||
|
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
|
||||||
|
}, [endSession, scheduleRefreshBeforeExpiry]);
|
||||||
|
|
||||||
|
// Restore the session on page load. Refreshing first means a returning
|
||||||
|
// user whose access token expired while they were away is logged straight
|
||||||
|
// back in, and gives us the expiry to schedule the next refresh against.
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSession()
|
||||||
|
.catch((error) => console.error("Failed to refresh session:", error))
|
||||||
|
.then(refresh)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Timers are throttled in background tabs and paused while the machine
|
||||||
|
// sleeps, so catch up as soon as the user comes back.
|
||||||
|
useEffect(() => {
|
||||||
|
const refreshIfDue = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const due = refreshDueRef.current;
|
||||||
|
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", refreshIfDue);
|
||||||
|
window.addEventListener("online", refreshIfDue);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", refreshIfDue);
|
||||||
|
window.removeEventListener("online", refreshIfDue);
|
||||||
|
};
|
||||||
|
}, [scheduleRefreshIn]);
|
||||||
|
|
||||||
|
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
|
||||||
|
|
||||||
const login = useCallback(async (googleCredential: string) => {
|
const login = useCallback(async (googleCredential: string) => {
|
||||||
await apiLogin(googleCredential);
|
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential));
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [refresh]);
|
}, [refresh, scheduleRefreshBeforeExpiry]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
// Drop the local session even if the server call fails, so the user
|
||||||
|
// is never left looking logged in after clicking log out.
|
||||||
|
endSession();
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
await refresh();
|
}, [endSession]);
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
registerUnauthorizedHandler(() => setUser(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {config} from "../../config.ts";
|
import {config} from "../../config.ts";
|
||||||
|
import type { APIResponse } from "../types.ts";
|
||||||
|
|
||||||
const API_URL = config.apiUrl;
|
const API_URL = config.apiUrl;
|
||||||
|
|
||||||
@@ -7,28 +8,72 @@ class AuthError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let onUnauthorized: (() => void) | null = null;
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null;
|
||||||
|
|
||||||
export function registerUnauthorizedHandler(handler: () => void) {
|
export function registerUnauthorizedHandler(handler: () => void) {
|
||||||
onUnauthorized = handler;
|
onUnauthorized = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
|
||||||
|
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
|
||||||
|
onSessionRefreshed = handler;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs a request against the API, sending session cookies and funnelling
|
* Exchanges the refresh token cookie for new access and refresh token cookies.
|
||||||
* expired/missing sessions through the registered unauthorized handler so the
|
* Concurrent callers share a single request, so a burst of 401s only rotates
|
||||||
* UI can drop back to a logged out state. Returns the raw response so callers
|
* the refresh token once.
|
||||||
* that need something other than JSON (blobs, streams) can read it themselves.
|
*
|
||||||
|
* @returns true if the session was refreshed, false if it has ended (no valid
|
||||||
|
* refresh token). Throws on network errors so a dropped connection isn't
|
||||||
|
* mistaken for being logged out.
|
||||||
*/
|
*/
|
||||||
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
|
function refreshSession(): Promise<boolean> {
|
||||||
const response = await fetch(url, {
|
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
|
||||||
...options,
|
return refreshInFlight;
|
||||||
credentials: 'include'
|
}
|
||||||
|
|
||||||
|
async function requestRefresh(): Promise<boolean> {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status == 401 || response.status == 403) {
|
if (!response.ok) return false;
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a request against the API, sending session cookies. An expired
|
||||||
|
* access token is refreshed and the request retried once, so callers only see
|
||||||
|
* an AuthError when the session has really ended — at which point the
|
||||||
|
* registered unauthorized handler drops the UI back to a logged out state.
|
||||||
|
* Returns the raw response so callers that need something other than JSON
|
||||||
|
* (blobs, streams) can read it themselves.
|
||||||
|
*/
|
||||||
|
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
|
const send = () => fetch(url, { ...options, credentials: 'include' });
|
||||||
|
|
||||||
|
let response = await send();
|
||||||
|
|
||||||
|
if (response.status == 401 && await refreshSession()) {
|
||||||
|
response = await send();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status == 401) {
|
||||||
onUnauthorized?.();
|
onUnauthorized?.();
|
||||||
throw new AuthError();
|
throw new AuthError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logged in, but not allowed to access this resource: don't end the session.
|
||||||
|
if (response.status == 403) {
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let message = text;
|
let message = text;
|
||||||
@@ -45,4 +90,4 @@ async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return text ? JSON.parse(text) : (undefined as T);
|
return text ? JSON.parse(text) : (undefined as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { API_URL, AuthError, apiFetch, apiFetchRaw };
|
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession };
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import type { APIResponse, User } from "../types.ts";
|
import type { APIResponse, User } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
// login and getUser deliberately use fetch directly: they run when there may be
|
// These deliberately use fetch directly rather than apiFetch: they run when
|
||||||
// no session at all, and getUser is what the unauthorized handler calls to
|
// there may be no session at all, and apiFetch's refresh-and-retry on 401
|
||||||
// refresh — routing them through apiFetch would loop on a 401.
|
// would either loop or log the user out from under the auth provider.
|
||||||
const login = async (googleToken: string): Promise<string> => {
|
|
||||||
|
/**
|
||||||
|
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
|
||||||
|
* cookies; only the access token lifetime is returned.
|
||||||
|
*
|
||||||
|
* @returns seconds until the access token expires
|
||||||
|
*/
|
||||||
|
const login = async (googleToken: string): Promise<number> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -17,12 +24,17 @@ const login = async (googleToken: string): Promise<string> => {
|
|||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||||
|
|
||||||
return result.data.token;
|
return result.data.accessTokenExpiresIn;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Revokes the session and clears the auth cookies. */
|
||||||
const logout = async (): Promise<void> => {
|
const logout = async (): Promise<void> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
|
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||||
if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUser = async (): Promise<User | null> => {
|
const getUser = async (): Promise<User | null> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user