diff --git a/README.md b/README.md
index f5df173..26ed82f 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ pnpm add @open-elements/ui next next-auth react react-dom lucide-react
### `@open-elements/nextjs-app-layer/server` (server-only)
-- `createAppLayerAuth({ issuer, clientId, clientSecret })`
+- `createAppLayerAuth({ issuer, clientId, clientSecret, ...tuning })`
- `createBackendProxyHandler({ backendUrl, auth })`
- `createLogoutHandler({ auth, oidcIssuer, authUrl })`
- `middlewareConfig` (reference value — see warning below)
@@ -84,6 +84,38 @@ export const { handlers, auth, signIn, signOut, oidcIssuer } =
});
```
+### Session and token-refresh tuning
+
+All four options are optional and can also be set per deployment via an env var.
+Precedence is: explicit config value > env var > default. Non-numeric or
+non-positive values fall back to the default.
+
+| Option | Env var | Default | Meaning |
+| -------------------------- | --------------------------------- | ------- | -------------------------------------------------------------------------- |
+| `sessionMaxAgeSeconds` | `AUTH_SESSION_MAX_AGE_SECONDS` | `28800` | Session/JWT lifetime (8 h) |
+| `sessionUpdateAgeSeconds` | `AUTH_SESSION_UPDATE_AGE_SECONDS` | `900` | How often the rolling session cookie is re-issued |
+| `refreshSkewSeconds` | `AUTH_TOKEN_REFRESH_SKEW_SECONDS` | `300` | Refresh this long before access-token expiry, clamped to half its lifetime |
+| `oidcTimeoutMs` | `OIDC_HTTP_TIMEOUT_MS` | `10000` | Timeout for OIDC discovery and token-endpoint calls |
+
+The refresh skew is clamped to `max(5, min(skew, floor(tokenLifetime / 2)))`, so
+an IdP that issues short-lived access tokens (≤ 60 s) is contacted at most once
+per half-lifetime instead of on every request. Concurrent refreshes of the same
+refresh token share a single token-endpoint call, and OIDC discovery is cached
+for 10 minutes.
+
+A transient refresh failure (5xx, network error, timeout) keeps the existing
+token and is retried on the next request; only a 4xx from the token endpoint —
+or a transient failure after the access token has already expired — marks the
+session with `error: "RefreshTokenError"`, which makes the middleware treat the
+request as unauthenticated.
+
+If your access tokens live for less than two minutes, lower the client-side
+session poll accordingly:
+
+```tsx
+{children}
+```
+
```ts
// src/app/api/[...path]/route.ts
import { auth } from "@/auth";
diff --git a/docs/releases/upgrade-to-0.7.1.md b/docs/releases/upgrade-to-0.7.1.md
new file mode 100644
index 0000000..57a6c38
--- /dev/null
+++ b/docs/releases/upgrade-to-0.7.1.md
@@ -0,0 +1,92 @@
+# Upgrade prompt: `@open-elements/nextjs-app-layer` 0.7.0 → 0.7.1
+
+## Prompt
+
+You are upgrading a Next.js app that depends on `@open-elements/nextjs-app-layer` from 0.7.0 to 0.7.1. This release is **additive and backward compatible** — no public API changed — but it **changes runtime behaviour**: sessions now expire after 8 hours instead of 30 days, and the OIDC access-token refresh is rate-limited, de-duplicated, and no longer fails the session on a transient IdP error. Bump the dependency, then decide whether any of the new tuning options apply to your deployment. Do not change anything outside this scope.
+
+### What changed in 0.7.1
+
+#### Dependencies
+
+Bump only `@open-elements/nextjs-app-layer` to `0.7.1`. No peer dependencies changed: `next`, `next-auth`, `react`, `react-dom`, `lucide-react`, and `@open-elements/ui` stay at whatever versions the consumer already uses. Do **not** change those coordinates as part of this upgrade.
+
+#### Behavioural: the session no longer outlives the access token
+
+0.7.0 used `session: { strategy: "jwt" }` with no `maxAge`, so the session cookie inherited the Auth.js default of **30 days**. The cookie therefore survived weeks after the OIDC access token — and its refresh token — had died: the middleware reported "authenticated" while every proxied API call returned 401.
+
+0.7.1 sets a session and JWT `maxAge` of **8 hours** with a rolling `updateAge` of **15 minutes**. Users of an app that relied on the 30-day cookie will now be redirected to the IdP once per working day (usually a silent SSO round-trip). If you deliberately need a longer session, set it explicitly — do not go back to the default:
+
+```ts
+createAppLayerAuth({
+ issuer: process.env.OIDC_ISSUER_URI,
+ clientId: process.env.OIDC_CLIENT_ID,
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
+ sessionMaxAgeSeconds: 12 * 60 * 60,
+});
+```
+
+#### Behavioural: refresh is clamped, de-duplicated, and fails soft
+
+0.7.0 refreshed when the access token was within a hard-coded **60 seconds** of expiry. Against an IdP that issues access tokens with a lifetime of 60 seconds or less (a common Authentik default), that window opened the moment the token was minted, so every RSC render, every client session poll, and every proxied API call POSTed to the token endpoint.
+
+In 0.7.1 the skew is clamped to the observed token lifetime:
+
+```
+effectiveSkew = max(5, min(configuredSkew, floor(tokenLifetime / 2)))
+```
+
+so short-lived tokens refresh at most once per half-lifetime. In addition:
+
+- Concurrent refreshes of the same refresh token share **one** token-endpoint call, so IdPs with refresh-token rotation no longer invalidate the losers of the race (which showed up as random logouts).
+- `.well-known/openid-configuration` is cached for 10 minutes instead of being re-fetched on every refresh.
+- Both HTTP calls use `AbortSignal.timeout()` (default 10 s), so a hanging IdP no longer hangs the request.
+- Failures are classified: a **4xx** (e.g. `invalid_grant`) means the refresh token is dead and sets `error: "RefreshTokenError"` immediately; a **5xx, network error, or timeout** keeps the existing token and is retried on the next request, and only fails the session once the access token has actually expired.
+
+#### Behavioural: the middleware now rejects a broken session
+
+`authorized()` previously returned `!!session?.user` and ignored `session.error`. A user whose refresh token had died was therefore admitted into an app shell whose API calls all 401'd. It now also requires `session.error !== "RefreshTokenError"`, so such a request is treated as unauthenticated and redirected to `/login`. `session()` is unchanged: it still blanks `accessToken` on that error and still exposes `idToken`, `expiresAt`, `roles`, and `error`.
+
+#### Additive: four optional tuning options on `createAppLayerAuth()`
+
+Each option can also be set via an env var, so a deployment can tune it without a code change. Precedence is: explicit config value > env var > default; non-numeric or non-positive values fall back to the default.
+
+| Option | Env var | Default | Meaning |
+| ------------------------- | --------------------------------- | ------- | -------------------------------------------------------------------------- |
+| `sessionMaxAgeSeconds` | `AUTH_SESSION_MAX_AGE_SECONDS` | `28800` | Session/JWT lifetime (8 h) |
+| `sessionUpdateAgeSeconds` | `AUTH_SESSION_UPDATE_AGE_SECONDS` | `900` | How often the rolling session cookie is re-issued |
+| `refreshSkewSeconds` | `AUTH_TOKEN_REFRESH_SKEW_SECONDS` | `300` | Refresh this long before access-token expiry, clamped to half its lifetime |
+| `oidcTimeoutMs` | `OIDC_HTTP_TIMEOUT_MS` | `10000` | Timeout for OIDC discovery and token-endpoint calls |
+
+Calling `createAppLayerAuth({ issuer, clientId, clientSecret })` with no tuning keeps working and picks up the defaults.
+
+#### Additive: `SessionProvider` accepts `refetchInterval`
+
+The client session poll was hard-coded to 120 seconds. It is now a prop with the same default, so an app whose access tokens live less than two minutes can poll more often:
+
+```tsx
+{children}
+```
+
+`refetchOnWindowFocus` stays enabled. Omitting the prop reproduces 0.7.0 behaviour exactly.
+
+### Steps
+
+1. Bump `@open-elements/nextjs-app-layer` to `0.7.1` in `package.json`; leave all other dependencies untouched. Reinstall (`pnpm install`).
+2. Check the access-token lifetime your IdP issues for this app's client. If it is under two minutes, pass `refetchInterval` to `SessionProvider` (roughly half the lifetime, minimum ~15 s) so the browser notices an expired session promptly.
+3. Decide whether the 8-hour session fits the app. If it does, do nothing. If not, set `sessionMaxAgeSeconds` (or `AUTH_SESSION_MAX_AGE_SECONDS` in the deployment) explicitly.
+4. If the app previously worked around the refresh bug — e.g. a custom `jwt` callback, a manual refresh route, a polling hack, or a shortened `refetchInterval` added to fight repeated 401s — remove that workaround; the library handles it now.
+5. Run type-check, build, and the test suite; confirm green before committing.
+6. Verify at runtime: sign in, idle past one access-token lifetime, and confirm the app keeps working with a single token-endpoint call per half-lifetime (check the IdP's request log, not just the UI).
+
+### Guard rails
+
+- Do **not** set `sessionMaxAgeSeconds` back to 30 days to "restore old behaviour" — the long cookie is the defect this release fixes.
+- Do **not** set `refreshSkewSeconds` larger than half your access-token lifetime expecting earlier refreshes; the value is clamped by design.
+- Do **not** add your own `jwt` or `authorized` callback to re-implement refresh handling on top of the library's.
+- Do **not** bump `next`, `next-auth`, `react`, `@open-elements/ui`, or any other dependency in the same change.
+
+### Don't do this
+
+- Do not treat `error: "RefreshTokenError"` as recoverable in app code — it now means the refresh token is dead and the user must sign in again.
+- Do not lower `oidcTimeoutMs` below a couple of seconds to "fail fast"; a timeout is classified as transient and costs the user a retry.
+- Do not bundle this upgrade with unrelated feature work in the same PR.
diff --git a/package.json b/package.json
index 0777026..630d427 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@open-elements/nextjs-app-layer",
- "version": "0.7.0",
+ "version": "0.7.1",
"description": "Next.js foundation (auth, proxy, admin pages, login/forbidden, layout) for Open Elements applications",
"packageManager": "pnpm@11.3.0",
"engines": {
diff --git a/src/components/session-provider.tsx b/src/components/session-provider.tsx
index f65d696..5acc1bc 100644
--- a/src/components/session-provider.tsx
+++ b/src/components/session-provider.tsx
@@ -13,9 +13,15 @@ function RefreshTokenErrorWatcher() {
return null;
}
-export function SessionProvider({ children }: { readonly children: React.ReactNode }) {
+export interface SessionProviderProps {
+ readonly children: React.ReactNode;
+ /** Session poll interval in seconds. Lower it for short-lived access tokens. */
+ readonly refetchInterval?: number;
+}
+
+export function SessionProvider({ children, refetchInterval = 120 }: SessionProviderProps) {
return (
-
+
{children}
diff --git a/src/index.ts b/src/index.ts
index 48afa52..277d949 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,7 @@ export {
export type { AppLayerTranslations } from "./translations/provider";
export { SessionProvider } from "./components/session-provider";
+export type { SessionProviderProps } from "./components/session-provider";
export { ForbiddenPage } from "./components/forbidden-page";
export { BearerTokenCard } from "./components/bearer-token-card";
export { AddCommentDialog } from "./components/add-comment-dialog";
diff --git a/src/layout/root-layout.tsx b/src/layout/root-layout.tsx
index abfb63e..f431285 100644
--- a/src/layout/root-layout.tsx
+++ b/src/layout/root-layout.tsx
@@ -33,17 +33,20 @@ export function OERootLayout({
translations,
apiClient,
htmlLang = "en",
+ sessionRefetchInterval,
}: {
readonly children: React.ReactNode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly translations: Record;
readonly apiClient?: AppLayerApiClient;
readonly htmlLang?: string;
+ /** Session poll interval in seconds. Lower it for short-lived access tokens. */
+ readonly sessionRefetchInterval?: number;
}) {
return (
-
+
{children}
diff --git a/src/server/__tests__/oidc-tokens.test.ts b/src/server/__tests__/oidc-tokens.test.ts
new file mode 100644
index 0000000..8c0f1cf
--- /dev/null
+++ b/src/server/__tests__/oidc-tokens.test.ts
@@ -0,0 +1,325 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+ DEFAULT_REFRESH_SKEW_SECONDS,
+ REFRESH_TOKEN_ERROR,
+ effectiveRefreshSkewSeconds,
+ ensureFreshTokens,
+ isAuthorized,
+ needsRefresh,
+ refreshAccessToken,
+ resetOidcRuntimeState,
+ resolvePositiveNumber,
+} from "../oidc-tokens";
+
+const ISSUER = "https://idp.example.test";
+const TOKEN_ENDPOINT = `${ISSUER}/application/o/token/`;
+
+const OPTIONS = {
+ issuer: ISSUER,
+ clientId: "client-id",
+ clientSecret: "client-secret",
+ refreshSkewSeconds: DEFAULT_REFRESH_SKEW_SECONDS,
+ timeoutMs: 10_000,
+};
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ } as Response;
+}
+
+function discoveryResponse(): Response {
+ return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
+}
+
+/** A fetch mock that answers discovery from the well-known URL and delegates the rest. */
+function mockFetch(tokenResponse: () => Response | Promise) {
+ return vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url.includes("/.well-known/openid-configuration")) {
+ return discoveryResponse();
+ }
+ return tokenResponse();
+ });
+}
+
+const NOW_MS = 1_700_000_000_000;
+const NOW_SEC = Math.floor(NOW_MS / 1000);
+
+describe("resolvePositiveNumber", () => {
+ const ENV = "APP_LAYER_TEST_NUMBER";
+ const env = (globalThis as unknown as { process: { env: Record } })
+ .process.env;
+
+ afterEach(() => {
+ delete env[ENV];
+ });
+
+ it("prefers the explicit config value over env and default", () => {
+ env[ENV] = "20";
+ expect(resolvePositiveNumber(10, ENV, 30)).toBe(10);
+ });
+
+ it("falls back to the env var when no config value is given", () => {
+ env[ENV] = "20";
+ expect(resolvePositiveNumber(undefined, ENV, 30)).toBe(20);
+ });
+
+ it("falls back to the default for non-numeric or non-positive values", () => {
+ env[ENV] = "not-a-number";
+ expect(resolvePositiveNumber(undefined, ENV, 30)).toBe(30);
+ env[ENV] = "0";
+ expect(resolvePositiveNumber(undefined, ENV, 30)).toBe(30);
+ env[ENV] = "-5";
+ expect(resolvePositiveNumber(undefined, ENV, 30)).toBe(30);
+ expect(resolvePositiveNumber(-1, ENV, 30)).toBe(30);
+ });
+
+ it("falls back to the default when neither config nor env is set", () => {
+ expect(resolvePositiveNumber(undefined, ENV, 30)).toBe(30);
+ });
+});
+
+describe("effectiveRefreshSkewSeconds", () => {
+ it("clamps the skew to half the token lifetime", () => {
+ expect(effectiveRefreshSkewSeconds(300, 60)).toBe(30);
+ });
+
+ it("never goes below the 5 second floor", () => {
+ expect(effectiveRefreshSkewSeconds(300, 4)).toBe(5);
+ });
+
+ it("keeps the configured skew for long-lived tokens", () => {
+ expect(effectiveRefreshSkewSeconds(300, 3600)).toBe(300);
+ });
+
+ it("keeps the configured skew when the lifetime is unknown", () => {
+ expect(effectiveRefreshSkewSeconds(300, undefined)).toBe(300);
+ });
+});
+
+describe("needsRefresh", () => {
+ it("returns false immediately after a 60 second token was issued", () => {
+ const state = { expiresAt: NOW_SEC + 60, tokenLifetime: 60 };
+ expect(needsRefresh(state, DEFAULT_REFRESH_SKEW_SECONDS, NOW_MS)).toBe(false);
+ });
+
+ it("returns true once past half the lifetime of a 60 second token", () => {
+ const state = { expiresAt: NOW_SEC + 60, tokenLifetime: 60 };
+ expect(needsRefresh(state, DEFAULT_REFRESH_SKEW_SECONDS, NOW_MS + 31_000)).toBe(true);
+ });
+
+ it("returns true when expiresAt is missing", () => {
+ expect(needsRefresh({}, DEFAULT_REFRESH_SKEW_SECONDS, NOW_MS)).toBe(true);
+ });
+
+ it("uses the configured skew for long-lived tokens", () => {
+ const state = { expiresAt: NOW_SEC + 3600, tokenLifetime: 3600 };
+ expect(needsRefresh(state, DEFAULT_REFRESH_SKEW_SECONDS, NOW_MS)).toBe(false);
+ expect(needsRefresh(state, DEFAULT_REFRESH_SKEW_SECONDS, NOW_MS + 3_301_000)).toBe(true);
+ });
+});
+
+describe("isAuthorized", () => {
+ it("returns false when the session has a refresh token error", () => {
+ expect(isAuthorized({ user: { name: "Ada" }, error: REFRESH_TOKEN_ERROR })).toBe(false);
+ });
+
+ it("returns true for a healthy session", () => {
+ expect(isAuthorized({ user: { name: "Ada" } })).toBe(true);
+ });
+
+ it("returns false without a user", () => {
+ expect(isAuthorized(null)).toBe(false);
+ expect(isAuthorized({})).toBe(false);
+ });
+});
+
+describe("token refresh", () => {
+ beforeEach(() => {
+ resetOidcRuntimeState();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("de-duplicates concurrent refreshes of the same refresh token", async () => {
+ let resolveToken: (value: Response) => void = () => {};
+ const pending = new Promise((resolve) => {
+ resolveToken = resolve;
+ });
+ const fetchMock = mockFetch(() => pending);
+ vi.stubGlobal("fetch", fetchMock);
+
+ const request = {
+ issuer: ISSUER,
+ clientId: "client-id",
+ clientSecret: "client-secret",
+ refreshToken: "refresh-1",
+ timeoutMs: 10_000,
+ };
+ const first = refreshAccessToken(request, NOW_MS);
+ const second = refreshAccessToken(request, NOW_MS);
+
+ resolveToken(jsonResponse({ access_token: "new-access", expires_in: 60 }));
+ const [a, b] = await Promise.all([first, second]);
+
+ expect(a.accessToken).toBe("new-access");
+ expect(b.accessToken).toBe("new-access");
+ const tokenCalls = fetchMock.mock.calls.filter(
+ ([url]) => !String(url).includes("/.well-known/"),
+ );
+ expect(tokenCalls).toHaveLength(1);
+ });
+
+ it("fetches OIDC discovery only once across refreshes within the TTL", async () => {
+ const fetchMock = mockFetch(() => jsonResponse({ access_token: "new-access", expires_in: 60 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const state = { accessToken: "old", refreshToken: "refresh-1", expiresAt: NOW_SEC - 1 };
+ await ensureFreshTokens(state, OPTIONS, NOW_MS);
+ await ensureFreshTokens({ ...state, refreshToken: "refresh-2" }, OPTIONS, NOW_MS + 1000);
+
+ const discoveryCalls = fetchMock.mock.calls.filter(([url]) =>
+ String(url).includes("/.well-known/openid-configuration"),
+ );
+ expect(discoveryCalls).toHaveLength(1);
+ });
+
+ it("sets RefreshTokenError when the token endpoint rejects the refresh token", async () => {
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(() => jsonResponse({ error: "invalid_grant" }, 400)),
+ );
+
+ const result = await ensureFreshTokens(
+ { accessToken: "old", refreshToken: "refresh-1", expiresAt: NOW_SEC + 3600 },
+ OPTIONS,
+ // Inside the refresh window of a long-lived token.
+ (NOW_SEC + 3600 - 10) * 1000,
+ );
+
+ expect(result.error).toBe(REFRESH_TOKEN_ERROR);
+ });
+
+ it("keeps the existing token on a 5xx while the access token is still valid", async () => {
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(() => jsonResponse({}, 503)),
+ );
+
+ const result = await ensureFreshTokens(
+ {
+ accessToken: "old",
+ refreshToken: "refresh-1",
+ expiresAt: NOW_SEC + 3600,
+ tokenLifetime: 3600,
+ },
+ OPTIONS,
+ (NOW_SEC + 3600 - 10) * 1000,
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.accessToken).toBe("old");
+ });
+
+ it("sets RefreshTokenError on a 5xx once the access token has expired", async () => {
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(() => jsonResponse({}, 503)),
+ );
+
+ const result = await ensureFreshTokens(
+ { accessToken: "old", refreshToken: "refresh-1", expiresAt: NOW_SEC - 1 },
+ OPTIONS,
+ NOW_MS,
+ );
+
+ expect(result.error).toBe(REFRESH_TOKEN_ERROR);
+ });
+
+ it("keeps the existing token on a network error while the access token is valid", async () => {
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(() => {
+ throw new Error("socket hang up");
+ }),
+ );
+
+ const result = await ensureFreshTokens(
+ {
+ accessToken: "old",
+ refreshToken: "refresh-1",
+ expiresAt: NOW_SEC + 3600,
+ tokenLifetime: 3600,
+ },
+ OPTIONS,
+ (NOW_SEC + 3600 - 10) * 1000,
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.accessToken).toBe("old");
+ });
+
+ it("stores the rotated refresh token and the new lifetime", async () => {
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(() =>
+ jsonResponse({ access_token: "new-access", refresh_token: "refresh-2", expires_in: 60 }),
+ ),
+ );
+
+ const result = await ensureFreshTokens(
+ { accessToken: "old", refreshToken: "refresh-1", expiresAt: NOW_SEC - 1 },
+ OPTIONS,
+ NOW_MS,
+ );
+
+ expect(result.accessToken).toBe("new-access");
+ expect(result.refreshToken).toBe("refresh-2");
+ expect(result.expiresAt).toBe(NOW_SEC + 60);
+ expect(result.tokenLifetime).toBe(60);
+ expect(result.error).toBeUndefined();
+ });
+
+ it("does not call the IdP while the access token is comfortably valid", async () => {
+ const fetchMock = mockFetch(() => jsonResponse({ access_token: "new-access", expires_in: 60 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const state = {
+ accessToken: "old",
+ refreshToken: "refresh-1",
+ expiresAt: NOW_SEC + 60,
+ tokenLifetime: 60,
+ };
+ const result = await ensureFreshTokens(state, OPTIONS, NOW_MS);
+
+ expect(result).toBe(state);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("sets RefreshTokenError without a refresh token only once the token expired", async () => {
+ const fetchMock = mockFetch(() => jsonResponse({}, 500));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const valid = await ensureFreshTokens(
+ { accessToken: "old", expiresAt: NOW_SEC + 3600, tokenLifetime: 3600 },
+ OPTIONS,
+ (NOW_SEC + 3600 - 10) * 1000,
+ );
+ expect(valid.error).toBeUndefined();
+
+ const expired = await ensureFreshTokens(
+ { accessToken: "old", expiresAt: NOW_SEC - 1 },
+ OPTIONS,
+ NOW_MS,
+ );
+ expect(expired.error).toBe(REFRESH_TOKEN_ERROR);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 0f7127a..5d51cb7 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -1,9 +1,42 @@
import NextAuth from "next-auth";
+import {
+ DEFAULT_OIDC_TIMEOUT_MS,
+ DEFAULT_REFRESH_SKEW_SECONDS,
+ DEFAULT_SESSION_MAX_AGE_SECONDS,
+ DEFAULT_SESSION_UPDATE_AGE_SECONDS,
+ REFRESH_TOKEN_ERROR,
+ applyTokenState,
+ ensureFreshTokens,
+ isAuthorized,
+ resolvePositiveNumber,
+ toTokenState,
+} from "./oidc-tokens";
export interface AppLayerAuthConfig {
readonly issuer: string | undefined;
readonly clientId: string | undefined;
readonly clientSecret: string | undefined;
+ /**
+ * Session lifetime in seconds. Env: `AUTH_SESSION_MAX_AGE_SECONDS`.
+ * Default: 8 hours.
+ */
+ readonly sessionMaxAgeSeconds?: number;
+ /**
+ * How often the rolling session cookie is re-issued, in seconds.
+ * Env: `AUTH_SESSION_UPDATE_AGE_SECONDS`. Default: 15 minutes.
+ */
+ readonly sessionUpdateAgeSeconds?: number;
+ /**
+ * Refresh the access token this many seconds before it expires, clamped to half
+ * the observed token lifetime. Env: `AUTH_TOKEN_REFRESH_SKEW_SECONDS`.
+ * Default: 5 minutes.
+ */
+ readonly refreshSkewSeconds?: number;
+ /**
+ * Timeout for OIDC discovery and token-endpoint calls, in milliseconds.
+ * Env: `OIDC_HTTP_TIMEOUT_MS`. Default: 10000.
+ */
+ readonly oidcTimeoutMs?: number;
}
/**
@@ -18,6 +51,27 @@ export function createAppLayerAuth(
): ReturnType & { oidcIssuer: string | undefined } {
const { issuer: oidcIssuer, clientId, clientSecret } = config;
+ const sessionMaxAge = resolvePositiveNumber(
+ config.sessionMaxAgeSeconds,
+ "AUTH_SESSION_MAX_AGE_SECONDS",
+ DEFAULT_SESSION_MAX_AGE_SECONDS,
+ );
+ const sessionUpdateAge = resolvePositiveNumber(
+ config.sessionUpdateAgeSeconds,
+ "AUTH_SESSION_UPDATE_AGE_SECONDS",
+ DEFAULT_SESSION_UPDATE_AGE_SECONDS,
+ );
+ const refreshSkewSeconds = resolvePositiveNumber(
+ config.refreshSkewSeconds,
+ "AUTH_TOKEN_REFRESH_SKEW_SECONDS",
+ DEFAULT_REFRESH_SKEW_SECONDS,
+ );
+ const oidcTimeoutMs = resolvePositiveNumber(
+ config.oidcTimeoutMs,
+ "OIDC_HTTP_TIMEOUT_MS",
+ DEFAULT_OIDC_TIMEOUT_MS,
+ );
+
const nextAuth = NextAuth({
providers: [
{
@@ -31,10 +85,11 @@ export function createAppLayerAuth(
},
],
pages: { signIn: "/login" },
- session: { strategy: "jwt" },
+ session: { strategy: "jwt", maxAge: sessionMaxAge, updateAge: sessionUpdateAge },
+ jwt: { maxAge: sessionMaxAge },
callbacks: {
authorized({ auth: session }) {
- return !!session?.user;
+ return isAuthorized(session);
},
async signIn() {
return true;
@@ -47,6 +102,11 @@ export function createAppLayerAuth(
t.refreshToken = account.refresh_token;
t.idToken = account.id_token;
t.expiresAt = account.expires_at;
+ t.tokenLifetime =
+ typeof account.expires_at === "number"
+ ? Math.max(0, account.expires_at - Math.floor(Date.now() / 1000))
+ : undefined;
+ t.error = undefined;
if (profile) {
t.name = profile.name;
t.email = profile.email;
@@ -57,44 +117,14 @@ export function createAppLayerAuth(
return token;
}
- if (typeof t.expiresAt === "number" && Date.now() < (t.expiresAt - 60) * 1000) {
- return token;
- }
-
- if (typeof t.refreshToken === "string") {
- try {
- const wellKnownResponse = await fetch(`${oidcIssuer}/.well-known/openid-configuration`);
- const wellKnown = await wellKnownResponse.json();
- const tokenEndpoint = wellKnown.token_endpoint;
-
- const response = await fetch(tokenEndpoint, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "refresh_token",
- client_id: clientId!,
- client_secret: clientSecret!,
- refresh_token: t.refreshToken as string,
- }),
- });
-
- const refreshed = await response.json();
-
- if (!response.ok) {
- throw new Error("Token refresh failed");
- }
-
- t.accessToken = refreshed.access_token;
- t.refreshToken = refreshed.refresh_token ?? t.refreshToken;
- t.expiresAt = Math.floor(Date.now() / 1000 + refreshed.expires_in);
- t.error = undefined;
- return token;
- } catch (error) {
- console.error("Token refresh failed:", error);
- t.error = "RefreshTokenError";
- return token;
- }
- }
+ const refreshed = await ensureFreshTokens(toTokenState(t), {
+ issuer: oidcIssuer,
+ clientId,
+ clientSecret,
+ refreshSkewSeconds,
+ timeoutMs: oidcTimeoutMs,
+ });
+ applyTokenState(t, refreshed);
return token;
},
@@ -105,7 +135,7 @@ export function createAppLayerAuth(
session.expiresAt = t.expiresAt as number | undefined;
session.roles = Array.isArray(t.roles) ? (t.roles as string[]) : [];
session.error = typeof t.error === "string" ? t.error : undefined;
- if (t.error === "RefreshTokenError") {
+ if (t.error === REFRESH_TOKEN_ERROR) {
session.accessToken = undefined;
}
if (typeof t.name === "string") session.user.name = t.name;
diff --git a/src/server/oidc-tokens.ts b/src/server/oidc-tokens.ts
new file mode 100644
index 0000000..1a9787a
--- /dev/null
+++ b/src/server/oidc-tokens.ts
@@ -0,0 +1,347 @@
+// OIDC token handling for `createAppLayerAuth()`.
+// Split out of `auth.ts` so the refresh logic can be unit-tested without a live IdP.
+
+export const REFRESH_TOKEN_ERROR = "RefreshTokenError";
+
+export const DEFAULT_SESSION_MAX_AGE_SECONDS = 8 * 60 * 60;
+export const DEFAULT_SESSION_UPDATE_AGE_SECONDS = 15 * 60;
+export const DEFAULT_REFRESH_SKEW_SECONDS = 5 * 60;
+export const DEFAULT_OIDC_TIMEOUT_MS = 10_000;
+
+/** Never refresh closer than this to expiry, even for very short-lived tokens. */
+const MIN_REFRESH_SKEW_SECONDS = 5;
+
+const DISCOVERY_CACHE_TTL_MS = 10 * 60 * 1000;
+
+function isPositiveFinite(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
+}
+
+// The package does not depend on @types/node, so `process` is reached via globalThis.
+function readEnv(name: string): string | undefined {
+ const globals = globalThis as { process?: { env?: Record } };
+ return globals.process?.env?.[name];
+}
+
+/**
+ * Resolve a numeric option: explicit config value > env var > default.
+ * Non-numeric or non-positive values fall back to the default.
+ */
+export function resolvePositiveNumber(
+ explicit: number | undefined,
+ envName: string,
+ fallback: number,
+): number {
+ if (isPositiveFinite(explicit)) {
+ return explicit;
+ }
+ const raw = readEnv(envName);
+ if (typeof raw === "string" && raw.trim() !== "") {
+ const parsed = Number(raw);
+ if (isPositiveFinite(parsed)) {
+ return parsed;
+ }
+ }
+ return fallback;
+}
+
+/**
+ * Clamp the configured refresh skew to half the observed token lifetime, so an IdP
+ * that issues very short-lived access tokens is refreshed once per half-lifetime
+ * instead of on every single request.
+ */
+export function effectiveRefreshSkewSeconds(
+ configuredSkewSeconds: number,
+ tokenLifetimeSeconds: number | undefined,
+): number {
+ if (!isPositiveFinite(tokenLifetimeSeconds)) {
+ return configuredSkewSeconds;
+ }
+ return Math.max(
+ MIN_REFRESH_SKEW_SECONDS,
+ Math.min(configuredSkewSeconds, Math.floor(tokenLifetimeSeconds / 2)),
+ );
+}
+
+export interface TokenState {
+ accessToken?: string;
+ refreshToken?: string;
+ idToken?: string;
+ /** Access-token expiry as a UNIX timestamp in seconds. */
+ expiresAt?: number;
+ /** Observed access-token lifetime in seconds, used to clamp the refresh skew. */
+ tokenLifetime?: number;
+ error?: string;
+}
+
+/** True when the access token is missing an expiry or is inside the refresh window. */
+export function needsRefresh(
+ state: Pick,
+ configuredSkewSeconds: number,
+ nowMs: number = Date.now(),
+): boolean {
+ if (typeof state.expiresAt !== "number" || !Number.isFinite(state.expiresAt)) {
+ return true;
+ }
+ const skew = effectiveRefreshSkewSeconds(configuredSkewSeconds, state.tokenLifetime);
+ return nowMs >= (state.expiresAt - skew) * 1000;
+}
+
+/** True when the access token is already past its expiry (or has none). */
+export function isAccessTokenExpired(
+ expiresAt: number | undefined,
+ nowMs: number = Date.now(),
+): boolean {
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
+ return true;
+ }
+ return nowMs >= expiresAt * 1000;
+}
+
+/** Middleware gate: a session with a dead refresh token is not authorized. */
+export function isAuthorized(
+ session: { user?: unknown; error?: unknown } | null | undefined,
+): boolean {
+ if (!session?.user) {
+ return false;
+ }
+ return session.error !== REFRESH_TOKEN_ERROR;
+}
+
+export class TokenRefreshError extends Error {
+ /** `true` when the refresh token itself was rejected and retrying is pointless. */
+ readonly permanent: boolean;
+
+ constructor(message: string, permanent: boolean, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = "TokenRefreshError";
+ this.permanent = permanent;
+ }
+}
+
+interface DiscoveryCacheEntry {
+ readonly tokenEndpoint: string;
+ readonly expiresAtMs: number;
+}
+
+const discoveryCache = new Map();
+const inFlightRefreshes = new Map>();
+
+/** Clears the discovery cache and in-flight refreshes. Exported for tests. */
+export function resetOidcRuntimeState(): void {
+ discoveryCache.clear();
+ inFlightRefreshes.clear();
+}
+
+async function resolveTokenEndpoint(
+ issuer: string,
+ timeoutMs: number,
+ nowMs: number,
+): Promise {
+ const cached = discoveryCache.get(issuer);
+ if (cached && cached.expiresAtMs > nowMs) {
+ return cached.tokenEndpoint;
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(`${issuer}/.well-known/openid-configuration`, {
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (cause) {
+ throw new TokenRefreshError("OIDC discovery request failed", false, { cause });
+ }
+ if (!response.ok) {
+ throw new TokenRefreshError(`OIDC discovery failed with status ${response.status}`, false);
+ }
+
+ let tokenEndpoint: unknown;
+ try {
+ tokenEndpoint = ((await response.json()) as Record).token_endpoint;
+ } catch (cause) {
+ throw new TokenRefreshError("OIDC discovery returned an unreadable body", false, { cause });
+ }
+ if (typeof tokenEndpoint !== "string" || tokenEndpoint === "") {
+ throw new TokenRefreshError("OIDC discovery returned no token_endpoint", false);
+ }
+
+ discoveryCache.set(issuer, { tokenEndpoint, expiresAtMs: nowMs + DISCOVERY_CACHE_TTL_MS });
+ return tokenEndpoint;
+}
+
+export interface RefreshedTokens {
+ readonly accessToken: string;
+ readonly refreshToken?: string;
+ readonly expiresInSeconds?: number;
+}
+
+export interface RefreshRequest {
+ readonly issuer: string;
+ readonly clientId: string;
+ readonly clientSecret: string;
+ readonly refreshToken: string;
+ readonly timeoutMs: number;
+}
+
+async function performRefresh(request: RefreshRequest, nowMs: number): Promise {
+ const tokenEndpoint = await resolveTokenEndpoint(request.issuer, request.timeoutMs, nowMs);
+
+ let response: Response;
+ try {
+ response = await fetch(tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ client_id: request.clientId,
+ client_secret: request.clientSecret,
+ refresh_token: request.refreshToken,
+ }),
+ signal: AbortSignal.timeout(request.timeoutMs),
+ });
+ } catch (cause) {
+ throw new TokenRefreshError("Token refresh request failed", false, { cause });
+ }
+
+ if (!response.ok) {
+ const permanent = response.status >= 400 && response.status < 500;
+ if (permanent) {
+ // A 4xx may also mean the cached token endpoint is stale.
+ discoveryCache.delete(request.issuer);
+ }
+ throw new TokenRefreshError(`Token refresh failed with status ${response.status}`, permanent);
+ }
+
+ let payload: Record;
+ try {
+ payload = (await response.json()) as Record;
+ } catch (cause) {
+ throw new TokenRefreshError("Token response was unreadable", false, { cause });
+ }
+
+ const accessToken = payload.access_token;
+ if (typeof accessToken !== "string" || accessToken === "") {
+ throw new TokenRefreshError("Token response contained no access_token", false);
+ }
+
+ return {
+ accessToken,
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : undefined,
+ expiresInSeconds: isPositiveFinite(payload.expires_in) ? payload.expires_in : undefined,
+ };
+}
+
+/**
+ * Refresh the access token, de-duplicated per refresh token: concurrent callers
+ * share a single POST so IdPs with refresh-token rotation do not invalidate the
+ * losers of the race.
+ */
+export function refreshAccessToken(
+ request: RefreshRequest,
+ nowMs: number = Date.now(),
+): Promise {
+ const inFlight = inFlightRefreshes.get(request.refreshToken);
+ if (inFlight) {
+ return inFlight;
+ }
+ const pending = performRefresh(request, nowMs).finally(() => {
+ inFlightRefreshes.delete(request.refreshToken);
+ });
+ inFlightRefreshes.set(request.refreshToken, pending);
+ return pending;
+}
+
+export interface RefreshOptions {
+ readonly issuer: string | undefined;
+ readonly clientId: string | undefined;
+ readonly clientSecret: string | undefined;
+ readonly refreshSkewSeconds: number;
+ readonly timeoutMs: number;
+}
+
+/**
+ * Return the token state to persist on the JWT: unchanged while the access token is
+ * still comfortably valid, refreshed when it is inside the refresh window.
+ *
+ * A transient failure (5xx, network error, timeout) keeps the existing token and only
+ * fails the session once that token has actually expired; a 4xx from the token
+ * endpoint means the refresh token is dead and fails the session immediately.
+ */
+export async function ensureFreshTokens(
+ state: TokenState,
+ options: RefreshOptions,
+ nowMs: number = Date.now(),
+): Promise {
+ if (!needsRefresh(state, options.refreshSkewSeconds, nowMs)) {
+ return state;
+ }
+
+ const expired = isAccessTokenExpired(state.expiresAt, nowMs);
+ const { issuer, clientId, clientSecret } = options;
+
+ if (!state.refreshToken || !issuer || !clientId || !clientSecret) {
+ // No way to refresh (e.g. the IdP did not grant `offline_access`).
+ return expired ? { ...state, error: REFRESH_TOKEN_ERROR } : state;
+ }
+
+ try {
+ const refreshed = await refreshAccessToken(
+ {
+ issuer,
+ clientId,
+ clientSecret,
+ refreshToken: state.refreshToken,
+ timeoutMs: options.timeoutMs,
+ },
+ nowMs,
+ );
+
+ const lifetime = refreshed.expiresInSeconds ?? state.tokenLifetime;
+ return {
+ ...state,
+ accessToken: refreshed.accessToken,
+ refreshToken: refreshed.refreshToken ?? state.refreshToken,
+ expiresAt: lifetime === undefined ? undefined : Math.floor(nowMs / 1000) + lifetime,
+ tokenLifetime: lifetime,
+ error: undefined,
+ };
+ } catch (error) {
+ const permanent = error instanceof TokenRefreshError && error.permanent;
+ // Never log token material — only the classification and the error message.
+ console.error(
+ `Token refresh failed (${permanent ? "permanent" : "transient"}):`,
+ error instanceof Error ? error.message : String(error),
+ );
+ return permanent || expired ? { ...state, error: REFRESH_TOKEN_ERROR } : state;
+ }
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+function asNumber(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
+}
+
+/** Read the token state out of the untyped NextAuth JWT record. */
+export function toTokenState(token: Record): TokenState {
+ return {
+ accessToken: asString(token.accessToken),
+ refreshToken: asString(token.refreshToken),
+ idToken: asString(token.idToken),
+ expiresAt: asNumber(token.expiresAt),
+ tokenLifetime: asNumber(token.tokenLifetime),
+ error: asString(token.error),
+ };
+}
+
+/** Write the token state back onto the untyped NextAuth JWT record. */
+export function applyTokenState(token: Record, state: TokenState): void {
+ token.accessToken = state.accessToken;
+ token.refreshToken = state.refreshToken;
+ token.idToken = state.idToken;
+ token.expiresAt = state.expiresAt;
+ token.tokenLifetime = state.tokenLifetime;
+ token.error = state.error;
+}