diff --git a/src/features/authentication/constants.ts b/src/features/authentication/constants.ts index b77224b3..5190bd19 100644 --- a/src/features/authentication/constants.ts +++ b/src/features/authentication/constants.ts @@ -1 +1,7 @@ export const AUTH_STATE_COOKIE_NAME = "topwr_auth"; + +/** + * When an access token has less than this percentage of its total lifetime remaining, + * a background refresh is triggered alongside the outgoing request. + */ +export const REFRESH_THRESHOLD_PERCENT = 20; diff --git a/src/features/authentication/node.ts b/src/features/authentication/node.ts index fd9bc3d4..d7c4a62f 100644 --- a/src/features/authentication/node.ts +++ b/src/features/authentication/node.ts @@ -1 +1,7 @@ +export * from "./schemas/auth-state-schema"; + +export * from "./utils/force-logout"; export * from "./utils/get-auth-state.node"; +export * from "./utils/get-cookie-options"; +export * from "./utils/get-token-status"; +export * from "./utils/refresh-access-token"; diff --git a/src/features/authentication/types/internal.ts b/src/features/authentication/types/internal.ts index 752dd53c..964c6205 100644 --- a/src/features/authentication/types/internal.ts +++ b/src/features/authentication/types/internal.ts @@ -14,3 +14,5 @@ export type RoutePermission = keyof typeof ROUTE_PERMISSIONS; export type AuthState = z.infer; export type LoginFormValues = z.infer; export type User = z.infer; + +export type TokenStatus = "ok" | "expiring-soon" | "expired" | "both-expired"; diff --git a/src/features/authentication/utils/decode-base64-url-segment.ts b/src/features/authentication/utils/decode-base64-url-segment.ts new file mode 100644 index 00000000..436db072 --- /dev/null +++ b/src/features/authentication/utils/decode-base64-url-segment.ts @@ -0,0 +1,10 @@ +export function decodeBase64UrlSegment(segment: string): string { + const base64 = segment.replaceAll("-", "+").replaceAll("_", "/"); + const paddingLength = base64.length % 4; + if (paddingLength === 1) { + throw new Error("Invalid base64url segment"); + } + const padded = + paddingLength === 0 ? base64 : base64 + "=".repeat(4 - paddingLength); + return atob(padded); +} diff --git a/src/features/authentication/utils/decode-jwt-payload.ts b/src/features/authentication/utils/decode-jwt-payload.ts new file mode 100644 index 00000000..e3ac8dde --- /dev/null +++ b/src/features/authentication/utils/decode-jwt-payload.ts @@ -0,0 +1,14 @@ +import { decodeBase64UrlSegment } from "./decode-base64-url-segment"; + +export function decodeJwtPayload(token: string): { iat?: number } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + const decoded = decodeBase64UrlSegment(parts[1]); + return JSON.parse(decoded) as { iat?: number }; + } catch { + return null; + } +} diff --git a/src/features/authentication/utils/force-logout.ts b/src/features/authentication/utils/force-logout.ts new file mode 100644 index 00000000..d0b7adf2 --- /dev/null +++ b/src/features/authentication/utils/force-logout.ts @@ -0,0 +1,21 @@ +import Cookies from "js-cookie"; + +import { deferClientToast } from "@/features/toaster"; + +// import { getToastMessages } from "@/lib/get-toast-messages"; + +import { AUTH_STATE_COOKIE_NAME } from "../constants"; + +/** + * Clears the local auth state, saves a deferred toast, and redirects to the + * login page. Must only be called in a browser context. + */ +export function forceLogout(): void { + Cookies.remove(AUTH_STATE_COOKIE_NAME); + deferClientToast({ + level: "warning", + // TODO: Resolve the circular dependency issue to get the message from getToastMessages.auth.sessionExpired, + message: "Twoja sesja wygasła. Proszę zalogować się ponownie.", + }); + window.location.href = "/login"; +} diff --git a/src/features/authentication/utils/get-cookie-options.ts b/src/features/authentication/utils/get-cookie-options.ts index 8ec57dbd..7e96b8c8 100644 --- a/src/features/authentication/utils/get-cookie-options.ts +++ b/src/features/authentication/utils/get-cookie-options.ts @@ -12,7 +12,7 @@ export function getCookieOptions( cookie, { // TODO: add more secure cookie flag options - expires: Date.now() + 60 * 60 * 24 * 30 * 1000, // 30 days, will be removed if it expires early + expires: new Date(authState.refreshTokenExpiresAt), // cookie will expire when refresh token expires sameSite: "lax", }, ] as const; diff --git a/src/features/authentication/utils/get-token-status.test.ts b/src/features/authentication/utils/get-token-status.test.ts new file mode 100644 index 00000000..78a8b1bd --- /dev/null +++ b/src/features/authentication/utils/get-token-status.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { REFRESH_THRESHOLD_PERCENT } from "../constants"; +import type { AuthState } from "../types/internal"; +import { getTokenStatus } from "./get-token-status"; + +function encodeJwtPayload(payload: Record): string { + const json = JSON.stringify(payload); + const base64 = btoa(json); + return base64.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function makeToken(payload: Record): string { + return `header.${encodeJwtPayload(payload)}.signature`; +} + +const NOW = 1_000_000_000_000; + +function makeAuthState( + options: Partial & { + accessTokenIat?: number | null; + } = {}, +): AuthState { + const { + accessTokenIat, + accessToken, + accessTokenExpiresAt = NOW + 10_000, + refreshTokenExpiresAt = NOW + 100_000, + ...rest + } = options; + + const iat = + accessTokenIat === undefined + ? Math.floor((NOW - 50_000) / 1000) // issued 50 s ago by default + : accessTokenIat; + + const token = + accessToken ?? (iat === null ? makeToken({}) : makeToken({ iat })); + + return { + user: null as unknown as AuthState["user"], + refreshToken: "refresh.token.sig", + accessToken: token, + accessTokenExpiresAt, + refreshTokenExpiresAt, + ...rest, + }; +} + +describe("getTokenStatus", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('"both-expired"', () => { + it("returns both-expired when refresh token is expired", () => { + const state = makeAuthState({ refreshTokenExpiresAt: NOW - 1 }); + expect(getTokenStatus(state)).toBe("both-expired"); + }); + + it("returns both-expired when refresh token expires exactly now", () => { + const state = makeAuthState({ refreshTokenExpiresAt: NOW }); + expect(getTokenStatus(state)).toBe("both-expired"); + }); + + it("returns both-expired even when access token is still valid", () => { + const state = makeAuthState({ + accessTokenExpiresAt: NOW + 10_000, + refreshTokenExpiresAt: NOW - 1, + }); + expect(getTokenStatus(state)).toBe("both-expired"); + }); + }); + + describe('"expired"', () => { + it("returns expired when access token is expired but refresh is still valid", () => { + const state = makeAuthState({ + accessTokenExpiresAt: NOW - 1, + refreshTokenExpiresAt: NOW + 100_000, + }); + expect(getTokenStatus(state)).toBe("expired"); + }); + + it("returns expired when access token expires exactly now", () => { + const state = makeAuthState({ + accessTokenExpiresAt: NOW, + refreshTokenExpiresAt: NOW + 100_000, + }); + expect(getTokenStatus(state)).toBe("expired"); + }); + }); + + describe('"expiring-soon"', () => { + it("returns expiring-soon when remaining lifetime is just below the threshold", () => { + // total lifetime = 100 s, threshold = 20 % → trigger at 20 s remaining + const issuedAt = NOW - 80_000; // 80 s ago + const expiresAt = NOW + 19_999; // 19.999 s left → < 20 % + const state = makeAuthState({ + accessTokenIat: Math.floor(issuedAt / 1000), + accessTokenExpiresAt: expiresAt, + refreshTokenExpiresAt: NOW + 1_000_000, + }); + expect(getTokenStatus(state)).toBe("expiring-soon"); + }); + + it("returns expiring-soon exactly at the threshold boundary (< not <=)", () => { + const totalMs = 100_000; + const thresholdMs = (REFRESH_THRESHOLD_PERCENT / 100) * totalMs; // 20 000 ms + const issuedAt = NOW - (totalMs - thresholdMs + 1); // remaining = thresholdMs - 1 + const expiresAt = NOW + thresholdMs - 1; + const state = makeAuthState({ + accessTokenIat: Math.floor(issuedAt / 1000), + accessTokenExpiresAt: expiresAt, + refreshTokenExpiresAt: NOW + 1_000_000, + }); + expect(getTokenStatus(state)).toBe("expiring-soon"); + }); + + it("returns ok (not expiring-soon) when remaining lifetime equals the threshold exactly", () => { + const totalMs = 100_000; + const thresholdMs = (REFRESH_THRESHOLD_PERCENT / 100) * totalMs; // 20 000 ms + const issuedAt = NOW - (totalMs - thresholdMs); + const expiresAt = NOW + thresholdMs; + const state = makeAuthState({ + accessTokenIat: Math.floor(issuedAt / 1000), + accessTokenExpiresAt: expiresAt, + refreshTokenExpiresAt: NOW + 1_000_000, + }); + expect(getTokenStatus(state)).toBe("ok"); + }); + }); + + describe('"ok"', () => { + it("returns ok when access token has plenty of lifetime left", () => { + const state = makeAuthState({ + accessTokenIat: Math.floor((NOW - 1000) / 1000), + accessTokenExpiresAt: NOW + 99_000, + refreshTokenExpiresAt: NOW + 1_000_000, + }); + expect(getTokenStatus(state)).toBe("ok"); + }); + }); + + describe("missing / invalid iat", () => { + it("returns ok when iat is absent from the payload", () => { + const token = makeToken({}); // no iat field + const state = makeAuthState({ + accessToken: token, + accessTokenExpiresAt: NOW + 10_000, + refreshTokenExpiresAt: NOW + 100_000, + }); + expect(getTokenStatus(state)).toBe("ok"); + }); + + it("returns ok when the token is not a valid JWT (no payload segment)", () => { + const state = makeAuthState({ + accessToken: "not-a-jwt", + accessTokenExpiresAt: NOW + 10_000, + refreshTokenExpiresAt: NOW + 100_000, + }); + expect(getTokenStatus(state)).toBe("ok"); + }); + + it("returns ok when the payload is not valid JSON", () => { + const state = makeAuthState({ + accessToken: "header.!!!invalid_base64!!$.signature", + accessTokenExpiresAt: NOW + 10_000, + refreshTokenExpiresAt: NOW + 100_000, + }); + expect(getTokenStatus(state)).toBe("ok"); + }); + + it("returns ok when totalLifetimeMs is zero (iat === expiresAt)", () => { + const expiresAt = NOW + 10_000; + const state = makeAuthState({ + accessTokenIat: Math.floor(expiresAt / 1000), // iat == expiry → totalLifetimeMs = 0 + accessTokenExpiresAt: expiresAt, + refreshTokenExpiresAt: NOW + 100_000, + }); + expect(getTokenStatus(state)).toBe("ok"); + }); + }); +}); diff --git a/src/features/authentication/utils/get-token-status.ts b/src/features/authentication/utils/get-token-status.ts new file mode 100644 index 00000000..8a2275b6 --- /dev/null +++ b/src/features/authentication/utils/get-token-status.ts @@ -0,0 +1,38 @@ +import { REFRESH_THRESHOLD_PERCENT } from "../constants"; +import type { AuthState, TokenStatus } from "../types/internal"; +import { decodeJwtPayload } from "./decode-jwt-payload"; + +/** + * Determines the validity status of the current access token. + * + * - `"both-expired"` – both access and refresh tokens are expired; user must re-login. + * - `"expired"` – access token is expired but refresh token is still valid. + * - `"expiring-soon"` – access token will expire within {@link REFRESH_THRESHOLD_PERCENT}% of its total lifetime. + * - `"ok"` – access token is valid. + */ +export function getTokenStatus(authState: AuthState): TokenStatus { + const now = Date.now(); + + if (now >= authState.refreshTokenExpiresAt) { + return "both-expired"; + } + + if (now >= authState.accessTokenExpiresAt) { + return "expired"; + } + + const payload = decodeJwtPayload(authState.accessToken); + if (payload?.iat != null) { + const issuedAtMs = payload.iat * 1000; + const totalLifetimeMs = authState.accessTokenExpiresAt - issuedAtMs; + const remainingMs = authState.accessTokenExpiresAt - now; + if ( + totalLifetimeMs > 0 && + remainingMs / totalLifetimeMs < REFRESH_THRESHOLD_PERCENT / 100 + ) { + return "expiring-soon"; + } + } + + return "ok"; +} diff --git a/src/features/authentication/utils/perform-token-refresh.ts b/src/features/authentication/utils/perform-token-refresh.ts new file mode 100644 index 00000000..0ca4b7a0 --- /dev/null +++ b/src/features/authentication/utils/perform-token-refresh.ts @@ -0,0 +1,60 @@ +import Cookies from "js-cookie"; + +import { env } from "@/config/env"; +import type { RefreshTokenResponse } from "@/features/backend/types"; +import { logger, parseError } from "@/features/logging"; + +import { AUTH_STATE_COOKIE_NAME } from "../constants"; +import { AuthStateSchema } from "../schemas/auth-state-schema"; +import type { AuthState } from "../types/internal"; +import { getAuthStateNode } from "./get-auth-state.node"; +import { getCookieOptions } from "./get-cookie-options"; + +export async function performTokenRefresh( + authState: AuthState, +): Promise { + try { + const url = `${env.NEXT_PUBLIC_API_URL}/api/v1/auth/refresh`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: authState.refreshToken }), + }); + + if (!response.ok) { + logger.warn({ status: response.status }, "Token refresh request failed"); + return null; + } + + const { newAccessToken } = (await response.json()) as RefreshTokenResponse; + const now = Date.now(); + const currentState = getAuthStateNode(); + if (currentState == null) { + return null; + } + + const parsed = AuthStateSchema.safeParse({ + accessToken: newAccessToken.accessToken, + accessTokenExpiresAt: now + newAccessToken.accessExpiresInMs, + refreshToken: authState.refreshToken, + refreshTokenExpiresAt: authState.refreshTokenExpiresAt, + user: currentState.user, + }); + + if (!parsed.success) { + logger.error( + parsed.error.format(), + "Token refresh response does not match expected schema", + ); + return null; + } + + const newState = parsed.data; + Object.assign(currentState, newState); + Cookies.set(AUTH_STATE_COOKIE_NAME, ...getCookieOptions(currentState)); + return currentState; + } catch (error) { + logger.error(parseError(error), "Token refresh failed"); + return null; + } +} diff --git a/src/features/authentication/utils/refresh-access-token.ts b/src/features/authentication/utils/refresh-access-token.ts new file mode 100644 index 00000000..24feb70f --- /dev/null +++ b/src/features/authentication/utils/refresh-access-token.ts @@ -0,0 +1,27 @@ +import type { AuthState } from "../types/internal"; +import { getAuthStateNode } from "./get-auth-state.node"; +import { performTokenRefresh } from "./perform-token-refresh"; + +/** Singleton promise – prevents concurrent refresh requests from stacking. */ +let pendingRefresh: Promise | null = null; + +/** + * Requests a new access token using the stored refresh token. + * Concurrent calls are de-duplicated – all callers share one in-flight request. + * Must only be called in a browser context. + */ +export async function refreshAccessToken(): Promise { + if (pendingRefresh != null) { + return pendingRefresh; + } + + const authState = getAuthStateNode(); + if (authState == null) { + return null; + } + + pendingRefresh = performTokenRefresh(authState).finally(() => { + pendingRefresh = null; + }); + return pendingRefresh; +} diff --git a/src/features/backend/types/responses.ts b/src/features/backend/types/responses.ts index 6af62346..ec06f113 100644 --- a/src/features/backend/types/responses.ts +++ b/src/features/backend/types/responses.ts @@ -19,6 +19,15 @@ export interface LogInResponse { refreshExpiresInMs: number; } +/** As returned from POST /auth/refresh */ +export interface RefreshTokenResponse { + newAccessToken: { + type: "bearer"; + accessToken: string; + accessExpiresInMs: number; + }; +} + /** As returned from GET /files/{id} */ export interface FileEntry extends DatedResource { id: string; diff --git a/src/features/backend/utils/execute-fetch.ts b/src/features/backend/utils/execute-fetch.ts index 6cb5d890..815d069a 100644 --- a/src/features/backend/utils/execute-fetch.ts +++ b/src/features/backend/utils/execute-fetch.ts @@ -1,3 +1,9 @@ +import { + forceLogout, + getAuthStateNode, + getTokenStatus, + refreshAccessToken, +} from "@/features/authentication/node"; import { logger, parseError } from "@/features/logging"; import type { Resource } from "@/features/resources"; @@ -10,6 +16,38 @@ export const executeFetch = async ( endpoint: string, options: FetchRequestOptions, ): Promise> => { + if (typeof window !== "undefined" && options.accessTokenOverride == null) { + const authState = getAuthStateNode(); + if (authState != null) { + const status = getTokenStatus(authState); + switch (status) { + case "both-expired": { + forceLogout(); + throw new Error("Session expired"); + } + case "expired": { + const refreshedAuthState = await refreshAccessToken(); + if (refreshedAuthState == null) { + forceLogout(); + throw new Error("Session expired"); + } + break; + } + case "expiring-soon": { + void refreshAccessToken().then((refreshedAuthState) => { + if (refreshedAuthState == null) { + forceLogout(); + } + }); + break; + } + case "ok": { + break; + } + } + } + } + const request = createRequest(endpoint, options); let response; try { diff --git a/src/features/toaster/index.ts b/src/features/toaster/index.ts index fe81acdc..21bed945 100644 --- a/src/features/toaster/index.ts +++ b/src/features/toaster/index.ts @@ -2,4 +2,5 @@ export * from "./components/toaster"; export * from "./hooks/use-saved-toast"; +export * from "./utils/defer-client-toast"; export * from "./utils/defer-toast.proxy"; diff --git a/src/features/toaster/utils/defer-client-toast.ts b/src/features/toaster/utils/defer-client-toast.ts new file mode 100644 index 00000000..d67ede23 --- /dev/null +++ b/src/features/toaster/utils/defer-client-toast.ts @@ -0,0 +1,13 @@ +import Cookies from "js-cookie"; + +import { SAVED_TOAST_COOKIE_NAME } from "../constants"; +import type { SavedToast } from "../types/internal"; +import { getSavedToastCookieOptions } from "./get-saved-toast-cookie-options"; + +/** + * Saves a toast message in a browser cookie to be displayed on the next page render. + * For use **outside** of React components, where the {@link useSavedToast} hook cannot be used. + */ +export const deferClientToast = (toast: SavedToast): void => { + Cookies.set(SAVED_TOAST_COOKIE_NAME, ...getSavedToastCookieOptions(toast)); +}; diff --git a/src/lib/get-toast-messages.ts b/src/lib/get-toast-messages.ts index d961382d..170fc8c3 100644 --- a/src/lib/get-toast-messages.ts +++ b/src/lib/get-toast-messages.ts @@ -84,5 +84,6 @@ export const getToastMessages = { }, auth: { invalidCookie: "Proszę zalogować się ponownie.", + sessionExpired: "Twoja sesja wygasła. Proszę zalogować się ponownie.", }, };