Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/features/authentication/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions src/features/authentication/node.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines +3 to +7
2 changes: 2 additions & 0 deletions src/features/authentication/types/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export type RoutePermission = keyof typeof ROUTE_PERMISSIONS;
export type AuthState = z.infer<typeof AuthStateSchema>;
export type LoginFormValues = z.infer<typeof LoginSchema>;
export type User = z.infer<typeof UserSchema>;

export type TokenStatus = "ok" | "expiring-soon" | "expired" | "both-expired";
10 changes: 10 additions & 0 deletions src/features/authentication/utils/decode-base64-url-segment.ts
Original file line number Diff line number Diff line change
@@ -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);
}
14 changes: 14 additions & 0 deletions src/features/authentication/utils/decode-jwt-payload.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 21 additions & 0 deletions src/features/authentication/utils/force-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Cookies from "js-cookie";

import { deferClientToast } from "@/features/toaster";
Comment thread
GTR1701 marked this conversation as resolved.

// 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.",
});
Comment thread
GTR1701 marked this conversation as resolved.
window.location.href = "/login";
}
2 changes: 1 addition & 1 deletion src/features/authentication/utils/get-cookie-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
188 changes: 188 additions & 0 deletions src/features/authentication/utils/get-token-status.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
const json = JSON.stringify(payload);
const base64 = btoa(json);
return base64.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}

function makeToken(payload: Record<string, unknown>): string {
return `header.${encodeJwtPayload(payload)}.signature`;
}

const NOW = 1_000_000_000_000;

function makeAuthState(
options: Partial<AuthState> & {
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");
});
});
});
38 changes: 38 additions & 0 deletions src/features/authentication/utils/get-token-status.ts
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
GTR1701 marked this conversation as resolved.
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";
}
60 changes: 60 additions & 0 deletions src/features/authentication/utils/perform-token-refresh.ts
Original file line number Diff line number Diff line change
@@ -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<AuthState | null> {
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;
}
}
27 changes: 27 additions & 0 deletions src/features/authentication/utils/refresh-access-token.ts
Original file line number Diff line number Diff line change
@@ -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<AuthState | null> | 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<AuthState | null> {
if (pendingRefresh != null) {
return pendingRefresh;
}

const authState = getAuthStateNode();
if (authState == null) {
return null;
}

pendingRefresh = performTokenRefresh(authState).finally(() => {
pendingRefresh = null;
});
return pendingRefresh;
}
Loading
Loading