From b5b1b22c482c7e076b67555127945a4780aaf5fb Mon Sep 17 00:00:00 2001 From: gtr1701 Date: Wed, 25 Mar 2026 13:56:03 +0100 Subject: [PATCH 1/6] feat(auth): create access token refreshing functionality --- src/features/authentication/constants.ts | 6 ++ src/features/authentication/node.ts | 6 ++ src/features/authentication/types/internal.ts | 2 + .../authentication/utils/do-refresh-token.ts | 65 +++++++++++++++++++ .../authentication/utils/force-logout.ts | 19 ++++++ .../utils/get-cookie-options.ts | 2 +- .../authentication/utils/get-token-status.ts | 50 ++++++++++++++ .../utils/refresh-access-token.ts | 27 ++++++++ src/features/backend/types/responses.ts | 9 +++ src/features/backend/utils/execute-fetch.ts | 30 +++++++++ src/features/toaster/index.ts | 1 + .../toaster/utils/defer-client-toast.ts | 13 ++++ src/lib/get-toast-messages.ts | 1 + 13 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/features/authentication/utils/do-refresh-token.ts create mode 100644 src/features/authentication/utils/force-logout.ts create mode 100644 src/features/authentication/utils/get-token-status.ts create mode 100644 src/features/authentication/utils/refresh-access-token.ts create mode 100644 src/features/toaster/utils/defer-client-toast.ts 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/do-refresh-token.ts b/src/features/authentication/utils/do-refresh-token.ts new file mode 100644 index 00000000..4d6e9084 --- /dev/null +++ b/src/features/authentication/utils/do-refresh-token.ts @@ -0,0 +1,65 @@ +import Cookies from "js-cookie"; + +import { env } from "@/config/env"; +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"; + +interface RefreshResponse { + newAccessToken: { + accessToken: string; + accessExpiresInMs: number; + }; +} + +export async function doRefreshToken( + 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 RefreshResponse; + 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; + Cookies.set(AUTH_STATE_COOKIE_NAME, ...getCookieOptions(newState)); + return newState; + } catch (error) { + logger.error(parseError(error), "Token refresh failed"); + 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..bc87f5f7 --- /dev/null +++ b/src/features/authentication/utils/force-logout.ts @@ -0,0 +1,19 @@ +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", + message: getToastMessages.auth.sessionExpired, + }); + 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.ts b/src/features/authentication/utils/get-token-status.ts new file mode 100644 index 00000000..07a2f849 --- /dev/null +++ b/src/features/authentication/utils/get-token-status.ts @@ -0,0 +1,50 @@ +import { REFRESH_THRESHOLD_PERCENT } from "../constants"; +import type { AuthState, TokenStatus } from "../types/internal"; + +function decodeJwtPayload(token: string): { iat?: number } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + const decoded = atob(parts[1].replaceAll("-", "+").replaceAll("_", "/")); + return JSON.parse(decoded) as { iat?: number }; + } catch { + return null; + } +} + +/** + * 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/refresh-access-token.ts b/src/features/authentication/utils/refresh-access-token.ts new file mode 100644 index 00000000..8f82ea8e --- /dev/null +++ b/src/features/authentication/utils/refresh-access-token.ts @@ -0,0 +1,27 @@ +import type { AuthState } from "../types/internal"; +import { doRefreshToken } from "./do-refresh-token"; +import { getAuthStateNode } from "./get-auth-state.node"; + +/** 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 = doRefreshToken(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..a319973c 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,30 @@ 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": { + await refreshAccessToken(); + break; + } + case "expiring-soon": { + void refreshAccessToken(); + 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.", }, }; From e342d315ba127f885663aeeb091b806e88b3e24d Mon Sep 17 00:00:00 2001 From: gtr1701 Date: Mon, 30 Mar 2026 20:18:50 +0200 Subject: [PATCH 2/6] chore(auth): remove duplicate return type --- src/features/authentication/utils/do-refresh-token.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/features/authentication/utils/do-refresh-token.ts b/src/features/authentication/utils/do-refresh-token.ts index 4d6e9084..e173eec0 100644 --- a/src/features/authentication/utils/do-refresh-token.ts +++ b/src/features/authentication/utils/do-refresh-token.ts @@ -1,6 +1,7 @@ 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"; @@ -9,13 +10,6 @@ import type { AuthState } from "../types/internal"; import { getAuthStateNode } from "./get-auth-state.node"; import { getCookieOptions } from "./get-cookie-options"; -interface RefreshResponse { - newAccessToken: { - accessToken: string; - accessExpiresInMs: number; - }; -} - export async function doRefreshToken( authState: AuthState, ): Promise { @@ -32,7 +26,7 @@ export async function doRefreshToken( return null; } - const { newAccessToken } = (await response.json()) as RefreshResponse; + const { newAccessToken } = (await response.json()) as RefreshTokenResponse; const now = Date.now(); const currentState = getAuthStateNode(); if (currentState == null) { From efc547650df6285314637585ee95b9e281867a28 Mon Sep 17 00:00:00 2001 From: gtr1701 Date: Wed, 6 May 2026 20:56:05 +0200 Subject: [PATCH 3/6] chore(auth): soft-patch toast message circular import --- src/features/authentication/utils/force-logout.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/authentication/utils/force-logout.ts b/src/features/authentication/utils/force-logout.ts index bc87f5f7..d0b7adf2 100644 --- a/src/features/authentication/utils/force-logout.ts +++ b/src/features/authentication/utils/force-logout.ts @@ -1,7 +1,8 @@ import Cookies from "js-cookie"; import { deferClientToast } from "@/features/toaster"; -import { getToastMessages } from "@/lib/get-toast-messages"; + +// import { getToastMessages } from "@/lib/get-toast-messages"; import { AUTH_STATE_COOKIE_NAME } from "../constants"; @@ -13,7 +14,8 @@ export function forceLogout(): void { Cookies.remove(AUTH_STATE_COOKIE_NAME); deferClientToast({ level: "warning", - message: getToastMessages.auth.sessionExpired, + // 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"; } From 703d786396f926b6a7046626937e5fb3b3c973af Mon Sep 17 00:00:00 2001 From: gtr1701 Date: Fri, 8 May 2026 14:27:54 +0200 Subject: [PATCH 4/6] feat(auth): improve refresh token logic --- .../utils/decode-base64-url-segment.ts | 10 ++++++++++ .../authentication/utils/decode-jwt-payload.ts | 14 ++++++++++++++ .../authentication/utils/do-refresh-token.ts | 5 +++-- .../authentication/utils/get-token-status.ts | 14 +------------- src/features/backend/utils/execute-fetch.ts | 12 ++++++++++-- 5 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 src/features/authentication/utils/decode-base64-url-segment.ts create mode 100644 src/features/authentication/utils/decode-jwt-payload.ts 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/do-refresh-token.ts b/src/features/authentication/utils/do-refresh-token.ts index e173eec0..6c79e941 100644 --- a/src/features/authentication/utils/do-refresh-token.ts +++ b/src/features/authentication/utils/do-refresh-token.ts @@ -50,8 +50,9 @@ export async function doRefreshToken( } const newState = parsed.data; - Cookies.set(AUTH_STATE_COOKIE_NAME, ...getCookieOptions(newState)); - return newState; + 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/get-token-status.ts b/src/features/authentication/utils/get-token-status.ts index 07a2f849..8a2275b6 100644 --- a/src/features/authentication/utils/get-token-status.ts +++ b/src/features/authentication/utils/get-token-status.ts @@ -1,18 +1,6 @@ import { REFRESH_THRESHOLD_PERCENT } from "../constants"; import type { AuthState, TokenStatus } from "../types/internal"; - -function decodeJwtPayload(token: string): { iat?: number } | null { - try { - const parts = token.split("."); - if (parts.length !== 3) { - return null; - } - const decoded = atob(parts[1].replaceAll("-", "+").replaceAll("_", "/")); - return JSON.parse(decoded) as { iat?: number }; - } catch { - return null; - } -} +import { decodeJwtPayload } from "./decode-jwt-payload"; /** * Determines the validity status of the current access token. diff --git a/src/features/backend/utils/execute-fetch.ts b/src/features/backend/utils/execute-fetch.ts index a319973c..815d069a 100644 --- a/src/features/backend/utils/execute-fetch.ts +++ b/src/features/backend/utils/execute-fetch.ts @@ -26,11 +26,19 @@ export const executeFetch = async ( throw new Error("Session expired"); } case "expired": { - await refreshAccessToken(); + const refreshedAuthState = await refreshAccessToken(); + if (refreshedAuthState == null) { + forceLogout(); + throw new Error("Session expired"); + } break; } case "expiring-soon": { - void refreshAccessToken(); + void refreshAccessToken().then((refreshedAuthState) => { + if (refreshedAuthState == null) { + forceLogout(); + } + }); break; } case "ok": { From 4c3e14d1fab0454de56c81834b32fa0ec5b412d9 Mon Sep 17 00:00:00 2001 From: gtr1701 Date: Fri, 8 May 2026 14:32:23 +0200 Subject: [PATCH 5/6] test(auth): get token status util test --- .../utils/get-token-status.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/features/authentication/utils/get-token-status.test.ts 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"); + }); + }); +}); From dffc102e22998b3e6a13a94b65a4c88427d80a4b Mon Sep 17 00:00:00 2001 From: gtr1701 Date: Mon, 25 May 2026 21:37:39 +0200 Subject: [PATCH 6/6] chore(auth): rename doRefreshToken to performTokenRefresh --- .../utils/{do-refresh-token.ts => perform-token-refresh.ts} | 2 +- src/features/authentication/utils/refresh-access-token.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/features/authentication/utils/{do-refresh-token.ts => perform-token-refresh.ts} (97%) diff --git a/src/features/authentication/utils/do-refresh-token.ts b/src/features/authentication/utils/perform-token-refresh.ts similarity index 97% rename from src/features/authentication/utils/do-refresh-token.ts rename to src/features/authentication/utils/perform-token-refresh.ts index 6c79e941..0ca4b7a0 100644 --- a/src/features/authentication/utils/do-refresh-token.ts +++ b/src/features/authentication/utils/perform-token-refresh.ts @@ -10,7 +10,7 @@ import type { AuthState } from "../types/internal"; import { getAuthStateNode } from "./get-auth-state.node"; import { getCookieOptions } from "./get-cookie-options"; -export async function doRefreshToken( +export async function performTokenRefresh( authState: AuthState, ): Promise { try { diff --git a/src/features/authentication/utils/refresh-access-token.ts b/src/features/authentication/utils/refresh-access-token.ts index 8f82ea8e..24feb70f 100644 --- a/src/features/authentication/utils/refresh-access-token.ts +++ b/src/features/authentication/utils/refresh-access-token.ts @@ -1,6 +1,6 @@ import type { AuthState } from "../types/internal"; -import { doRefreshToken } from "./do-refresh-token"; 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; @@ -20,7 +20,7 @@ export async function refreshAccessToken(): Promise { return null; } - pendingRefresh = doRefreshToken(authState).finally(() => { + pendingRefresh = performTokenRefresh(authState).finally(() => { pendingRefresh = null; }); return pendingRefresh;