@eventuras/fides-auth 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { SessionClientEvent } from './session-events';
1
2
  /**
2
3
  * Configuration for {@link createHeartbeat}.
3
4
  */
@@ -5,8 +6,9 @@ export interface HeartbeatConfig {
5
6
  /**
6
7
  * Endpoint to POST to in order to refresh the session.
7
8
  * The endpoint must run a server-side refresh and return 2xx with
8
- * `{ accessTokenExpiresAt }` (ISO 8601) on success, or 401 when the refresh
9
- * token is no longer valid.
9
+ * `{ accessTokenExpiresAt }` (ISO 8601) on success, 401 with `{ reason }` when
10
+ * the session is over, and any other non-2xx (503) when the refresh could not
11
+ * be attempted — only 401 ends the session; the rest are retried.
10
12
  * @default '/api/auth/heartbeat'
11
13
  */
12
14
  endpoint?: string;
@@ -58,6 +60,22 @@ export interface HeartbeatConfig {
58
60
  * is just an observability hook.
59
61
  */
60
62
  onError?: (error: Error) => void;
63
+ /**
64
+ * Called for every session lifecycle event this engine observes.
65
+ *
66
+ * The logger writes to the browser console, which production never sees. This
67
+ * hook is the way out: forward the events to your own endpoint, Sentry, or an
68
+ * OTLP collector. The library deliberately ships no transport — where these
69
+ * go, and whether they go anywhere, is the consumer's call.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * useHeartbeat({
74
+ * onEvent: (event) => navigator.sendBeacon('/api/telemetry', JSON.stringify(event)),
75
+ * });
76
+ * ```
77
+ */
78
+ onEvent?: (event: SessionClientEvent) => void;
61
79
  /**
62
80
  * Namespace for the logger.
63
81
  * @default 'fides:heartbeat'
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat.d.ts","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"AAeA;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAE1C;;OAEG;IACH,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAE9B;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB;AA2BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,MAAM,GAAE,eAAoB,GAAG,eAAe,CAsK7E"}
1
+ {"version":3,"file":"heartbeat.d.ts","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"AAcA,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,kBAAkB,CAAC;AAE1B;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAE1C;;OAEG;IACH,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAE9B;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAEjC;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAE9C;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB;AA2BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,MAAM,GAAE,eAAoB,GAAG,eAAe,CA0M7E"}
package/dist/heartbeat.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_ACTIVITY_EVENTS, createActivityTracker } from "./activity-tracker.js";
2
2
  import { createLogger } from "./logger.js";
3
+ import { SESSION_EVENT, isSessionRejectedReason } from "./session-events.js";
3
4
  //#region src/heartbeat.ts
4
5
  var DEFAULTS = {
5
6
  endpoint: "/api/auth/heartbeat",
@@ -53,6 +54,13 @@ function createHeartbeat(config = {}) {
53
54
  });
54
55
  const tracker = createActivityTracker();
55
56
  const abortController = new AbortController();
57
+ const emit = (event) => {
58
+ try {
59
+ config.onEvent?.(event);
60
+ } catch (error) {
61
+ logger.warn({ error: String(error) }, "onEvent callback threw");
62
+ }
63
+ };
56
64
  let timeoutId = null;
57
65
  let stopped = false;
58
66
  let primed = initialExpiresAt !== null;
@@ -130,24 +138,50 @@ function createHeartbeat(config = {}) {
130
138
  });
131
139
  if (stopped) return;
132
140
  if (response.status === 401) {
133
- logger.info("Heartbeat returned 401 — session expired");
141
+ const body = await response.json().catch(() => null);
142
+ const reason = isSessionRejectedReason(body?.reason) ? body.reason : void 0;
143
+ logger.info({ reason }, "Heartbeat returned 401 — session expired");
144
+ emit({
145
+ event: SESSION_EVENT.REJECTED,
146
+ source: "heartbeat",
147
+ reason
148
+ });
134
149
  config.onSessionExpired?.();
135
150
  teardown();
136
151
  return;
137
152
  }
138
- if (!response.ok) throw new Error(`Heartbeat failed with status ${response.status}`);
153
+ if (!response.ok) {
154
+ emit({
155
+ event: SESSION_EVENT.REFRESH_FAILED,
156
+ source: "heartbeat",
157
+ status: response.status,
158
+ attempt: failureCount + 1
159
+ });
160
+ throw new Error(`Heartbeat failed with status ${response.status}`);
161
+ }
139
162
  failureCount = 0;
140
163
  primed = true;
141
164
  expiresAt = toEpochMs((await response.json().catch(() => null))?.accessTokenExpiresAt);
142
165
  logger.debug({ expiresAt }, "Heartbeat refresh succeeded");
166
+ emit({
167
+ event: SESSION_EVENT.REFRESHED,
168
+ source: "heartbeat",
169
+ expiresAt
170
+ });
143
171
  config.onRefreshed?.();
144
172
  scheduleRefresh();
145
173
  } catch (error) {
146
174
  if (stopped) return;
147
175
  const err = error instanceof Error ? error : new Error(String(error));
148
176
  logger.warn({ error: err.message }, "Heartbeat tick failed, will retry");
149
- config.onError?.(err);
150
177
  failureCount += 1;
178
+ if (!(error instanceof Error) || !/^Heartbeat failed with status/.test(err.message)) emit({
179
+ event: SESSION_EVENT.REFRESH_FAILED,
180
+ source: "heartbeat",
181
+ attempt: failureCount,
182
+ error: err.message
183
+ });
184
+ config.onError?.(err);
151
185
  const backoff = Math.min(MAX_RETRY_DELAY_MS, minRefreshIntervalMs * 2 ** (failureCount - 1));
152
186
  scheduleRefresh(backoff);
153
187
  }
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat.js","names":[],"sources":["../src/heartbeat.ts"],"sourcesContent":["// heartbeat.ts\n//\n// Framework-agnostic, browser-side session keepalive engine. This is the logic\n// behind the React `useHeartbeat` hook, extracted so it has no React dependency:\n// any adapter (React, React Router, plain JS) can start it and get a `stop()`\n// back. It uses browser globals (`fetch`, `document`, `window`) at runtime, but\n// importing this module performs no I/O and touches no globals.\n\nimport {\n createActivityTracker,\n DEFAULT_ACTIVITY_EVENTS,\n type ActivityTracker,\n} from './activity-tracker';\nimport { createLogger } from './logger';\n\n/**\n * Configuration for {@link createHeartbeat}.\n */\nexport interface HeartbeatConfig {\n /**\n * Endpoint to POST to in order to refresh the session.\n * The endpoint must run a server-side refresh and return 2xx with\n * `{ accessTokenExpiresAt }` (ISO 8601) on success, or 401 when the refresh\n * token is no longer valid.\n * @default '/api/auth/heartbeat'\n */\n endpoint?: string;\n\n /**\n * How much of the access-token lifetime to keep as headroom: the refresh is\n * scheduled when `fraction` of the TTL remains. Governs long tokens (a 1 h\n * token at 0.3 refreshes ~42 min in); short tokens are dominated by\n * {@link minSkewMs} instead.\n * @default 0.3\n */\n fraction?: number;\n\n /**\n * Absolute floor for the refresh lead time (network round-trip + clock skew).\n * Dominates for short-lived tokens: a 10 s token still refreshes ~5 s in.\n * @default 5_000 (5 seconds)\n */\n minSkewMs?: number;\n\n /**\n * Floor on how often a refresh may fire, so ultra-short tokens don't hammer\n * the endpoint.\n * @default 5_000 (5 seconds)\n */\n minRefreshIntervalMs?: number;\n\n /**\n * How recently the user must have interacted for a due refresh to fire.\n * Decoupled from the access-token TTL on purpose — this only suppresses\n * refreshes in genuinely abandoned tabs, so keep it generous (tie it to the\n * IdP's SSO/session idle, not the access token). When a refresh is suppressed\n * the engine waits for the next interaction (or tab focus) and refreshes then.\n * @default 1_800_000 (30 minutes)\n */\n idleThresholdMs?: number;\n\n /**\n * Known access-token expiry at start (ISO 8601 string or epoch ms). When\n * provided, the first refresh is scheduled from it instead of priming with an\n * immediate refresh to discover the expiry.\n */\n initialExpiresAt?: string | number | null;\n\n /**\n * Called when the heartbeat endpoint returns 401 — refresh token is gone.\n */\n onSessionExpired?: () => void;\n\n /**\n * Called after a successful refresh.\n */\n onRefreshed?: () => void;\n\n /**\n * Called on transient errors (network failure, 5xx). The engine retries — this\n * is just an observability hook.\n */\n onError?: (error: Error) => void;\n\n /**\n * Namespace for the logger.\n * @default 'fides:heartbeat'\n */\n loggerNamespace?: string;\n}\n\n/** Handle returned by {@link createHeartbeat}. */\nexport interface HeartbeatHandle {\n /** Stops the heartbeat: clears timers, removes listeners, aborts in-flight fetches. */\n stop: () => void;\n}\n\nconst DEFAULTS = {\n endpoint: '/api/auth/heartbeat',\n fraction: 0.3,\n minSkewMs: 5_000,\n minRefreshIntervalMs: 5_000,\n idleThresholdMs: 30 * 60_000,\n loggerNamespace: 'fides:heartbeat',\n} as const;\n\n// Fallback cadence when a refresh succeeds but the response carries no usable\n// expiry — keeps the session alive without busy-looping on an unknown TTL.\nconst FALLBACK_INTERVAL_MS = 60_000;\n// Upper bound for exponential backoff between transient-failure retries.\nconst MAX_RETRY_DELAY_MS = 60_000;\n\n/** Parses an ISO 8601 string or epoch-ms number into epoch ms, or null. */\nfunction toEpochMs(value: string | number | null | undefined): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null;\n if (typeof value === 'string') {\n const t = Date.parse(value);\n return Number.isFinite(t) ? t : null;\n }\n return null;\n}\n\n/**\n * Starts an expiry-driven session keepalive and returns a handle to stop it.\n *\n * Schedules each refresh from the access token's actual expiry rather than a\n * fixed interval, so the cadence self-adjusts to whatever TTL the IdP issues: a\n * 5-minute token refreshes ~3.5 min in; a 10-second token refreshes ~5 s in\n * (skew floor). It primes with one refresh on start to learn the current expiry\n * (pass {@link HeartbeatConfig.initialExpiresAt} to skip that), retries on\n * transient failure, and — for hidden or idle tabs — defers the refresh until\n * the tab is focused or the user interacts again.\n *\n * A 401 from the endpoint (refresh token / SSO session gone) is the logout\n * trigger via {@link HeartbeatConfig.onSessionExpired}; the idle gate is only an\n * optimization for abandoned tabs, never a gate on access-token freshness.\n *\n * No-ops (returns a handle whose `stop` does nothing) when there is no `window`,\n * e.g. during server-side rendering.\n */\nexport function createHeartbeat(config: HeartbeatConfig = {}): HeartbeatHandle {\n if (typeof window === 'undefined') return { stop: () => undefined };\n\n const endpoint = config.endpoint ?? DEFAULTS.endpoint;\n const fraction = config.fraction ?? DEFAULTS.fraction;\n const minSkewMs = config.minSkewMs ?? DEFAULTS.minSkewMs;\n const minRefreshIntervalMs = config.minRefreshIntervalMs ?? DEFAULTS.minRefreshIntervalMs;\n const idleThresholdMs = config.idleThresholdMs ?? DEFAULTS.idleThresholdMs;\n const initialExpiresAt = toEpochMs(config.initialExpiresAt);\n const loggerNamespace = config.loggerNamespace ?? DEFAULTS.loggerNamespace;\n\n const logger = createLogger({\n namespace: loggerNamespace,\n context: { component: 'Heartbeat' },\n });\n\n const tracker: ActivityTracker = createActivityTracker();\n const abortController = new AbortController();\n\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n let stopped = false;\n let primed = initialExpiresAt !== null;\n let expiresAt: number | null = initialExpiresAt;\n let failureCount = 0;\n let wakerArmed = false;\n\n const clearTimer = (): void => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n };\n\n const disarmWaker = (): void => {\n if (!wakerArmed) return;\n wakerArmed = false;\n for (const event of DEFAULT_ACTIVITY_EVENTS) {\n globalThis.removeEventListener(event, onWake);\n }\n document.removeEventListener('visibilitychange', onVisibleWake);\n };\n\n const teardown = (): void => {\n if (stopped) return;\n stopped = true;\n clearTimer();\n disarmWaker();\n abortController.abort();\n tracker.dispose();\n };\n\n // Time until the next refresh should fire, from the current known expiry.\n const nextDelay = (): number => {\n if (!primed) return 0; // prime now to discover the expiry\n if (expiresAt === null) return FALLBACK_INTERVAL_MS;\n const now = Date.now();\n const ttl = Math.max(0, expiresAt - now);\n const lead = Math.max(minSkewMs, fraction * ttl);\n return Math.max(minRefreshIntervalMs, expiresAt - lead - now);\n };\n\n const scheduleRefresh = (delay = nextDelay()): void => {\n if (stopped) return;\n clearTimer();\n timeoutId = setTimeout(onDue, delay);\n };\n\n function onWake(): void {\n if (stopped || !wakerArmed) return;\n disarmWaker();\n void onDue();\n }\n\n function onVisibleWake(): void {\n if (document.visibilityState !== 'visible') return;\n // Returning to the tab counts as activity, so a refresh deferred past\n // idleThresholdMs while hidden runs now instead of deferring again\n // (a visibility change does not touch the activity tracker on its own).\n tracker.recordActivity();\n onWake();\n }\n\n // Suspend refreshing until the tab is focused or the user interacts again.\n const deferUntilActive = (): void => {\n if (stopped || wakerArmed) return;\n wakerArmed = true;\n clearTimer();\n for (const event of DEFAULT_ACTIVITY_EVENTS) {\n globalThis.addEventListener(event, onWake, { passive: true });\n }\n document.addEventListener('visibilitychange', onVisibleWake);\n };\n\n async function onDue(): Promise<void> {\n if (stopped) return;\n timeoutId = null;\n\n // Hidden or abandoned: don't refresh now; wake on focus/interaction.\n if (document.visibilityState === 'hidden' || !tracker.isActiveWithin(idleThresholdMs)) {\n logger.debug('Refresh due but tab hidden/idle — deferring until active');\n deferUntilActive();\n return;\n }\n\n await beat();\n }\n\n async function beat(): Promise<void> {\n if (stopped) return;\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json' },\n signal: abortController.signal,\n });\n\n // Stopped (or 401-driven teardown) during the fetch — bail before touching\n // callbacks so we don't fire on a dead consumer.\n if (stopped) return;\n\n if (response.status === 401) {\n logger.info('Heartbeat returned 401 — session expired');\n config.onSessionExpired?.();\n teardown();\n return;\n }\n\n if (!response.ok) {\n throw new Error(`Heartbeat failed with status ${response.status}`);\n }\n\n failureCount = 0;\n primed = true;\n const body = (await response.json().catch(() => null)) as\n | { accessTokenExpiresAt?: string | number | null }\n | null;\n expiresAt = toEpochMs(body?.accessTokenExpiresAt);\n\n logger.debug({ expiresAt }, 'Heartbeat refresh succeeded');\n config.onRefreshed?.();\n scheduleRefresh();\n } catch (error) {\n if (stopped) return;\n const err = error instanceof Error ? error : new Error(String(error));\n logger.warn({ error: err.message }, 'Heartbeat tick failed, will retry');\n config.onError?.(err);\n\n // Retry with capped exponential backoff; a real expiry resumes the normal\n // cadence once a tick succeeds.\n failureCount += 1;\n const backoff = Math.min(\n MAX_RETRY_DELAY_MS,\n minRefreshIntervalMs * 2 ** (failureCount - 1),\n );\n scheduleRefresh(backoff);\n }\n }\n\n scheduleRefresh();\n logger.debug(\n { fraction, minSkewMs, minRefreshIntervalMs, idleThresholdMs, endpoint, primed },\n 'Heartbeat started',\n );\n\n return { stop: teardown };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"heartbeat.js","names":[],"sources":["../src/heartbeat.ts"],"sourcesContent":["// heartbeat.ts\n//\n// Framework-agnostic, browser-side session keepalive engine. This is the logic\n// behind the React `useHeartbeat` hook, extracted so it has no React dependency:\n// any adapter (React, React Router, plain JS) can start it and get a `stop()`\n// back. It uses browser globals (`fetch`, `document`, `window`) at runtime, but\n// importing this module performs no I/O and touches no globals.\n\nimport {\n createActivityTracker,\n DEFAULT_ACTIVITY_EVENTS,\n type ActivityTracker,\n} from './activity-tracker';\nimport { createLogger } from './logger';\nimport {\n SESSION_EVENT,\n isSessionRejectedReason,\n type SessionClientEvent,\n} from './session-events';\n\n/**\n * Configuration for {@link createHeartbeat}.\n */\nexport interface HeartbeatConfig {\n /**\n * Endpoint to POST to in order to refresh the session.\n * The endpoint must run a server-side refresh and return 2xx with\n * `{ accessTokenExpiresAt }` (ISO 8601) on success, 401 with `{ reason }` when\n * the session is over, and any other non-2xx (503) when the refresh could not\n * be attempted — only 401 ends the session; the rest are retried.\n * @default '/api/auth/heartbeat'\n */\n endpoint?: string;\n\n /**\n * How much of the access-token lifetime to keep as headroom: the refresh is\n * scheduled when `fraction` of the TTL remains. Governs long tokens (a 1 h\n * token at 0.3 refreshes ~42 min in); short tokens are dominated by\n * {@link minSkewMs} instead.\n * @default 0.3\n */\n fraction?: number;\n\n /**\n * Absolute floor for the refresh lead time (network round-trip + clock skew).\n * Dominates for short-lived tokens: a 10 s token still refreshes ~5 s in.\n * @default 5_000 (5 seconds)\n */\n minSkewMs?: number;\n\n /**\n * Floor on how often a refresh may fire, so ultra-short tokens don't hammer\n * the endpoint.\n * @default 5_000 (5 seconds)\n */\n minRefreshIntervalMs?: number;\n\n /**\n * How recently the user must have interacted for a due refresh to fire.\n * Decoupled from the access-token TTL on purpose — this only suppresses\n * refreshes in genuinely abandoned tabs, so keep it generous (tie it to the\n * IdP's SSO/session idle, not the access token). When a refresh is suppressed\n * the engine waits for the next interaction (or tab focus) and refreshes then.\n * @default 1_800_000 (30 minutes)\n */\n idleThresholdMs?: number;\n\n /**\n * Known access-token expiry at start (ISO 8601 string or epoch ms). When\n * provided, the first refresh is scheduled from it instead of priming with an\n * immediate refresh to discover the expiry.\n */\n initialExpiresAt?: string | number | null;\n\n /**\n * Called when the heartbeat endpoint returns 401 — refresh token is gone.\n */\n onSessionExpired?: () => void;\n\n /**\n * Called after a successful refresh.\n */\n onRefreshed?: () => void;\n\n /**\n * Called on transient errors (network failure, 5xx). The engine retries — this\n * is just an observability hook.\n */\n onError?: (error: Error) => void;\n\n /**\n * Called for every session lifecycle event this engine observes.\n *\n * The logger writes to the browser console, which production never sees. This\n * hook is the way out: forward the events to your own endpoint, Sentry, or an\n * OTLP collector. The library deliberately ships no transport — where these\n * go, and whether they go anywhere, is the consumer's call.\n *\n * @example\n * ```ts\n * useHeartbeat({\n * onEvent: (event) => navigator.sendBeacon('/api/telemetry', JSON.stringify(event)),\n * });\n * ```\n */\n onEvent?: (event: SessionClientEvent) => void;\n\n /**\n * Namespace for the logger.\n * @default 'fides:heartbeat'\n */\n loggerNamespace?: string;\n}\n\n/** Handle returned by {@link createHeartbeat}. */\nexport interface HeartbeatHandle {\n /** Stops the heartbeat: clears timers, removes listeners, aborts in-flight fetches. */\n stop: () => void;\n}\n\nconst DEFAULTS = {\n endpoint: '/api/auth/heartbeat',\n fraction: 0.3,\n minSkewMs: 5_000,\n minRefreshIntervalMs: 5_000,\n idleThresholdMs: 30 * 60_000,\n loggerNamespace: 'fides:heartbeat',\n} as const;\n\n// Fallback cadence when a refresh succeeds but the response carries no usable\n// expiry — keeps the session alive without busy-looping on an unknown TTL.\nconst FALLBACK_INTERVAL_MS = 60_000;\n// Upper bound for exponential backoff between transient-failure retries.\nconst MAX_RETRY_DELAY_MS = 60_000;\n\n/** Parses an ISO 8601 string or epoch-ms number into epoch ms, or null. */\nfunction toEpochMs(value: string | number | null | undefined): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null;\n if (typeof value === 'string') {\n const t = Date.parse(value);\n return Number.isFinite(t) ? t : null;\n }\n return null;\n}\n\n/**\n * Starts an expiry-driven session keepalive and returns a handle to stop it.\n *\n * Schedules each refresh from the access token's actual expiry rather than a\n * fixed interval, so the cadence self-adjusts to whatever TTL the IdP issues: a\n * 5-minute token refreshes ~3.5 min in; a 10-second token refreshes ~5 s in\n * (skew floor). It primes with one refresh on start to learn the current expiry\n * (pass {@link HeartbeatConfig.initialExpiresAt} to skip that), retries on\n * transient failure, and — for hidden or idle tabs — defers the refresh until\n * the tab is focused or the user interacts again.\n *\n * A 401 from the endpoint (refresh token / SSO session gone) is the logout\n * trigger via {@link HeartbeatConfig.onSessionExpired}; the idle gate is only an\n * optimization for abandoned tabs, never a gate on access-token freshness.\n *\n * No-ops (returns a handle whose `stop` does nothing) when there is no `window`,\n * e.g. during server-side rendering.\n */\nexport function createHeartbeat(config: HeartbeatConfig = {}): HeartbeatHandle {\n if (typeof window === 'undefined') return { stop: () => undefined };\n\n const endpoint = config.endpoint ?? DEFAULTS.endpoint;\n const fraction = config.fraction ?? DEFAULTS.fraction;\n const minSkewMs = config.minSkewMs ?? DEFAULTS.minSkewMs;\n const minRefreshIntervalMs = config.minRefreshIntervalMs ?? DEFAULTS.minRefreshIntervalMs;\n const idleThresholdMs = config.idleThresholdMs ?? DEFAULTS.idleThresholdMs;\n const initialExpiresAt = toEpochMs(config.initialExpiresAt);\n const loggerNamespace = config.loggerNamespace ?? DEFAULTS.loggerNamespace;\n\n const logger = createLogger({\n namespace: loggerNamespace,\n context: { component: 'Heartbeat' },\n });\n\n const tracker: ActivityTracker = createActivityTracker();\n const abortController = new AbortController();\n\n // A consumer's beacon must never be able to kill the keepalive.\n const emit = (event: SessionClientEvent): void => {\n try {\n config.onEvent?.(event);\n } catch (error) {\n logger.warn({ error: String(error) }, 'onEvent callback threw');\n }\n };\n\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n let stopped = false;\n let primed = initialExpiresAt !== null;\n let expiresAt: number | null = initialExpiresAt;\n let failureCount = 0;\n let wakerArmed = false;\n\n const clearTimer = (): void => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n };\n\n const disarmWaker = (): void => {\n if (!wakerArmed) return;\n wakerArmed = false;\n for (const event of DEFAULT_ACTIVITY_EVENTS) {\n globalThis.removeEventListener(event, onWake);\n }\n document.removeEventListener('visibilitychange', onVisibleWake);\n };\n\n const teardown = (): void => {\n if (stopped) return;\n stopped = true;\n clearTimer();\n disarmWaker();\n abortController.abort();\n tracker.dispose();\n };\n\n // Time until the next refresh should fire, from the current known expiry.\n const nextDelay = (): number => {\n if (!primed) return 0; // prime now to discover the expiry\n if (expiresAt === null) return FALLBACK_INTERVAL_MS;\n const now = Date.now();\n const ttl = Math.max(0, expiresAt - now);\n const lead = Math.max(minSkewMs, fraction * ttl);\n return Math.max(minRefreshIntervalMs, expiresAt - lead - now);\n };\n\n const scheduleRefresh = (delay = nextDelay()): void => {\n if (stopped) return;\n clearTimer();\n timeoutId = setTimeout(onDue, delay);\n };\n\n function onWake(): void {\n if (stopped || !wakerArmed) return;\n disarmWaker();\n void onDue();\n }\n\n function onVisibleWake(): void {\n if (document.visibilityState !== 'visible') return;\n // Returning to the tab counts as activity, so a refresh deferred past\n // idleThresholdMs while hidden runs now instead of deferring again\n // (a visibility change does not touch the activity tracker on its own).\n tracker.recordActivity();\n onWake();\n }\n\n // Suspend refreshing until the tab is focused or the user interacts again.\n const deferUntilActive = (): void => {\n if (stopped || wakerArmed) return;\n wakerArmed = true;\n clearTimer();\n for (const event of DEFAULT_ACTIVITY_EVENTS) {\n globalThis.addEventListener(event, onWake, { passive: true });\n }\n document.addEventListener('visibilitychange', onVisibleWake);\n };\n\n async function onDue(): Promise<void> {\n if (stopped) return;\n timeoutId = null;\n\n // Hidden or abandoned: don't refresh now; wake on focus/interaction.\n if (document.visibilityState === 'hidden' || !tracker.isActiveWithin(idleThresholdMs)) {\n logger.debug('Refresh due but tab hidden/idle — deferring until active');\n deferUntilActive();\n return;\n }\n\n await beat();\n }\n\n async function beat(): Promise<void> {\n if (stopped) return;\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json' },\n signal: abortController.signal,\n });\n\n // Stopped (or 401-driven teardown) during the fetch — bail before touching\n // callbacks so we don't fire on a dead consumer.\n if (stopped) return;\n\n if (response.status === 401) {\n // The handler names the reason in the body; older servers send nothing,\n // and an unrecognised value is dropped rather than passed through.\n const body = (await response.json().catch(() => null)) as { reason?: unknown } | null;\n const reason = isSessionRejectedReason(body?.reason) ? body.reason : undefined;\n\n logger.info({ reason }, 'Heartbeat returned 401 — session expired');\n emit({ event: SESSION_EVENT.REJECTED, source: 'heartbeat', reason });\n config.onSessionExpired?.();\n teardown();\n return;\n }\n\n if (!response.ok) {\n // 503 means the server could not reach the provider — the session is\n // probably still valid, so this is a retry, not a logout.\n emit({\n event: SESSION_EVENT.REFRESH_FAILED,\n source: 'heartbeat',\n status: response.status,\n attempt: failureCount + 1,\n });\n throw new Error(`Heartbeat failed with status ${response.status}`);\n }\n\n failureCount = 0;\n primed = true;\n const body = (await response.json().catch(() => null)) as\n | { accessTokenExpiresAt?: string | number | null }\n | null;\n expiresAt = toEpochMs(body?.accessTokenExpiresAt);\n\n logger.debug({ expiresAt }, 'Heartbeat refresh succeeded');\n emit({ event: SESSION_EVENT.REFRESHED, source: 'heartbeat', expiresAt });\n config.onRefreshed?.();\n scheduleRefresh();\n } catch (error) {\n if (stopped) return;\n const err = error instanceof Error ? error : new Error(String(error));\n logger.warn({ error: err.message }, 'Heartbeat tick failed, will retry');\n\n // Retry with capped exponential backoff; a real expiry resumes the normal\n // cadence once a tick succeeds.\n failureCount += 1;\n\n // A thrown non-ok response already emitted with its status above; this\n // covers the requests that never landed at all.\n if (!(error instanceof Error) || !/^Heartbeat failed with status/.test(err.message)) {\n emit({\n event: SESSION_EVENT.REFRESH_FAILED,\n source: 'heartbeat',\n attempt: failureCount,\n error: err.message,\n });\n }\n config.onError?.(err);\n\n const backoff = Math.min(\n MAX_RETRY_DELAY_MS,\n minRefreshIntervalMs * 2 ** (failureCount - 1),\n );\n scheduleRefresh(backoff);\n }\n }\n\n scheduleRefresh();\n logger.debug(\n { fraction, minSkewMs, minRefreshIntervalMs, idleThresholdMs, endpoint, primed },\n 'Heartbeat started',\n );\n\n return { stop: teardown };\n}\n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './activity-tracker';
7
7
  export * from './cookies';
8
8
  export * from './logger';
9
9
  export * from './oauth-logging';
10
+ export * from './session-events';
10
11
  export * from './session-refresh';
11
12
  export * from './session-validation';
12
13
  export * from './utils';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { DEFAULT_ACTIVITY_EVENTS, createActivityTracker } from "./activity-tracker.js";
2
2
  import { ACCESS_TOKEN_COOKIE_NAME, COOKIE_INFO_BYTES, COOKIE_MAX_BYTES, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, assertCookieWithinLimit, cookieByteSize, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "./cookies.js";
3
3
  import { configureLogger, createLogger } from "./logger.js";
4
- import { getOAuthConfigLogContext, getOAuthErrorLogContext } from "./oauth-logging.js";
4
+ import { classifyRefreshFailure, getOAuthConfigLogContext, getOAuthErrorLogContext } from "./oauth-logging.js";
5
+ import { SESSION_EVENT, SESSION_REJECTED_REASONS, isSessionRejectedReason, logSessionEvent, newSessionId, sessionEventLevel } from "./session-events.js";
5
6
  import { refreshSession } from "./session-refresh.js";
6
7
  import { accessTokenExpires, createEncryptedJWT, decrypt, decryptJWT, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex } from "./utils.js";
7
8
  import { isSession, validateSessionJwt } from "./session-validation.js";
8
9
  import "./types.js";
9
- export { ACCESS_TOKEN_COOKIE_NAME, COOKIE_INFO_BYTES, COOKIE_MAX_BYTES, CookieTooLargeError, DEFAULT_ACTIVITY_EVENTS, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, accessTokenExpires, assertCookieWithinLimit, configureLogger, cookieByteSize, createActivityTracker, createEncryptedJWT, createLogger, decrypt, decryptJWT, defaultOAuthCookieOptions, defaultSessionCookieOptions, encrypt, generateToken, getOAuthConfigLogContext, getOAuthErrorLogContext, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, isSession, refreshSession, sha256, sha512, toHex, validateSessionJwt };
10
+ export { ACCESS_TOKEN_COOKIE_NAME, COOKIE_INFO_BYTES, COOKIE_MAX_BYTES, CookieTooLargeError, DEFAULT_ACTIVITY_EVENTS, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_EVENT, SESSION_REJECTED_REASONS, accessTokenExpires, assertCookieWithinLimit, classifyRefreshFailure, configureLogger, cookieByteSize, createActivityTracker, createEncryptedJWT, createLogger, decrypt, decryptJWT, defaultOAuthCookieOptions, defaultSessionCookieOptions, encrypt, generateToken, getOAuthConfigLogContext, getOAuthErrorLogContext, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, isSession, isSessionRejectedReason, logSessionEvent, newSessionId, refreshSession, sessionEventLevel, sha256, sha512, toHex, validateSessionJwt };
@@ -1,4 +1,5 @@
1
1
  import { OAuthConfig } from './oauth';
2
+ import { RefreshFailureCause } from './session-events';
2
3
  /**
3
4
  * Returns a structured, log-safe context describing an `OAuthConfig`.
4
5
  *
@@ -42,4 +43,18 @@ export declare function getOAuthErrorLogContext(error: unknown): {
42
43
  causeName: string | undefined;
43
44
  value?: undefined;
44
45
  };
46
+ /**
47
+ * Classifies a refresh failure into the three causes that call for different
48
+ * responses.
49
+ *
50
+ * The distinction is not cosmetic: `invalid_grant` is the provider stating the
51
+ * refresh token is dead, and the user genuinely has to log in again. A transport
52
+ * fault or a provider 5xx says nothing about the session — treating those the
53
+ * same way logs out a user whose session was perfectly valid, which is exactly
54
+ * the bug that makes "logged out every few minutes" so hard to diagnose.
55
+ *
56
+ * Unknown errors fall back to `idp_error` rather than `invalid_grant`, so an
57
+ * unrecognised failure never silently ends a session.
58
+ */
59
+ export declare function classifyRefreshFailure(error: unknown): RefreshFailureCause;
45
60
  //# sourceMappingURL=oauth-logging.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauth-logging.d.ts","sourceRoot":"","sources":["../src/oauth-logging.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,WAAW;;;;;;EAQ3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO;;;;;;;;;;;;;;;;;;;;EA0BrD"}
1
+ {"version":3,"file":"oauth-logging.d.ts","sourceRoot":"","sources":["../src/oauth-logging.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE5D;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,WAAW;;;;;;EAQ3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO;;;;;;;;;;;;;;;;;;;;EA0BrD;AAmCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,CAY1E"}
@@ -44,7 +44,69 @@ function stringProp(value, key) {
44
44
  function numberProp(value, key) {
45
45
  return typeof value[key] === "number" ? value[key] : void 0;
46
46
  }
47
+ /**
48
+ * Node/undici and browser fetch surface connectivity failures as syscall codes on
49
+ * `cause`. Any of these means the request never reached the provider.
50
+ */
51
+ var TRANSPORT_CODES = /* @__PURE__ */ new Set([
52
+ "ECONNREFUSED",
53
+ "ECONNRESET",
54
+ "ENOTFOUND",
55
+ "EAI_AGAIN",
56
+ "ETIMEDOUT",
57
+ "EHOSTUNREACH",
58
+ "ENETUNREACH",
59
+ "EPIPE",
60
+ "EPROTO",
61
+ "CERT_HAS_EXPIRED",
62
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
63
+ "UND_ERR_CONNECT_TIMEOUT",
64
+ "UND_ERR_HEADERS_TIMEOUT",
65
+ "UND_ERR_BODY_TIMEOUT",
66
+ "UND_ERR_SOCKET"
67
+ ]);
68
+ /** `fetch` rejects with a bare TypeError on network failure; the wording is runtime-specific. */
69
+ var TRANSPORT_MESSAGE = /fetch failed|failed to fetch|network(?:\s|error)|load failed|socket hang up/i;
70
+ /**
71
+ * Classifies a refresh failure into the three causes that call for different
72
+ * responses.
73
+ *
74
+ * The distinction is not cosmetic: `invalid_grant` is the provider stating the
75
+ * refresh token is dead, and the user genuinely has to log in again. A transport
76
+ * fault or a provider 5xx says nothing about the session — treating those the
77
+ * same way logs out a user whose session was perfectly valid, which is exactly
78
+ * the bug that makes "logged out every few minutes" so hard to diagnose.
79
+ *
80
+ * Unknown errors fall back to `idp_error` rather than `invalid_grant`, so an
81
+ * unrecognised failure never silently ends a session.
82
+ */
83
+ function classifyRefreshFailure(error) {
84
+ const context = getOAuthErrorLogContext(error);
85
+ if (context.code === "OAUTH_RESPONSE_BODY_ERROR" && context.error === "invalid_grant") return "invalid_grant";
86
+ if (isTransportFailure(error, context)) return "transport";
87
+ return "idp_error";
88
+ }
89
+ function isTransportFailure(error, context) {
90
+ if (context.name === "AbortError" || context.name === "TimeoutError") return true;
91
+ if (context.causeName === "AbortError" || context.causeName === "TimeoutError") return true;
92
+ const code = syscallCode(error);
93
+ if (code !== void 0 && TRANSPORT_CODES.has(code)) return true;
94
+ if (context.name === "TypeError") {
95
+ if (context.message && TRANSPORT_MESSAGE.test(context.message)) return true;
96
+ if (context.causeMessage && TRANSPORT_MESSAGE.test(context.causeMessage)) return true;
97
+ }
98
+ return false;
99
+ }
100
+ /** Reads a syscall code off the error or its `cause`, where fetch buries connectivity failures. */
101
+ function syscallCode(error) {
102
+ if (typeof error !== "object" || error === null) return void 0;
103
+ const candidate = error;
104
+ const own = stringProp(candidate, "code");
105
+ if (own !== void 0 && own !== "OAUTH_RESPONSE_BODY_ERROR") return own;
106
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : null;
107
+ return cause ? stringProp(cause, "code") : void 0;
108
+ }
47
109
  //#endregion
48
- export { getOAuthConfigLogContext, getOAuthErrorLogContext };
110
+ export { classifyRefreshFailure, getOAuthConfigLogContext, getOAuthErrorLogContext };
49
111
 
50
112
  //# sourceMappingURL=oauth-logging.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauth-logging.js","names":[],"sources":["../src/oauth-logging.ts"],"sourcesContent":["import type { OAuthConfig } from './oauth';\n\n/**\n * Returns a structured, log-safe context describing an `OAuthConfig`.\n *\n * Excludes `clientSecret`. Use this to attach provider configuration to log\n * lines for debugging OAuth/OIDC flows without leaking credentials.\n */\nexport function getOAuthConfigLogContext(config: OAuthConfig) {\n return {\n clientId: config.clientId,\n issuer: config.issuer,\n redirectUri: config.redirect_uri,\n scope: config.scope,\n usePar: config.usePar ?? false,\n };\n}\n\n/**\n * Returns a structured log context for an unknown OAuth-related error.\n *\n * `oauth4webapi` (used via `openid-client`) wraps OAuth response errors. The\n * original OAuth response (`{error, error_description, ...}`) usually lives\n * either on the error itself or on `cause`.\n *\n * @see https://github.com/panva/oauth4webapi/blob/main/docs/classes/ResponseBodyError.md\n */\nexport function getOAuthErrorLogContext(error: unknown) {\n if (typeof error !== 'object' || error === null) {\n return { value: error };\n }\n\n const candidate = error as Record<string, unknown>;\n const cause =\n typeof candidate.cause === 'object' && candidate.cause !== null\n ? (candidate.cause as Record<string, unknown>)\n : null;\n\n // OAuth-specific fields (code/error/error_description) may live either on\n // the error itself or on `cause` depending on how oauth4webapi wraps the\n // response. Prefer the top-level value but fall back to `cause`.\n return {\n code: stringProp(candidate, 'code') ?? (cause ? stringProp(cause, 'code') : undefined),\n error: stringProp(candidate, 'error') ?? (cause ? stringProp(cause, 'error') : undefined),\n errorDescription:\n stringProp(candidate, 'error_description') ??\n (cause ? stringProp(cause, 'error_description') : undefined),\n message: stringProp(candidate, 'message'),\n name: stringProp(candidate, 'name'),\n status: numberProp(candidate, 'status'),\n causeMessage: cause ? stringProp(cause, 'message') : undefined,\n causeName: cause ? stringProp(cause, 'name') : undefined,\n };\n}\n\nfunction stringProp(value: Record<string, unknown>, key: string): string | undefined {\n return typeof value[key] === 'string' ? (value[key] as string) : undefined;\n}\n\nfunction numberProp(value: Record<string, unknown>, key: string): number | undefined {\n return typeof value[key] === 'number' ? (value[key] as number) : undefined;\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,yBAAyB,QAAqB;CAC5D,OAAO;EACL,UAAU,OAAO;EACjB,QAAQ,OAAO;EACf,aAAa,OAAO;EACpB,OAAO,OAAO;EACd,QAAQ,OAAO,UAAU;CAC3B;AACF;;;;;;;;;;AAWA,SAAgB,wBAAwB,OAAgB;CACtD,IAAI,OAAO,UAAU,YAAY,UAAU,MACzC,OAAO,EAAE,OAAO,MAAM;CAGxB,MAAM,YAAY;CAClB,MAAM,QACJ,OAAO,UAAU,UAAU,YAAY,UAAU,UAAU,OACtD,UAAU,QACX;CAKN,OAAO;EACL,MAAM,WAAW,WAAW,MAAM,MAAM,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;EAC5E,OAAO,WAAW,WAAW,OAAO,MAAM,QAAQ,WAAW,OAAO,OAAO,IAAI,KAAA;EAC/E,kBACE,WAAW,WAAW,mBAAmB,MACxC,QAAQ,WAAW,OAAO,mBAAmB,IAAI,KAAA;EACpD,SAAS,WAAW,WAAW,SAAS;EACxC,MAAM,WAAW,WAAW,MAAM;EAClC,QAAQ,WAAW,WAAW,QAAQ;EACtC,cAAc,QAAQ,WAAW,OAAO,SAAS,IAAI,KAAA;EACrD,WAAW,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;CACjD;AACF;AAEA,SAAS,WAAW,OAAgC,KAAiC;CACnF,OAAO,OAAO,MAAM,SAAS,WAAY,MAAM,OAAkB,KAAA;AACnE;AAEA,SAAS,WAAW,OAAgC,KAAiC;CACnF,OAAO,OAAO,MAAM,SAAS,WAAY,MAAM,OAAkB,KAAA;AACnE"}
1
+ {"version":3,"file":"oauth-logging.js","names":[],"sources":["../src/oauth-logging.ts"],"sourcesContent":["import type { OAuthConfig } from './oauth';\nimport type { RefreshFailureCause } from './session-events';\n\n/**\n * Returns a structured, log-safe context describing an `OAuthConfig`.\n *\n * Excludes `clientSecret`. Use this to attach provider configuration to log\n * lines for debugging OAuth/OIDC flows without leaking credentials.\n */\nexport function getOAuthConfigLogContext(config: OAuthConfig) {\n return {\n clientId: config.clientId,\n issuer: config.issuer,\n redirectUri: config.redirect_uri,\n scope: config.scope,\n usePar: config.usePar ?? false,\n };\n}\n\n/**\n * Returns a structured log context for an unknown OAuth-related error.\n *\n * `oauth4webapi` (used via `openid-client`) wraps OAuth response errors. The\n * original OAuth response (`{error, error_description, ...}`) usually lives\n * either on the error itself or on `cause`.\n *\n * @see https://github.com/panva/oauth4webapi/blob/main/docs/classes/ResponseBodyError.md\n */\nexport function getOAuthErrorLogContext(error: unknown) {\n if (typeof error !== 'object' || error === null) {\n return { value: error };\n }\n\n const candidate = error as Record<string, unknown>;\n const cause =\n typeof candidate.cause === 'object' && candidate.cause !== null\n ? (candidate.cause as Record<string, unknown>)\n : null;\n\n // OAuth-specific fields (code/error/error_description) may live either on\n // the error itself or on `cause` depending on how oauth4webapi wraps the\n // response. Prefer the top-level value but fall back to `cause`.\n return {\n code: stringProp(candidate, 'code') ?? (cause ? stringProp(cause, 'code') : undefined),\n error: stringProp(candidate, 'error') ?? (cause ? stringProp(cause, 'error') : undefined),\n errorDescription:\n stringProp(candidate, 'error_description') ??\n (cause ? stringProp(cause, 'error_description') : undefined),\n message: stringProp(candidate, 'message'),\n name: stringProp(candidate, 'name'),\n status: numberProp(candidate, 'status'),\n causeMessage: cause ? stringProp(cause, 'message') : undefined,\n causeName: cause ? stringProp(cause, 'name') : undefined,\n };\n}\n\nfunction stringProp(value: Record<string, unknown>, key: string): string | undefined {\n return typeof value[key] === 'string' ? (value[key] as string) : undefined;\n}\n\nfunction numberProp(value: Record<string, unknown>, key: string): number | undefined {\n return typeof value[key] === 'number' ? (value[key] as number) : undefined;\n}\n\n/**\n * Node/undici and browser fetch surface connectivity failures as syscall codes on\n * `cause`. Any of these means the request never reached the provider.\n */\nconst TRANSPORT_CODES = new Set([\n 'ECONNREFUSED',\n 'ECONNRESET',\n 'ENOTFOUND',\n 'EAI_AGAIN',\n 'ETIMEDOUT',\n 'EHOSTUNREACH',\n 'ENETUNREACH',\n 'EPIPE',\n 'EPROTO',\n 'CERT_HAS_EXPIRED',\n 'DEPTH_ZERO_SELF_SIGNED_CERT',\n 'UND_ERR_CONNECT_TIMEOUT',\n 'UND_ERR_HEADERS_TIMEOUT',\n 'UND_ERR_BODY_TIMEOUT',\n 'UND_ERR_SOCKET',\n]);\n\n/** `fetch` rejects with a bare TypeError on network failure; the wording is runtime-specific. */\nconst TRANSPORT_MESSAGE = /fetch failed|failed to fetch|network(?:\\s|error)|load failed|socket hang up/i;\n\n/**\n * Classifies a refresh failure into the three causes that call for different\n * responses.\n *\n * The distinction is not cosmetic: `invalid_grant` is the provider stating the\n * refresh token is dead, and the user genuinely has to log in again. A transport\n * fault or a provider 5xx says nothing about the session — treating those the\n * same way logs out a user whose session was perfectly valid, which is exactly\n * the bug that makes \"logged out every few minutes\" so hard to diagnose.\n *\n * Unknown errors fall back to `idp_error` rather than `invalid_grant`, so an\n * unrecognised failure never silently ends a session.\n */\nexport function classifyRefreshFailure(error: unknown): RefreshFailureCause {\n const context = getOAuthErrorLogContext(error);\n\n if (context.code === 'OAUTH_RESPONSE_BODY_ERROR' && context.error === 'invalid_grant') {\n return 'invalid_grant';\n }\n\n if (isTransportFailure(error, context)) {\n return 'transport';\n }\n\n return 'idp_error';\n}\n\nfunction isTransportFailure(\n error: unknown,\n context: ReturnType<typeof getOAuthErrorLogContext>,\n): boolean {\n // An aborted or timed-out request never got an answer.\n if (context.name === 'AbortError' || context.name === 'TimeoutError') return true;\n if (context.causeName === 'AbortError' || context.causeName === 'TimeoutError') return true;\n\n const code = syscallCode(error);\n if (code !== undefined && TRANSPORT_CODES.has(code)) return true;\n\n // Only trust the message when the error is a bare TypeError — the shape fetch\n // uses for connectivity failures. A provider error that happens to mention a\n // network is still a provider error.\n if (context.name === 'TypeError') {\n if (context.message && TRANSPORT_MESSAGE.test(context.message)) return true;\n if (context.causeMessage && TRANSPORT_MESSAGE.test(context.causeMessage)) return true;\n }\n\n return false;\n}\n\n/** Reads a syscall code off the error or its `cause`, where fetch buries connectivity failures. */\nfunction syscallCode(error: unknown): string | undefined {\n if (typeof error !== 'object' || error === null) return undefined;\n const candidate = error as Record<string, unknown>;\n\n const own = stringProp(candidate, 'code');\n if (own !== undefined && own !== 'OAUTH_RESPONSE_BODY_ERROR') return own;\n\n const cause =\n typeof candidate.cause === 'object' && candidate.cause !== null\n ? (candidate.cause as Record<string, unknown>)\n : null;\n return cause ? stringProp(cause, 'code') : undefined;\n}\n"],"mappings":";;;;;;;AASA,SAAgB,yBAAyB,QAAqB;CAC5D,OAAO;EACL,UAAU,OAAO;EACjB,QAAQ,OAAO;EACf,aAAa,OAAO;EACpB,OAAO,OAAO;EACd,QAAQ,OAAO,UAAU;CAC3B;AACF;;;;;;;;;;AAWA,SAAgB,wBAAwB,OAAgB;CACtD,IAAI,OAAO,UAAU,YAAY,UAAU,MACzC,OAAO,EAAE,OAAO,MAAM;CAGxB,MAAM,YAAY;CAClB,MAAM,QACJ,OAAO,UAAU,UAAU,YAAY,UAAU,UAAU,OACtD,UAAU,QACX;CAKN,OAAO;EACL,MAAM,WAAW,WAAW,MAAM,MAAM,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;EAC5E,OAAO,WAAW,WAAW,OAAO,MAAM,QAAQ,WAAW,OAAO,OAAO,IAAI,KAAA;EAC/E,kBACE,WAAW,WAAW,mBAAmB,MACxC,QAAQ,WAAW,OAAO,mBAAmB,IAAI,KAAA;EACpD,SAAS,WAAW,WAAW,SAAS;EACxC,MAAM,WAAW,WAAW,MAAM;EAClC,QAAQ,WAAW,WAAW,QAAQ;EACtC,cAAc,QAAQ,WAAW,OAAO,SAAS,IAAI,KAAA;EACrD,WAAW,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;CACjD;AACF;AAEA,SAAS,WAAW,OAAgC,KAAiC;CACnF,OAAO,OAAO,MAAM,SAAS,WAAY,MAAM,OAAkB,KAAA;AACnE;AAEA,SAAS,WAAW,OAAgC,KAAiC;CACnF,OAAO,OAAO,MAAM,SAAS,WAAY,MAAM,OAAkB,KAAA;AACnE;;;;;AAMA,IAAM,kCAAkB,IAAI,IAAI;CAC9B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;AAGD,IAAM,oBAAoB;;;;;;;;;;;;;;AAe1B,SAAgB,uBAAuB,OAAqC;CAC1E,MAAM,UAAU,wBAAwB,KAAK;CAE7C,IAAI,QAAQ,SAAS,+BAA+B,QAAQ,UAAU,iBACpE,OAAO;CAGT,IAAI,mBAAmB,OAAO,OAAO,GACnC,OAAO;CAGT,OAAO;AACT;AAEA,SAAS,mBACP,OACA,SACS;CAET,IAAI,QAAQ,SAAS,gBAAgB,QAAQ,SAAS,gBAAgB,OAAO;CAC7E,IAAI,QAAQ,cAAc,gBAAgB,QAAQ,cAAc,gBAAgB,OAAO;CAEvF,MAAM,OAAO,YAAY,KAAK;CAC9B,IAAI,SAAS,KAAA,KAAa,gBAAgB,IAAI,IAAI,GAAG,OAAO;CAK5D,IAAI,QAAQ,SAAS,aAAa;EAChC,IAAI,QAAQ,WAAW,kBAAkB,KAAK,QAAQ,OAAO,GAAG,OAAO;EACvE,IAAI,QAAQ,gBAAgB,kBAAkB,KAAK,QAAQ,YAAY,GAAG,OAAO;CACnF;CAEA,OAAO;AACT;;AAGA,SAAS,YAAY,OAAoC;CACvD,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO,KAAA;CACxD,MAAM,YAAY;CAElB,MAAM,MAAM,WAAW,WAAW,MAAM;CACxC,IAAI,QAAQ,KAAA,KAAa,QAAQ,6BAA6B,OAAO;CAErE,MAAM,QACJ,OAAO,UAAU,UAAU,YAAY,UAAU,UAAU,OACtD,UAAU,QACX;CACN,OAAO,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;AAC7C"}
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAKvC,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAE1E,uFAAuF;AACvF,eAAO,MAAM,YAAY,wCAAwC,CAAC;AAElE,wDAAwD;AACxD,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,MAAM,CAAC,qBAAqB,CAAC,CA4CvC;AAED,8DAA8D;AAC9D,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAmBhF;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,CAAC,aAAa,EAC5B,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,GAAG,CAAC,CASd;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,4BAA4B,CAChD,MAAM,EAAE,MAAM,CAAC,aAAa,EAC5B,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,GAAG,CAAC,CAYd;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,gCAAgC,CACpD,WAAW,EAAE,WAAW,EACxB,WAAW,EAAE,WAAW,GACvB,OAAO,CAAC,GAAG,CAAC,CAkCd;AAED;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC,+CAA+C;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,sBAAsB;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,0BAA0B;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,WAAW,EACxB,WAAW,EAAE,GAAG,EAChB,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,CAAC,qBAAqB,CAAC,CA4CvC;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,CAAC,qBAAqB,EACpC,UAAU,GAAE,MAAgB,GAC3B,YAAY,CAoBd;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,CAAC,qBAAqB,EACpC,UAAU,GAAE,MAAgB,GAC3B,OAAO,CAiCT;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,iBAAiB,EAAE,MAAM,EACzB,WAAW,GAAE,MAAY,EACzB,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,GACvC,GAAG,CA+BL;AAED,uFAAuF;AACvF,MAAM,WAAW,iBAAiB;IAChC,sEAAsE;IACtE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAE/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,OAAO,EAAE,iBAAiB,GAAG,MAAM,GAClC,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC,CA+DrB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,MAAM,CAAC,qBAAqB,CAAC,CAiCvC"}
package/dist/oauth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createLogger } from "./logger.js";
2
2
  import { getOAuthErrorLogContext } from "./oauth-logging.js";
3
+ import { newSessionId } from "./session-events.js";
3
4
  import { decodeJwt } from "jose";
4
5
  import * as openid from "openid-client";
5
6
  //#region src/oauth.ts
@@ -214,6 +215,7 @@ function buildSessionFromTokens(tokens, rolesClaim = "roles") {
214
215
  if (parsed.length > 0) scopes = parsed;
215
216
  }
216
217
  return {
218
+ sid: newSessionId(),
217
219
  tokens: {
218
220
  accessToken: tokens.access_token,
219
221
  accessTokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3).toISOString() : void 0,
package/dist/oauth.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["import { decodeJwt } from 'jose';\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:oauth' });\n\n// Re-export commonly used types from openid-client for convenience\nexport type { Configuration, TokenEndpointResponse } from 'openid-client';\n\n/** Default OIDC scope requesting user identity, profile, email, and offline access. */\nexport const defaultScope = 'openid profile email offline_access';\n\n/** Configuration for server-side OAuth / OIDC flows. */\nexport type OAuthConfig = {\n issuer: string;\n clientId: string;\n clientSecret: string;\n redirect_uri: string;\n scope: string;\n /**\n * Opt in to Pushed Authorization Requests (RFC 9126) when the provider\n * supports it. When true, authorization parameters are posted to the\n * provider's PAR endpoint ahead of the redirect, and only `client_id` +\n * `request_uri` travel through the user-agent. The provider must advertise\n * `pushed_authorization_request_endpoint` in its discovery metadata.\n */\n usePar?: boolean;\n};\n\n/**\n * Refreshes an access token using a refresh token grant.\n *\n * @param oAuthConfig - OAuth configuration for the identity provider\n * @param refreshToken - The refresh token to exchange\n * @returns Token endpoint response containing new access and refresh tokens\n */\nexport async function refreshAccessToken(\n oAuthConfig: OAuthConfig,\n refreshToken: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug({ issuer: oAuthConfig.issuer }, 'Starting token refresh');\n\n try {\n const config = await openid.discovery(\n new URL(oAuthConfig.issuer),\n oAuthConfig.clientId,\n oAuthConfig.clientSecret,\n openid.ClientSecretPost(oAuthConfig.clientSecret),\n );\n\n const tokens = await openid.refreshTokenGrant(\n config,\n refreshToken,\n {\n scope: oAuthConfig.scope,\n },\n );\n\n logger.info({\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in\n }, 'Token refresh successful');\n\n return tokens;\n } catch (error) {\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Most often the refresh token has expired - log at info level\n logger.info(\n { ...errorContext, issuer: oAuthConfig.issuer },\n 'Token refresh failed - refresh token expired or invalid',\n );\n } else {\n // Unexpected error - log at error level\n logger.error({ error, issuer: oAuthConfig.issuer }, 'Token refresh failed');\n }\n\n throw error;\n }\n}\n\n/** PKCE parameters generated for an authorization request. */\nexport interface PKCEOptions {\n code_verifier: string;\n code_challenge: string;\n state: string;\n parameters: Record<string, string>;\n}\n\n/**\n * Creates PKCE parameters for an OAuth flow.\n *\n * @param config - OAuthConfig configuration including the redirect URI and scope.\n * @returns A promise that resolves to an object containing:\n * - code_verifier: The randomly generated PKCE code verifier.\n * - code_challenge: The derived PKCE code challenge.\n * - state: A random state to protect against CSRF.\n * - parameters: A Record<string,string> with the assembled parameters.\n */\nexport async function buildPKCEOptions(config: OAuthConfig): Promise<PKCEOptions> {\n const code_verifier = openid.randomPKCECodeVerifier();\n const code_challenge = await openid.calculatePKCECodeChallenge(code_verifier);\n const state = openid.randomState();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope ?? defaultScope,\n code_challenge,\n code_challenge_method: 'S256',\n state,\n };\n\n logger.info(\n { redirectUri: config.redirect_uri, scope: parameters.scope },\n 'PKCE parameters generated'\n );\n\n return { code_verifier, code_challenge, state, parameters };\n}\n\n/**\n * Builds an authorization URL with PKCE parameters.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function buildAuthorizationUrl(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = openid.buildAuthorizationUrl(config, pkceOptions.parameters);\n logger.info('Authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build authorization URL');\n throw error;\n }\n}\n\n/**\n * Builds an authorization URL using Pushed Authorization Requests (RFC 9126).\n *\n * Posts the PKCE parameters to the provider's PAR endpoint and returns a URL\n * containing only `client_id` and `request_uri`. The provider's discovery\n * metadata must include `pushed_authorization_request_endpoint`.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the PAR-based authorization URL\n */\nexport async function buildAuthorizationUrlWithPAR(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = await openid.buildAuthorizationUrlWithPAR(\n config,\n pkceOptions.parameters,\n );\n logger.info('PAR authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build PAR authorization URL');\n throw error;\n }\n}\n\n/**\n * Convenience function to discover OpenID configuration and build an\n * authorization URL. Routes through PAR when `oauthConfig.usePar` is true and\n * the provider advertises `pushed_authorization_request_endpoint`.\n *\n * - `usePar === true` + provider advertises PAR → uses PAR.\n * - `usePar === true` + provider does **not** advertise PAR → throws.\n * - `usePar` unset/false + provider advertises PAR → standard flow + one-line\n * `info` advisory that PAR is available but not enabled.\n * - `usePar` unset/false + no PAR endpoint → standard flow, no advisory.\n *\n * @param oauthConfig - Your OAuth configuration\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function discoverAndBuildAuthorizationUrl(\n oauthConfig: OAuthConfig,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n logger.debug({ issuer: oauthConfig.issuer }, 'Discovering OpenID configuration');\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret)\n );\n\n const parEndpoint = config.serverMetadata().pushed_authorization_request_endpoint;\n\n if (oauthConfig.usePar) {\n if (!parEndpoint) {\n throw new Error(\n 'PAR requested (usePar=true) but provider does not advertise pushed_authorization_request_endpoint',\n );\n }\n return buildAuthorizationUrlWithPAR(config, pkceOptions);\n }\n\n if (parEndpoint) {\n logger.info(\n { issuer: oauthConfig.issuer },\n 'Provider advertises PAR but usePar is not enabled — set OAuthConfig.usePar=true to use it',\n );\n }\n\n return buildAuthorizationUrl(config, pkceOptions);\n } catch (error) {\n logger.error({ error, issuer: oauthConfig.issuer }, 'Failed to discover config or build URL');\n throw error;\n }\n}\n\n/**\n * Configuration for OAuth client credentials flow (machine-to-machine authentication)\n */\nexport type ClientCredentialsConfig = {\n /** OAuth token endpoint URL. Must be https. */\n tokenEndpoint: string;\n /** OAuth client ID */\n clientId: string;\n /** OAuth client secret */\n clientSecret: string;\n /** Optional scope for the access token */\n scope?: string;\n /**\n * Issuer identifier. Only used to satisfy the server metadata; defaults to the\n * token endpoint's origin, which is right for the common case.\n */\n issuer?: string;\n /**\n * Request timeout in seconds.\n * @default 30\n */\n timeout?: number;\n};\n\n/**\n * Exchanges an authorization code for tokens using OIDC discovery and PKCE.\n *\n * @param oauthConfig - OAuth configuration\n * @param callbackUrl - The full callback URL with query parameters from the OIDC provider\n * @param codeVerifier - The PKCE code verifier stored during login initiation\n * @param expectedState - The state parameter stored during login initiation\n * @returns Token response from the OIDC provider\n */\nexport async function exchangeAuthorizationCode(\n oauthConfig: OAuthConfig,\n callbackUrl: URL,\n codeVerifier: string,\n expectedState: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug(\n { issuer: oauthConfig.issuer, clientId: oauthConfig.clientId },\n 'Exchanging authorization code for tokens',\n );\n\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n\n // openid-client overrides the explicit redirect_uri option with\n // stripParams(callbackUrl). Anchor to oauthConfig.redirect_uri so PAR and\n // token-exchange stay consistent behind reverse proxies that don't forward\n // the original scheme. Build from the trusted base and then copy pathname/\n // search by assignment — `new URL(callbackUrl.pathname, base)` would let a\n // `//evil.com/...` pathname swap the host.\n const normalizedCallbackUrl = new URL(oauthConfig.redirect_uri);\n normalizedCallbackUrl.pathname = callbackUrl.pathname;\n normalizedCallbackUrl.search = callbackUrl.search;\n normalizedCallbackUrl.hash = '';\n\n const tokens = await openid.authorizationCodeGrant(\n config,\n normalizedCallbackUrl,\n {\n pkceCodeVerifier: codeVerifier,\n expectedState,\n },\n { redirect_uri: oauthConfig.redirect_uri },\n );\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Authorization code exchange successful',\n );\n\n return tokens;\n}\n\n/**\n * Result of extracting user information from an OIDC token response.\n */\nexport interface OidcUserInfo {\n sub: string;\n name: string;\n email: string;\n roles: string[];\n}\n\n/**\n * Extracts user information from an OIDC token response.\n * Decodes the ID token and extracts standard claims plus roles from a configurable claim.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns Extracted user information\n */\nexport function extractUserFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): OidcUserInfo {\n if (!tokens.id_token) {\n throw new Error('OIDC token response is missing id_token');\n }\n\n const idToken = decodeJwt(tokens.id_token);\n\n // Normalize roles: some providers send a single string instead of an array\n const rawRoles = idToken[rolesClaim];\n const nonArrayRoles = typeof rawRoles === 'string' ? [rawRoles] : [];\n const roles = Array.isArray(rawRoles) ? rawRoles : nonArrayRoles;\n\n logger.debug({ sub: idToken.sub, rolesClaim, rolesCount: roles.length }, 'Extracted user from ID token');\n\n return {\n sub: idToken.sub ?? '',\n name: idToken.name as string,\n email: idToken.email as string,\n roles,\n };\n}\n\n/**\n * Builds a Session object from an OIDC token response.\n * Maps provider tokens and user claims into the internal Session format.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns A Session object ready to be encrypted and stored\n */\nexport function buildSessionFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): Session {\n if (!tokens.access_token) {\n throw new Error('OIDC token response is missing access_token');\n }\n\n const userInfo = extractUserFromTokens(tokens, rolesClaim);\n\n let scopes: string[] | undefined;\n if (typeof tokens.scope === 'string') {\n const parsed = tokens.scope.split(' ').filter(s => s.length > 0);\n if (parsed.length > 0) scopes = parsed;\n }\n\n return {\n tokens: {\n accessToken: tokens.access_token,\n accessTokenExpiresAt: tokens.expires_in\n ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: tokens.refresh_token,\n // Kept for `id_token_hint` on RP-initiated logout.\n idToken: tokens.id_token,\n },\n user: {\n name: userInfo.name,\n email: userInfo.email,\n roles: userInfo.roles,\n },\n scopes,\n };\n}\n\n/**\n * Validates a return URL against open redirect attacks.\n * Ensures the URL is same-origin and passes optional custom validation.\n *\n * @param returnTo - The raw returnTo path/URL to validate\n * @param applicationOrigin - The application's origin URL (e.g., 'https://example.com')\n * @param defaultPath - Fallback path if validation fails (default: '/')\n * @param validate - Optional custom validation function for the pathname\n * @returns A safe, validated URL\n */\nexport function validateReturnUrl(\n returnTo: string | null | undefined,\n applicationOrigin: string,\n defaultPath: string = '/',\n validate?: (pathname: string) => boolean,\n): URL {\n const redirectPath = returnTo ?? defaultPath;\n let redirectUrl: URL;\n\n try {\n redirectUrl = new URL(redirectPath, applicationOrigin);\n } catch {\n logger.warn({ returnTo: redirectPath }, 'Invalid returnTo URL, using default');\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Enforce same-origin — cross-origin returnTo is a potential open redirect attack\n const originUrl = new URL(applicationOrigin);\n if (redirectUrl.origin !== originUrl.origin) {\n logger.error(\n { returnTo: redirectPath, redirectOrigin: redirectUrl.origin, applicationOrigin: originUrl.origin },\n 'Possible open redirect attack: returnTo points outside application origin',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Custom validation\n if (validate && !validate(redirectUrl.pathname)) {\n logger.warn(\n { pathname: redirectUrl.pathname },\n 'returnTo failed custom validation, falling back to default',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n return redirectUrl;\n}\n\n/** Parameters for an RP-initiated logout request (OIDC RP-Initiated Logout 1.0 §2). */\nexport interface OidcLogoutOptions {\n /** Post-logout redirect URI. Must be registered with the provider. */\n postLogoutRedirectUri?: string;\n\n /**\n * Raw ID token from `session.tokens.idToken`, sent as `id_token_hint`. Tells the\n * OP which session to end, and is usually what lets it skip the confirmation\n * interstitial.\n */\n idTokenHint?: string;\n\n /** Opaque value echoed back on the post-logout redirect. */\n state?: string;\n\n /** `logout_hint`, when the provider documents one. */\n logoutHint?: string;\n\n /**\n * Whether to send `client_id`. Default true, also alongside `id_token_hint`: the\n * spec allows both (the OP must then verify they match), and `client_id` is often\n * what lets the OP accept `post_logout_redirect_uri` when the hint is stale or\n * absent. Set false only for a provider that documents rejecting the combination.\n */\n includeClientId?: boolean;\n}\n\n/**\n * Discovers the OIDC provider's end_session_endpoint and builds an RP-initiated\n * logout URL. Returns null when the provider exposes none, so callers can fall\n * back to clearing the local session only.\n *\n * @param oauthConfig - OAuth configuration (issuer, clientId, clientSecret)\n * @param options - Logout parameters, or a string as shorthand for\n * `postLogoutRedirectUri`\n * @returns Logout URL or null if not supported\n *\n * @example\n * ```typescript\n * const session = await readSession(cookies, secret);\n * const logoutUrl = await buildOidcLogoutUrl(oauthConfig, {\n * postLogoutRedirectUri: 'https://app.example.com/',\n * idTokenHint: session?.tokens?.idToken,\n * });\n * ```\n */\nexport async function buildOidcLogoutUrl(\n oauthConfig: OAuthConfig,\n options: OidcLogoutOptions | string,\n): Promise<URL | null> {\n const {\n postLogoutRedirectUri,\n idTokenHint,\n state,\n logoutHint,\n includeClientId = true,\n } = typeof options === 'string' ? { postLogoutRedirectUri: options } : options;\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n const endSessionEndpoint = config.serverMetadata().end_session_endpoint;\n\n if (!endSessionEndpoint) {\n logger.info({ issuer: oauthConfig.issuer }, 'No end_session_endpoint found');\n return null;\n }\n\n const parameters: Record<string, string> = {};\n if (postLogoutRedirectUri) parameters.post_logout_redirect_uri = postLogoutRedirectUri;\n if (idTokenHint) parameters.id_token_hint = idTokenHint;\n if (state) parameters.state = state;\n if (logoutHint) parameters.logout_hint = logoutHint;\n\n // buildEndSessionUrl always adds client_id, and deleting it afterwards would\n // also drop one the provider baked into its own endpoint — so when it isn't\n // wanted, assemble the URL directly instead.\n let logoutUrl: URL;\n if (includeClientId) {\n logoutUrl = openid.buildEndSessionUrl(config, parameters);\n } else {\n logoutUrl = new URL(endSessionEndpoint);\n\n // openid-client rejects a non-https endpoint on the path above; the manual\n // one must too. The URL carries a raw id_token_hint, so a discovery document\n // that downgrades the scheme would put it on the wire in the clear.\n if (logoutUrl.protocol !== 'https:') {\n logger.error(\n { issuer: oauthConfig.issuer, protocol: logoutUrl.protocol },\n 'end_session_endpoint is not https — refusing to build a logout URL',\n );\n return null;\n }\n\n for (const [key, value] of Object.entries(parameters)) {\n logoutUrl.searchParams.append(key, value);\n }\n }\n\n logger.debug(\n { endSessionEndpoint, hasIdTokenHint: !!idTokenHint, hasState: !!state },\n 'Built OIDC logout URL',\n );\n return logoutUrl;\n } catch (error) {\n logger.warn({ error, issuer: oauthConfig.issuer }, 'OIDC discovery failed for logout');\n return null;\n }\n}\n\n/**\n * Performs OAuth 2.0 client credentials grant flow for machine-to-machine authentication.\n *\n * Uses client_secret_post against the given token endpoint. The endpoint must be\n * https — the request carries the client secret.\n *\n * @param config - Client credentials configuration including token endpoint, client ID, and secret\n * @returns Token response from the OAuth provider\n * @throws Error if the endpoint is not https, the request times out, or the grant fails\n *\n * @example\n * ```typescript\n * const tokens = await clientCredentialsGrant({\n * tokenEndpoint: 'https://api.example.com/oauth2/token',\n * clientId: 'my-client-id',\n * clientSecret: 'my-client-secret',\n * scope: 'api:read api:write',\n * });\n * ```\n */\nexport async function clientCredentialsGrant(\n config: ClientCredentialsConfig,\n): Promise<openid.TokenEndpointResponse> {\n try {\n // Build the configuration from metadata rather than discovering it: callers\n // give us a token endpoint, not an issuer. Going through openid-client anyway\n // buys the https guard and the timeout, which a raw fetch of a caller-supplied\n // URL carrying a client secret would not have.\n const openidConfig = new openid.Configuration(\n {\n issuer: config.issuer ?? new URL(config.tokenEndpoint).origin,\n token_endpoint: config.tokenEndpoint,\n },\n config.clientId,\n config.clientSecret,\n // Explicit rather than relying on openid-client's default, matching the\n // discovery calls above: the JSDoc promises client_secret_post.\n openid.ClientSecretPost(config.clientSecret),\n );\n openidConfig.timeout = config.timeout ?? 30;\n\n const tokens = await openid.clientCredentialsGrant(\n openidConfig,\n config.scope ? { scope: config.scope } : undefined,\n );\n\n logger.debug({ tokenEndpoint: config.tokenEndpoint }, 'Client credentials grant successful');\n return tokens;\n } catch (error) {\n logger.error(\n { ...getOAuthErrorLogContext(error), tokenEndpoint: config.tokenEndpoint },\n 'Client credentials grant failed',\n );\n throw error;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["import { decodeJwt } from 'jose';\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { newSessionId } from './session-events';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:oauth' });\n\n// Re-export commonly used types from openid-client for convenience\nexport type { Configuration, TokenEndpointResponse } from 'openid-client';\n\n/** Default OIDC scope requesting user identity, profile, email, and offline access. */\nexport const defaultScope = 'openid profile email offline_access';\n\n/** Configuration for server-side OAuth / OIDC flows. */\nexport type OAuthConfig = {\n issuer: string;\n clientId: string;\n clientSecret: string;\n redirect_uri: string;\n scope: string;\n /**\n * Opt in to Pushed Authorization Requests (RFC 9126) when the provider\n * supports it. When true, authorization parameters are posted to the\n * provider's PAR endpoint ahead of the redirect, and only `client_id` +\n * `request_uri` travel through the user-agent. The provider must advertise\n * `pushed_authorization_request_endpoint` in its discovery metadata.\n */\n usePar?: boolean;\n};\n\n/**\n * Refreshes an access token using a refresh token grant.\n *\n * @param oAuthConfig - OAuth configuration for the identity provider\n * @param refreshToken - The refresh token to exchange\n * @returns Token endpoint response containing new access and refresh tokens\n */\nexport async function refreshAccessToken(\n oAuthConfig: OAuthConfig,\n refreshToken: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug({ issuer: oAuthConfig.issuer }, 'Starting token refresh');\n\n try {\n const config = await openid.discovery(\n new URL(oAuthConfig.issuer),\n oAuthConfig.clientId,\n oAuthConfig.clientSecret,\n openid.ClientSecretPost(oAuthConfig.clientSecret),\n );\n\n const tokens = await openid.refreshTokenGrant(\n config,\n refreshToken,\n {\n scope: oAuthConfig.scope,\n },\n );\n\n logger.info({\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in\n }, 'Token refresh successful');\n\n return tokens;\n } catch (error) {\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Most often the refresh token has expired - log at info level\n logger.info(\n { ...errorContext, issuer: oAuthConfig.issuer },\n 'Token refresh failed - refresh token expired or invalid',\n );\n } else {\n // Unexpected error - log at error level\n logger.error({ error, issuer: oAuthConfig.issuer }, 'Token refresh failed');\n }\n\n throw error;\n }\n}\n\n/** PKCE parameters generated for an authorization request. */\nexport interface PKCEOptions {\n code_verifier: string;\n code_challenge: string;\n state: string;\n parameters: Record<string, string>;\n}\n\n/**\n * Creates PKCE parameters for an OAuth flow.\n *\n * @param config - OAuthConfig configuration including the redirect URI and scope.\n * @returns A promise that resolves to an object containing:\n * - code_verifier: The randomly generated PKCE code verifier.\n * - code_challenge: The derived PKCE code challenge.\n * - state: A random state to protect against CSRF.\n * - parameters: A Record<string,string> with the assembled parameters.\n */\nexport async function buildPKCEOptions(config: OAuthConfig): Promise<PKCEOptions> {\n const code_verifier = openid.randomPKCECodeVerifier();\n const code_challenge = await openid.calculatePKCECodeChallenge(code_verifier);\n const state = openid.randomState();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope ?? defaultScope,\n code_challenge,\n code_challenge_method: 'S256',\n state,\n };\n\n logger.info(\n { redirectUri: config.redirect_uri, scope: parameters.scope },\n 'PKCE parameters generated'\n );\n\n return { code_verifier, code_challenge, state, parameters };\n}\n\n/**\n * Builds an authorization URL with PKCE parameters.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function buildAuthorizationUrl(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = openid.buildAuthorizationUrl(config, pkceOptions.parameters);\n logger.info('Authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build authorization URL');\n throw error;\n }\n}\n\n/**\n * Builds an authorization URL using Pushed Authorization Requests (RFC 9126).\n *\n * Posts the PKCE parameters to the provider's PAR endpoint and returns a URL\n * containing only `client_id` and `request_uri`. The provider's discovery\n * metadata must include `pushed_authorization_request_endpoint`.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the PAR-based authorization URL\n */\nexport async function buildAuthorizationUrlWithPAR(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = await openid.buildAuthorizationUrlWithPAR(\n config,\n pkceOptions.parameters,\n );\n logger.info('PAR authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build PAR authorization URL');\n throw error;\n }\n}\n\n/**\n * Convenience function to discover OpenID configuration and build an\n * authorization URL. Routes through PAR when `oauthConfig.usePar` is true and\n * the provider advertises `pushed_authorization_request_endpoint`.\n *\n * - `usePar === true` + provider advertises PAR → uses PAR.\n * - `usePar === true` + provider does **not** advertise PAR → throws.\n * - `usePar` unset/false + provider advertises PAR → standard flow + one-line\n * `info` advisory that PAR is available but not enabled.\n * - `usePar` unset/false + no PAR endpoint → standard flow, no advisory.\n *\n * @param oauthConfig - Your OAuth configuration\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function discoverAndBuildAuthorizationUrl(\n oauthConfig: OAuthConfig,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n logger.debug({ issuer: oauthConfig.issuer }, 'Discovering OpenID configuration');\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret)\n );\n\n const parEndpoint = config.serverMetadata().pushed_authorization_request_endpoint;\n\n if (oauthConfig.usePar) {\n if (!parEndpoint) {\n throw new Error(\n 'PAR requested (usePar=true) but provider does not advertise pushed_authorization_request_endpoint',\n );\n }\n return buildAuthorizationUrlWithPAR(config, pkceOptions);\n }\n\n if (parEndpoint) {\n logger.info(\n { issuer: oauthConfig.issuer },\n 'Provider advertises PAR but usePar is not enabled — set OAuthConfig.usePar=true to use it',\n );\n }\n\n return buildAuthorizationUrl(config, pkceOptions);\n } catch (error) {\n logger.error({ error, issuer: oauthConfig.issuer }, 'Failed to discover config or build URL');\n throw error;\n }\n}\n\n/**\n * Configuration for OAuth client credentials flow (machine-to-machine authentication)\n */\nexport type ClientCredentialsConfig = {\n /** OAuth token endpoint URL. Must be https. */\n tokenEndpoint: string;\n /** OAuth client ID */\n clientId: string;\n /** OAuth client secret */\n clientSecret: string;\n /** Optional scope for the access token */\n scope?: string;\n /**\n * Issuer identifier. Only used to satisfy the server metadata; defaults to the\n * token endpoint's origin, which is right for the common case.\n */\n issuer?: string;\n /**\n * Request timeout in seconds.\n * @default 30\n */\n timeout?: number;\n};\n\n/**\n * Exchanges an authorization code for tokens using OIDC discovery and PKCE.\n *\n * @param oauthConfig - OAuth configuration\n * @param callbackUrl - The full callback URL with query parameters from the OIDC provider\n * @param codeVerifier - The PKCE code verifier stored during login initiation\n * @param expectedState - The state parameter stored during login initiation\n * @returns Token response from the OIDC provider\n */\nexport async function exchangeAuthorizationCode(\n oauthConfig: OAuthConfig,\n callbackUrl: URL,\n codeVerifier: string,\n expectedState: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug(\n { issuer: oauthConfig.issuer, clientId: oauthConfig.clientId },\n 'Exchanging authorization code for tokens',\n );\n\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n\n // openid-client overrides the explicit redirect_uri option with\n // stripParams(callbackUrl). Anchor to oauthConfig.redirect_uri so PAR and\n // token-exchange stay consistent behind reverse proxies that don't forward\n // the original scheme. Build from the trusted base and then copy pathname/\n // search by assignment — `new URL(callbackUrl.pathname, base)` would let a\n // `//evil.com/...` pathname swap the host.\n const normalizedCallbackUrl = new URL(oauthConfig.redirect_uri);\n normalizedCallbackUrl.pathname = callbackUrl.pathname;\n normalizedCallbackUrl.search = callbackUrl.search;\n normalizedCallbackUrl.hash = '';\n\n const tokens = await openid.authorizationCodeGrant(\n config,\n normalizedCallbackUrl,\n {\n pkceCodeVerifier: codeVerifier,\n expectedState,\n },\n { redirect_uri: oauthConfig.redirect_uri },\n );\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Authorization code exchange successful',\n );\n\n return tokens;\n}\n\n/**\n * Result of extracting user information from an OIDC token response.\n */\nexport interface OidcUserInfo {\n sub: string;\n name: string;\n email: string;\n roles: string[];\n}\n\n/**\n * Extracts user information from an OIDC token response.\n * Decodes the ID token and extracts standard claims plus roles from a configurable claim.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns Extracted user information\n */\nexport function extractUserFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): OidcUserInfo {\n if (!tokens.id_token) {\n throw new Error('OIDC token response is missing id_token');\n }\n\n const idToken = decodeJwt(tokens.id_token);\n\n // Normalize roles: some providers send a single string instead of an array\n const rawRoles = idToken[rolesClaim];\n const nonArrayRoles = typeof rawRoles === 'string' ? [rawRoles] : [];\n const roles = Array.isArray(rawRoles) ? rawRoles : nonArrayRoles;\n\n logger.debug({ sub: idToken.sub, rolesClaim, rolesCount: roles.length }, 'Extracted user from ID token');\n\n return {\n sub: idToken.sub ?? '',\n name: idToken.name as string,\n email: idToken.email as string,\n roles,\n };\n}\n\n/**\n * Builds a Session object from an OIDC token response.\n * Maps provider tokens and user claims into the internal Session format.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns A Session object ready to be encrypted and stored\n */\nexport function buildSessionFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): Session {\n if (!tokens.access_token) {\n throw new Error('OIDC token response is missing access_token');\n }\n\n const userInfo = extractUserFromTokens(tokens, rolesClaim);\n\n let scopes: string[] | undefined;\n if (typeof tokens.scope === 'string') {\n const parsed = tokens.scope.split(' ').filter(s => s.length > 0);\n if (parsed.length > 0) scopes = parsed;\n }\n\n return {\n // Minted once here and carried across refreshes, so every log line for this\n // session shares one correlation id.\n sid: newSessionId(),\n tokens: {\n accessToken: tokens.access_token,\n accessTokenExpiresAt: tokens.expires_in\n ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: tokens.refresh_token,\n // Kept for `id_token_hint` on RP-initiated logout.\n idToken: tokens.id_token,\n },\n user: {\n name: userInfo.name,\n email: userInfo.email,\n roles: userInfo.roles,\n },\n scopes,\n };\n}\n\n/**\n * Validates a return URL against open redirect attacks.\n * Ensures the URL is same-origin and passes optional custom validation.\n *\n * @param returnTo - The raw returnTo path/URL to validate\n * @param applicationOrigin - The application's origin URL (e.g., 'https://example.com')\n * @param defaultPath - Fallback path if validation fails (default: '/')\n * @param validate - Optional custom validation function for the pathname\n * @returns A safe, validated URL\n */\nexport function validateReturnUrl(\n returnTo: string | null | undefined,\n applicationOrigin: string,\n defaultPath: string = '/',\n validate?: (pathname: string) => boolean,\n): URL {\n const redirectPath = returnTo ?? defaultPath;\n let redirectUrl: URL;\n\n try {\n redirectUrl = new URL(redirectPath, applicationOrigin);\n } catch {\n logger.warn({ returnTo: redirectPath }, 'Invalid returnTo URL, using default');\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Enforce same-origin — cross-origin returnTo is a potential open redirect attack\n const originUrl = new URL(applicationOrigin);\n if (redirectUrl.origin !== originUrl.origin) {\n logger.error(\n { returnTo: redirectPath, redirectOrigin: redirectUrl.origin, applicationOrigin: originUrl.origin },\n 'Possible open redirect attack: returnTo points outside application origin',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Custom validation\n if (validate && !validate(redirectUrl.pathname)) {\n logger.warn(\n { pathname: redirectUrl.pathname },\n 'returnTo failed custom validation, falling back to default',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n return redirectUrl;\n}\n\n/** Parameters for an RP-initiated logout request (OIDC RP-Initiated Logout 1.0 §2). */\nexport interface OidcLogoutOptions {\n /** Post-logout redirect URI. Must be registered with the provider. */\n postLogoutRedirectUri?: string;\n\n /**\n * Raw ID token from `session.tokens.idToken`, sent as `id_token_hint`. Tells the\n * OP which session to end, and is usually what lets it skip the confirmation\n * interstitial.\n */\n idTokenHint?: string;\n\n /** Opaque value echoed back on the post-logout redirect. */\n state?: string;\n\n /** `logout_hint`, when the provider documents one. */\n logoutHint?: string;\n\n /**\n * Whether to send `client_id`. Default true, also alongside `id_token_hint`: the\n * spec allows both (the OP must then verify they match), and `client_id` is often\n * what lets the OP accept `post_logout_redirect_uri` when the hint is stale or\n * absent. Set false only for a provider that documents rejecting the combination.\n */\n includeClientId?: boolean;\n}\n\n/**\n * Discovers the OIDC provider's end_session_endpoint and builds an RP-initiated\n * logout URL. Returns null when the provider exposes none, so callers can fall\n * back to clearing the local session only.\n *\n * @param oauthConfig - OAuth configuration (issuer, clientId, clientSecret)\n * @param options - Logout parameters, or a string as shorthand for\n * `postLogoutRedirectUri`\n * @returns Logout URL or null if not supported\n *\n * @example\n * ```typescript\n * const session = await readSession(cookies, secret);\n * const logoutUrl = await buildOidcLogoutUrl(oauthConfig, {\n * postLogoutRedirectUri: 'https://app.example.com/',\n * idTokenHint: session?.tokens?.idToken,\n * });\n * ```\n */\nexport async function buildOidcLogoutUrl(\n oauthConfig: OAuthConfig,\n options: OidcLogoutOptions | string,\n): Promise<URL | null> {\n const {\n postLogoutRedirectUri,\n idTokenHint,\n state,\n logoutHint,\n includeClientId = true,\n } = typeof options === 'string' ? { postLogoutRedirectUri: options } : options;\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n const endSessionEndpoint = config.serverMetadata().end_session_endpoint;\n\n if (!endSessionEndpoint) {\n logger.info({ issuer: oauthConfig.issuer }, 'No end_session_endpoint found');\n return null;\n }\n\n const parameters: Record<string, string> = {};\n if (postLogoutRedirectUri) parameters.post_logout_redirect_uri = postLogoutRedirectUri;\n if (idTokenHint) parameters.id_token_hint = idTokenHint;\n if (state) parameters.state = state;\n if (logoutHint) parameters.logout_hint = logoutHint;\n\n // buildEndSessionUrl always adds client_id, and deleting it afterwards would\n // also drop one the provider baked into its own endpoint — so when it isn't\n // wanted, assemble the URL directly instead.\n let logoutUrl: URL;\n if (includeClientId) {\n logoutUrl = openid.buildEndSessionUrl(config, parameters);\n } else {\n logoutUrl = new URL(endSessionEndpoint);\n\n // openid-client rejects a non-https endpoint on the path above; the manual\n // one must too. The URL carries a raw id_token_hint, so a discovery document\n // that downgrades the scheme would put it on the wire in the clear.\n if (logoutUrl.protocol !== 'https:') {\n logger.error(\n { issuer: oauthConfig.issuer, protocol: logoutUrl.protocol },\n 'end_session_endpoint is not https — refusing to build a logout URL',\n );\n return null;\n }\n\n for (const [key, value] of Object.entries(parameters)) {\n logoutUrl.searchParams.append(key, value);\n }\n }\n\n logger.debug(\n { endSessionEndpoint, hasIdTokenHint: !!idTokenHint, hasState: !!state },\n 'Built OIDC logout URL',\n );\n return logoutUrl;\n } catch (error) {\n logger.warn({ error, issuer: oauthConfig.issuer }, 'OIDC discovery failed for logout');\n return null;\n }\n}\n\n/**\n * Performs OAuth 2.0 client credentials grant flow for machine-to-machine authentication.\n *\n * Uses client_secret_post against the given token endpoint. The endpoint must be\n * https — the request carries the client secret.\n *\n * @param config - Client credentials configuration including token endpoint, client ID, and secret\n * @returns Token response from the OAuth provider\n * @throws Error if the endpoint is not https, the request times out, or the grant fails\n *\n * @example\n * ```typescript\n * const tokens = await clientCredentialsGrant({\n * tokenEndpoint: 'https://api.example.com/oauth2/token',\n * clientId: 'my-client-id',\n * clientSecret: 'my-client-secret',\n * scope: 'api:read api:write',\n * });\n * ```\n */\nexport async function clientCredentialsGrant(\n config: ClientCredentialsConfig,\n): Promise<openid.TokenEndpointResponse> {\n try {\n // Build the configuration from metadata rather than discovering it: callers\n // give us a token endpoint, not an issuer. Going through openid-client anyway\n // buys the https guard and the timeout, which a raw fetch of a caller-supplied\n // URL carrying a client secret would not have.\n const openidConfig = new openid.Configuration(\n {\n issuer: config.issuer ?? new URL(config.tokenEndpoint).origin,\n token_endpoint: config.tokenEndpoint,\n },\n config.clientId,\n config.clientSecret,\n // Explicit rather than relying on openid-client's default, matching the\n // discovery calls above: the JSDoc promises client_secret_post.\n openid.ClientSecretPost(config.clientSecret),\n );\n openidConfig.timeout = config.timeout ?? 30;\n\n const tokens = await openid.clientCredentialsGrant(\n openidConfig,\n config.scope ? { scope: config.scope } : undefined,\n );\n\n logger.debug({ tokenEndpoint: config.tokenEndpoint }, 'Client credentials grant successful');\n return tokens;\n } catch (error) {\n logger.error(\n { ...getOAuthErrorLogContext(error), tokenEndpoint: config.tokenEndpoint },\n 'Client credentials grant failed',\n );\n throw error;\n }\n}\n"],"mappings":";;;;;;AASA,IAAM,SAAS,aAAa,EAAE,WAAW,mBAAmB,CAAC;;AAM7D,IAAa,eAAe;;;;;;;;AA0B5B,eAAsB,mBACpB,aACA,cACuC;CACvC,OAAO,MAAM,EAAE,QAAQ,YAAY,OAAO,GAAG,wBAAwB;CAErE,IAAI;EACF,MAAM,SAAS,MAAM,OAAO,UAC1B,IAAI,IAAI,YAAY,MAAM,GAC1B,YAAY,UACZ,YAAY,cACZ,OAAO,iBAAiB,YAAY,YAAY,CAClD;EAEA,MAAM,SAAS,MAAM,OAAO,kBAC1B,QACA,cACA,EACE,OAAO,YAAY,MACrB,CACF;EAEA,OAAO,KAAK;GACV,gBAAgB,CAAC,CAAC,OAAO;GACzB,iBAAiB,CAAC,CAAC,OAAO;GAC1B,WAAW,OAAO;EACpB,GAAG,0BAA0B;EAE7B,OAAO;CACT,SAAS,OAAO;EACd,MAAM,eAAe,wBAAwB,KAAK;EAIlD,IAFE,aAAa,SAAS,+BAA+B,aAAa,UAAU,iBAI5E,OAAO,KACL;GAAE,GAAG;GAAc,QAAQ,YAAY;EAAO,GAC9C,yDACF;OAGA,OAAO,MAAM;GAAE;GAAO,QAAQ,YAAY;EAAO,GAAG,sBAAsB;EAG5E,MAAM;CACR;AACF;;;;;;;;;;;AAoBA,eAAsB,iBAAiB,QAA2C;CAChF,MAAM,gBAAgB,OAAO,uBAAuB;CACpD,MAAM,iBAAiB,MAAM,OAAO,2BAA2B,aAAa;CAC5E,MAAM,QAAQ,OAAO,YAAY;CAEjC,MAAM,aAAqC;EACzC,cAAc,OAAO;EACrB,OAAO,OAAO,SAAA;EACd;EACA,uBAAuB;EACvB;CACF;CAEA,OAAO,KACL;EAAE,aAAa,OAAO;EAAc,OAAO,WAAW;CAAM,GAC5D,2BACF;CAEA,OAAO;EAAE;EAAe;EAAgB;EAAO;CAAW;AAC5D;;;;;;;;AASA,eAAsB,sBACpB,QACA,aACc;CACd,IAAI;EACF,MAAM,UAAU,OAAO,sBAAsB,QAAQ,YAAY,UAAU;EAC3E,OAAO,KAAK,sCAAsC;EAClD,OAAO;CACT,SAAS,OAAO;EACd,OAAO,MAAM,EAAE,MAAM,GAAG,mCAAmC;EAC3D,MAAM;CACR;AACF;;;;;;;;;;;;AAaA,eAAsB,6BACpB,QACA,aACc;CACd,IAAI;EACF,MAAM,UAAU,MAAM,OAAO,6BAC3B,QACA,YAAY,UACd;EACA,OAAO,KAAK,0CAA0C;EACtD,OAAO;CACT,SAAS,OAAO;EACd,OAAO,MAAM,EAAE,MAAM,GAAG,uCAAuC;EAC/D,MAAM;CACR;AACF;;;;;;;;;;;;;;;;AAiBA,eAAsB,iCACpB,aACA,aACc;CACd,OAAO,MAAM,EAAE,QAAQ,YAAY,OAAO,GAAG,kCAAkC;CAE/E,IAAI;EACF,MAAM,SAAS,MAAM,OAAO,UAC1B,IAAI,IAAI,YAAY,MAAM,GAC1B,YAAY,UACZ,YAAY,cACZ,OAAO,iBAAiB,YAAY,YAAY,CAClD;EAEA,MAAM,cAAc,OAAO,eAAe,CAAC,CAAC;EAE5C,IAAI,YAAY,QAAQ;GACtB,IAAI,CAAC,aACH,MAAM,IAAI,MACR,mGACF;GAEF,OAAO,6BAA6B,QAAQ,WAAW;EACzD;EAEA,IAAI,aACF,OAAO,KACL,EAAE,QAAQ,YAAY,OAAO,GAC7B,2FACF;EAGF,OAAO,sBAAsB,QAAQ,WAAW;CAClD,SAAS,OAAO;EACd,OAAO,MAAM;GAAE;GAAO,QAAQ,YAAY;EAAO,GAAG,wCAAwC;EAC5F,MAAM;CACR;AACF;;;;;;;;;;AAmCA,eAAsB,0BACpB,aACA,aACA,cACA,eACuC;CACvC,OAAO,MACL;EAAE,QAAQ,YAAY;EAAQ,UAAU,YAAY;CAAS,GAC7D,0CACF;CAEA,MAAM,SAAS,MAAM,OAAO,UAC1B,IAAI,IAAI,YAAY,MAAM,GAC1B,YAAY,UACZ,YAAY,cACZ,OAAO,iBAAiB,YAAY,YAAY,CAClD;CAQA,MAAM,wBAAwB,IAAI,IAAI,YAAY,YAAY;CAC9D,sBAAsB,WAAW,YAAY;CAC7C,sBAAsB,SAAS,YAAY;CAC3C,sBAAsB,OAAO;CAE7B,MAAM,SAAS,MAAM,OAAO,uBAC1B,QACA,uBACA;EACE,kBAAkB;EAClB;CACF,GACA,EAAE,cAAc,YAAY,aAAa,CAC3C;CAEA,OAAO,KACL;EACE,gBAAgB,CAAC,CAAC,OAAO;EACzB,iBAAiB,CAAC,CAAC,OAAO;EAC1B,WAAW,OAAO;CACpB,GACA,wCACF;CAEA,OAAO;AACT;;;;;;;;;AAoBA,SAAgB,sBACd,QACA,aAAqB,SACP;CACd,IAAI,CAAC,OAAO,UACV,MAAM,IAAI,MAAM,yCAAyC;CAG3D,MAAM,UAAU,UAAU,OAAO,QAAQ;CAGzC,MAAM,WAAW,QAAQ;CACzB,MAAM,gBAAgB,OAAO,aAAa,WAAW,CAAC,QAAQ,IAAI,CAAC;CACnE,MAAM,QAAQ,MAAM,QAAQ,QAAQ,IAAI,WAAW;CAEnD,OAAO,MAAM;EAAE,KAAK,QAAQ;EAAK;EAAY,YAAY,MAAM;CAAO,GAAG,8BAA8B;CAEvG,OAAO;EACL,KAAK,QAAQ,OAAO;EACpB,MAAM,QAAQ;EACd,OAAO,QAAQ;EACf;CACF;AACF;;;;;;;;;AAUA,SAAgB,uBACd,QACA,aAAqB,SACZ;CACT,IAAI,CAAC,OAAO,cACV,MAAM,IAAI,MAAM,6CAA6C;CAG/D,MAAM,WAAW,sBAAsB,QAAQ,UAAU;CAEzD,IAAI;CACJ,IAAI,OAAO,OAAO,UAAU,UAAU;EACpC,MAAM,SAAS,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,QAAO,MAAK,EAAE,SAAS,CAAC;EAC/D,IAAI,OAAO,SAAS,GAAG,SAAS;CAClC;CAEA,OAAO;EAGL,KAAK,aAAa;EAClB,QAAQ;GACN,aAAa,OAAO;GACpB,sBAAsB,OAAO,aACzB,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI,CAAC,CAAC,YAAY,IAC5D,KAAA;GACJ,cAAc,OAAO;GAErB,SAAS,OAAO;EAClB;EACA,MAAM;GACJ,MAAM,SAAS;GACf,OAAO,SAAS;GAChB,OAAO,SAAS;EAClB;EACA;CACF;AACF;;;;;;;;;;;AAYA,SAAgB,kBACd,UACA,mBACA,cAAsB,KACtB,UACK;CACL,MAAM,eAAe,YAAY;CACjC,IAAI;CAEJ,IAAI;EACF,cAAc,IAAI,IAAI,cAAc,iBAAiB;CACvD,QAAQ;EACN,OAAO,KAAK,EAAE,UAAU,aAAa,GAAG,qCAAqC;EAC7E,OAAO,IAAI,IAAI,aAAa,iBAAiB;CAC/C;CAGA,MAAM,YAAY,IAAI,IAAI,iBAAiB;CAC3C,IAAI,YAAY,WAAW,UAAU,QAAQ;EAC3C,OAAO,MACL;GAAE,UAAU;GAAc,gBAAgB,YAAY;GAAQ,mBAAmB,UAAU;EAAO,GAClG,2EACF;EACA,OAAO,IAAI,IAAI,aAAa,iBAAiB;CAC/C;CAGA,IAAI,YAAY,CAAC,SAAS,YAAY,QAAQ,GAAG;EAC/C,OAAO,KACL,EAAE,UAAU,YAAY,SAAS,GACjC,4DACF;EACA,OAAO,IAAI,IAAI,aAAa,iBAAiB;CAC/C;CAEA,OAAO;AACT;;;;;;;;;;;;;;;;;;;;AAgDA,eAAsB,mBACpB,aACA,SACqB;CACrB,MAAM,EACJ,uBACA,aACA,OACA,YACA,kBAAkB,SAChB,OAAO,YAAY,WAAW,EAAE,uBAAuB,QAAQ,IAAI;CAEvE,IAAI;EACF,MAAM,SAAS,MAAM,OAAO,UAC1B,IAAI,IAAI,YAAY,MAAM,GAC1B,YAAY,UACZ,YAAY,cACZ,OAAO,iBAAiB,YAAY,YAAY,CAClD;EACA,MAAM,qBAAqB,OAAO,eAAe,CAAC,CAAC;EAEnD,IAAI,CAAC,oBAAoB;GACvB,OAAO,KAAK,EAAE,QAAQ,YAAY,OAAO,GAAG,+BAA+B;GAC3E,OAAO;EACT;EAEA,MAAM,aAAqC,CAAC;EAC5C,IAAI,uBAAuB,WAAW,2BAA2B;EACjE,IAAI,aAAa,WAAW,gBAAgB;EAC5C,IAAI,OAAO,WAAW,QAAQ;EAC9B,IAAI,YAAY,WAAW,cAAc;EAKzC,IAAI;EACJ,IAAI,iBACF,YAAY,OAAO,mBAAmB,QAAQ,UAAU;OACnD;GACL,YAAY,IAAI,IAAI,kBAAkB;GAKtC,IAAI,UAAU,aAAa,UAAU;IACnC,OAAO,MACL;KAAE,QAAQ,YAAY;KAAQ,UAAU,UAAU;IAAS,GAC3D,oEACF;IACA,OAAO;GACT;GAEA,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,UAAU,GAClD,UAAU,aAAa,OAAO,KAAK,KAAK;EAE5C;EAEA,OAAO,MACL;GAAE;GAAoB,gBAAgB,CAAC,CAAC;GAAa,UAAU,CAAC,CAAC;EAAM,GACvE,uBACF;EACA,OAAO;CACT,SAAS,OAAO;EACd,OAAO,KAAK;GAAE;GAAO,QAAQ,YAAY;EAAO,GAAG,kCAAkC;EACrF,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;;;;;AAsBA,eAAsB,uBACpB,QACuC;CACvC,IAAI;EAKF,MAAM,eAAe,IAAI,OAAO,cAC9B;GACE,QAAQ,OAAO,UAAU,IAAI,IAAI,OAAO,aAAa,CAAC,CAAC;GACvD,gBAAgB,OAAO;EACzB,GACA,OAAO,UACP,OAAO,cAGP,OAAO,iBAAiB,OAAO,YAAY,CAC7C;EACA,aAAa,UAAU,OAAO,WAAW;EAEzC,MAAM,SAAS,MAAM,OAAO,uBAC1B,cACA,OAAO,QAAQ,EAAE,OAAO,OAAO,MAAM,IAAI,KAAA,CAC3C;EAEA,OAAO,MAAM,EAAE,eAAe,OAAO,cAAc,GAAG,qCAAqC;EAC3F,OAAO;CACT,SAAS,OAAO;EACd,OAAO,MACL;GAAE,GAAG,wBAAwB,KAAK;GAAG,eAAe,OAAO;EAAc,GACzE,iCACF;EACA,MAAM;CACR;AACF"}
@@ -13,9 +13,16 @@ export interface HeartbeatHandlerConfig {
13
13
  /**
14
14
  * Handles a heartbeat request — refresh the active session if there is one.
15
15
  *
16
- * Returns 200 with `{ accessTokenExpiresAt }` on success, 401 when there is no
17
- * session or the refresh token is dead, 405 for non-POST methods, and 429 when
18
- * rate-limited.
16
+ * Returns 200 with `{ accessTokenExpiresAt }` on success, 401 with `{ reason }`
17
+ * when the session is genuinely over, 503 when the provider could not be
18
+ * reached, 405 for non-POST methods, and 429 when rate-limited.
19
+ *
20
+ * The 401/503 split matters: a 401 tells the client to log the user out, so it
21
+ * must be reserved for the cases where the session really is finished. A network
22
+ * blip or a provider 5xx says nothing about the session's validity — answering
23
+ * 401 there logs out users whose sessions were fine, which is precisely the
24
+ * "logged out every few minutes" symptom. The client already retries non-401
25
+ * failures with backoff.
19
26
  */
20
27
  export declare function handleHeartbeat(request: Request, config: HeartbeatHandlerConfig): Promise<Response>;
21
28
  //# sourceMappingURL=heartbeat-handler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-handler.d.ts","sourceRoot":"","sources":["../../src/server/heartbeat-handler.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAKlD,MAAM,WAAW,sBAAsB;IACrC,iEAAiE;IACjE,WAAW,EAAE,WAAW,CAAC;IAEzB,wCAAwC;IACxC,OAAO,EAAE,WAAW,CAAC;IAErB,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE7C,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,sBAAsB,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAwCnB"}
1
+ {"version":3,"file":"heartbeat-handler.d.ts","sourceRoot":"","sources":["../../src/server/heartbeat-handler.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAKlD,MAAM,WAAW,sBAAsB;IACrC,iEAAiE;IACjE,WAAW,EAAE,WAAW,CAAC;IAEzB,wCAAwC;IACxC,OAAO,EAAE,WAAW,CAAC;IAErB,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE7C,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;CAC9B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,sBAAsB,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAkDnB"}
@@ -1,5 +1,5 @@
1
1
  export type { CookieStore } from './cookie-store';
2
- export { persistSession, readSession, refreshSessionInStore, readIdToken, clearSession, } from './session';
2
+ export { persistSession, readSession, tryReadSession, refreshSessionInStore, tryRefreshSessionInStore, readIdToken, clearSession, type ClearSessionOptions, type PersistSessionOptions, type ReadSessionResult, type RefreshSessionResult, } from './session';
3
3
  export { handleOidcLogin, type OidcLoginConfig } from './oidc-login';
4
4
  export { handleOidcCallback, type OidcCallbackConfig } from './oidc-callback';
5
5
  export { handleOidcLogout, type OidcLogoutConfig } from './oidc-logout';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EACL,cAAc,EACd,WAAW,EACX,qBAAqB,EACrB,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAC9E,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EACL,cAAc,EACd,WAAW,EACX,cAAc,EACd,qBAAqB,EACrB,wBAAwB,EACxB,WAAW,EACX,YAAY,EACZ,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,GAC1B,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAC9E,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,qBAAqB,CAAC"}