@eventuras/fides-auth 0.14.1 → 0.15.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.
Files changed (94) hide show
  1. package/README.md +225 -33
  2. package/dist/activity-tracker.js +1 -2
  3. package/dist/cookies.d.ts +6 -0
  4. package/dist/cookies.d.ts.map +1 -1
  5. package/dist/cookies.js +8 -3
  6. package/dist/cookies.js.map +1 -1
  7. package/dist/heartbeat.js +1 -2
  8. package/dist/id-token.d.ts +31 -0
  9. package/dist/id-token.d.ts.map +1 -0
  10. package/dist/id-token.js +60 -0
  11. package/dist/id-token.js.map +1 -0
  12. package/dist/index.js +3 -3
  13. package/dist/logger.js +1 -2
  14. package/dist/oauth-browser.d.ts +13 -10
  15. package/dist/oauth-browser.d.ts.map +1 -1
  16. package/dist/oauth-browser.js +4 -2
  17. package/dist/oauth-browser.js.map +1 -1
  18. package/dist/oauth-config.d.ts +70 -0
  19. package/dist/oauth-config.d.ts.map +1 -0
  20. package/dist/oauth-logging.d.ts +4 -3
  21. package/dist/oauth-logging.d.ts.map +1 -1
  22. package/dist/oauth-logging.js +5 -5
  23. package/dist/oauth-logging.js.map +1 -1
  24. package/dist/oauth.d.ts +47 -45
  25. package/dist/oauth.d.ts.map +1 -1
  26. package/dist/oauth.js +58 -29
  27. package/dist/oauth.js.map +1 -1
  28. package/dist/oidc-discovery.d.ts +13 -4
  29. package/dist/oidc-discovery.d.ts.map +1 -1
  30. package/dist/oidc-discovery.js +82 -15
  31. package/dist/oidc-discovery.js.map +1 -1
  32. package/dist/pkce.d.ts +10 -0
  33. package/dist/pkce.d.ts.map +1 -0
  34. package/dist/pkce.js +23 -0
  35. package/dist/pkce.js.map +1 -0
  36. package/dist/providers/vipps/client.d.ts +3 -2
  37. package/dist/providers/vipps/client.d.ts.map +1 -1
  38. package/dist/providers/vipps/index.js +10 -10
  39. package/dist/providers/vipps/index.js.map +1 -1
  40. package/dist/providers/vipps/types.d.ts +2 -2
  41. package/dist/providers/vipps/types.d.ts.map +1 -1
  42. package/dist/rate-limit.js +1 -2
  43. package/dist/server/authorized-fetch.d.ts +78 -0
  44. package/dist/server/authorized-fetch.d.ts.map +1 -0
  45. package/dist/server/heartbeat-handler.d.ts +6 -4
  46. package/dist/server/heartbeat-handler.d.ts.map +1 -1
  47. package/dist/server/index.d.ts +4 -0
  48. package/dist/server/index.d.ts.map +1 -1
  49. package/dist/server/index.js +744 -94
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/login-transaction.d.ts +55 -0
  52. package/dist/server/login-transaction.d.ts.map +1 -0
  53. package/dist/server/oidc-callback.d.ts +13 -4
  54. package/dist/server/oidc-callback.d.ts.map +1 -1
  55. package/dist/server/oidc-login.d.ts +14 -5
  56. package/dist/server/oidc-login.d.ts.map +1 -1
  57. package/dist/server/oidc-logout.d.ts +6 -4
  58. package/dist/server/oidc-logout.d.ts.map +1 -1
  59. package/dist/server/session-persistence.d.ts +102 -0
  60. package/dist/server/session-persistence.d.ts.map +1 -0
  61. package/dist/server/session-store.d.ts +39 -0
  62. package/dist/server/session-store.d.ts.map +1 -0
  63. package/dist/server/session.d.ts +35 -28
  64. package/dist/server/session.d.ts.map +1 -1
  65. package/dist/session-cookies.d.ts +1 -0
  66. package/dist/session-cookies.d.ts.map +1 -1
  67. package/dist/session-cookies.js +4 -3
  68. package/dist/session-cookies.js.map +1 -1
  69. package/dist/session-events.d.ts +10 -3
  70. package/dist/session-events.d.ts.map +1 -1
  71. package/dist/session-events.js +4 -3
  72. package/dist/session-events.js.map +1 -1
  73. package/dist/session-refresh.d.ts +1 -1
  74. package/dist/session-refresh.d.ts.map +1 -1
  75. package/dist/session-refresh.js +1 -2
  76. package/dist/session-refresh.js.map +1 -1
  77. package/dist/session-validation.d.ts.map +1 -1
  78. package/dist/session-validation.js +9 -6
  79. package/dist/session-validation.js.map +1 -1
  80. package/dist/silent-login.d.ts +1 -1
  81. package/dist/silent-login.d.ts.map +1 -1
  82. package/dist/silent-login.js +3 -3
  83. package/dist/silent-login.js.map +1 -1
  84. package/dist/types.d.ts +4 -1
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/utils.d.ts +0 -14
  87. package/dist/utils.d.ts.map +1 -1
  88. package/dist/utils.js +2 -24
  89. package/dist/utils.js.map +1 -1
  90. package/docs/discovery-cache.md +7 -3
  91. package/docs/rp-initiated-logout.md +6 -3
  92. package/docs/server-sessions.md +154 -0
  93. package/docs/session-events.md +17 -16
  94. package/package.json +14 -6
@@ -1,22 +1,258 @@
1
- import { ACCESS_TOKEN_COOKIE_NAME, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, assertCookieWithinLimit, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "../cookies.js";
1
+ import { ACCESS_TOKEN_COOKIE_NAME, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_REF_COOKIE_NAME, assertCookieWithinLimit, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "../cookies.js";
2
2
  import { createLogger } from "../logger.js";
3
3
  import { classifyRefreshFailure, getOAuthErrorLogContext } from "../oauth-logging.js";
4
4
  import { SESSION_EVENT, logSessionEvent } from "../session-events.js";
5
5
  import { buildOidcLogoutUrl, buildPKCEOptions, buildSessionFromTokens, discoverAndBuildAuthorizationUrl, exchangeAuthorizationCode, validateReturnUrl } from "../oauth.js";
6
6
  import { refreshSession } from "../session-refresh.js";
7
- import { getSessionSecret } from "../utils.js";
7
+ import { createEncryptedJWT, decryptJWT, generateToken, hexToUint8Array, sha256 } from "../utils.js";
8
+ import { isSession } from "../session-validation.js";
8
9
  import { decodeIdTokenCookie, encodeSessionCookies, tryDecodeSessionCookies } from "../session-cookies.js";
9
- //#region src/server/session.ts
10
- var logger$4 = createLogger({ namespace: "fides-auth:server:session" });
10
+ // src/server/session-store.ts
11
+ /**
12
+ * In-memory store for tests and single-instance development. Sessions do not
13
+ * survive a restart, and expired records are dropped on read.
14
+ */
15
+ function createMemorySessionStore() {
16
+ const records = /* @__PURE__ */ new Map();
17
+ const locks = /* @__PURE__ */ new Map();
18
+ return {
19
+ get(key) {
20
+ const record = records.get(key);
21
+ if (!record) return null;
22
+ if (!(Date.parse(record.expiresAt) > Date.now())) {
23
+ records.delete(key);
24
+ return null;
25
+ }
26
+ return record;
27
+ },
28
+ set(key, record) {
29
+ records.set(key, record);
30
+ },
31
+ delete(key) {
32
+ records.delete(key);
33
+ },
34
+ withLock(key, fn) {
35
+ const run = (locks.get(key) ?? Promise.resolve()).then(fn);
36
+ const settled = run.then(() => void 0, () => void 0);
37
+ locks.set(key, settled);
38
+ settled.then(() => {
39
+ if (locks.get(key) === settled) locks.delete(key);
40
+ });
41
+ return run;
42
+ }
43
+ };
44
+ }
45
+ // src/server/session-persistence.ts
46
+ var logger$7 = createLogger({ namespace: "fides-auth:server:session-persistence" });
47
+ /** A missing or malformed session secret: a configuration error, never a bad cookie. */
48
+ var SessionSecretError = class extends TypeError {
49
+ name = "SessionSecretError";
50
+ };
51
+ /** Throws unless `secret` is the 32 bytes A256GCM needs. Never echoes the secret. */
52
+ function assertSessionSecret(secret) {
53
+ if (!secret || secret.length === 0) throw new SessionSecretError("fides-auth: session secret is missing or empty");
54
+ if (!(secret instanceof Uint8Array ? secret.length === 32 : typeof secret === "string" && /^[0-9a-fA-F]{64}$/.test(secret))) throw new SessionSecretError("fides-auth: session secret must be 32 bytes (64 hex characters or a Uint8Array)");
55
+ }
56
+ /** Accepts the pre-0.15 bare secret wherever a persistence is expected. */
57
+ function resolvePersistence(value) {
58
+ return typeof value === "object" && value !== null && !(value instanceof Uint8Array) ? value : cookiePersistence(value);
59
+ }
60
+ /** The configured `persistence`, else the bare `secret`. */
61
+ function persistenceFrom(config) {
62
+ if (config.secret === void 0) return config.persistence;
63
+ return config.persistence ?? config.secret;
64
+ }
65
+ var SPLIT_COOKIE_NAMES = [
66
+ SESSION_COOKIE_NAME,
67
+ ACCESS_TOKEN_COOKIE_NAME,
68
+ ID_TOKEN_COOKIE_NAME
69
+ ];
70
+ /** Deletes the `session`, `session_at` and `session_it` cookies. */
71
+ async function clearSessionCookies(cookies) {
72
+ for (const name of SPLIT_COOKIE_NAMES) await cookies.delete(name);
73
+ }
11
74
  /** Applies the browser size guard to one cookie value; logs as it nears the limit. */
12
75
  function checkSize(name, value) {
13
76
  const size = assertCookieWithinLimit(name, value);
14
- if (size >= 3500) logger$4.info({
77
+ if (size >= 3500) logger$7.info({
15
78
  cookieName: name,
16
79
  size
17
80
  }, "Cookie approaching browser size limit");
18
81
  return size;
19
82
  }
83
+ /**
84
+ * The default: the session encrypted into the `session`, `session_at` and
85
+ * `session_it` cookies.
86
+ */
87
+ function cookiePersistence(secret) {
88
+ return {
89
+ mode: "cookie",
90
+ async write(cookies, session) {
91
+ assertSessionSecret(secret);
92
+ const encoded = await encodeSessionCookies(session, secret);
93
+ const values = [
94
+ [SESSION_COOKIE_NAME, encoded.session],
95
+ [ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],
96
+ [ID_TOKEN_COOKIE_NAME, encoded.idToken]
97
+ ];
98
+ let cookieBytes = 0;
99
+ for (const [name, value] of values) if (value) cookieBytes += checkSize(name, value);
100
+ for (const [name, value] of values) if (value) await cookies.set(name, value, defaultSessionCookieOptions);
101
+ else await cookies.delete(name);
102
+ return {
103
+ cookieValue: encoded.session,
104
+ cookieBytes
105
+ };
106
+ },
107
+ async read(cookies) {
108
+ assertSessionSecret(secret);
109
+ return tryDecodeSessionCookies({
110
+ session: await cookies.get("session") ?? null,
111
+ accessToken: await cookies.get("session_at") ?? null,
112
+ idToken: await cookies.get("session_it") ?? null
113
+ }, secret);
114
+ },
115
+ async readIdToken(cookies) {
116
+ assertSessionSecret(secret);
117
+ const raw = await cookies.get(ID_TOKEN_COOKIE_NAME);
118
+ return raw ? decodeIdTokenCookie(raw, secret) : void 0;
119
+ },
120
+ async clear(cookies) {
121
+ assertSessionSecret(secret);
122
+ await clearSessionCookies(cookies);
123
+ }
124
+ };
125
+ }
126
+ /**
127
+ * The session in a {@link SessionStore}; the browser holds only a random
128
+ * 256-bit handle. The store is keyed by `SHA-256(handle)`, so neither a store
129
+ * dump nor a logged key can be replayed as a cookie, and the record is
130
+ * encrypted under `secret`, so a dump alone yields no tokens.
131
+ */
132
+ function serverPersistence(options) {
133
+ const { store, secret, cookieName = SESSION_REF_COOKIE_NAME, sessionDurationDays = 30 } = options;
134
+ const maxAge = sessionDurationDays * 24 * 60 * 60;
135
+ const inflight = /* @__PURE__ */ new Map();
136
+ /** The handle the request carries and the store key it maps to. */
137
+ async function locate(cookies) {
138
+ const handle = await cookies.get(cookieName);
139
+ return handle ? {
140
+ handle,
141
+ key: await sha256(handle)
142
+ } : null;
143
+ }
144
+ /** Drops split cookies left behind by cookie persistence, when there are any. */
145
+ async function clearSplitCookies(cookies) {
146
+ for (const name of SPLIT_COOKIE_NAMES) if (await cookies.get(name)) await cookies.delete(name);
147
+ }
148
+ async function readRecord(key) {
149
+ const record = await store.get(key);
150
+ if (!record) return {
151
+ session: null,
152
+ reason: "session_not_found"
153
+ };
154
+ if (!(Date.parse(record.expiresAt) > Date.now())) {
155
+ await store.delete(key);
156
+ return {
157
+ session: null,
158
+ reason: "session_not_found"
159
+ };
160
+ }
161
+ let payload;
162
+ try {
163
+ payload = await decryptJWT(record.data, secret);
164
+ } catch (error) {
165
+ logger$7.warn({
166
+ error,
167
+ sid: record.sid
168
+ }, "Stored session would not decrypt");
169
+ return {
170
+ session: null,
171
+ reason: "unreadable_session"
172
+ };
173
+ }
174
+ if (!isSession(payload)) {
175
+ logger$7.warn({ sid: record.sid }, "Stored session has the wrong shape");
176
+ return {
177
+ session: null,
178
+ reason: "unreadable_session"
179
+ };
180
+ }
181
+ return { session: payload };
182
+ }
183
+ return {
184
+ mode: "server",
185
+ async write(cookies, session, { continuation = false } = {}) {
186
+ assertSessionSecret(secret);
187
+ if (!isSession(session)) throw new TypeError("A session needs a tokens or user object");
188
+ const current = await locate(cookies);
189
+ await clearSplitCookies(cookies);
190
+ const data = await createEncryptedJWT(session, secret);
191
+ if (continuation && current) {
192
+ const existing = await store.get(current.key);
193
+ if (existing) await store.set(current.key, {
194
+ data,
195
+ expiresAt: existing.expiresAt,
196
+ sid: session.sid
197
+ });
198
+ else logger$7.info({ sid: session.sid }, "Session ended during refresh; not persisted");
199
+ return { cookieValue: current.handle };
200
+ }
201
+ if (current) await store.delete(current.key);
202
+ const handle = generateToken(32);
203
+ const key = await sha256(handle);
204
+ const expiresAt = new Date(Date.now() + maxAge * 1e3).toISOString();
205
+ await store.set(key, {
206
+ data,
207
+ expiresAt,
208
+ sid: session.sid
209
+ });
210
+ await cookies.set(cookieName, handle, {
211
+ ...defaultSessionCookieOptions,
212
+ maxAge
213
+ });
214
+ return { cookieValue: handle };
215
+ },
216
+ async read(cookies) {
217
+ assertSessionSecret(secret);
218
+ const current = await locate(cookies);
219
+ if (!current) return {
220
+ session: null,
221
+ reason: await cookies.get("session") ? "stale_legacy_session" : "no_session_cookie"
222
+ };
223
+ return readRecord(current.key);
224
+ },
225
+ async readIdToken(cookies) {
226
+ assertSessionSecret(secret);
227
+ const current = await locate(cookies);
228
+ if (!current) return void 0;
229
+ return (await readRecord(current.key)).session?.tokens?.idToken;
230
+ },
231
+ async clear(cookies) {
232
+ assertSessionSecret(secret);
233
+ const current = await locate(cookies);
234
+ if (current) {
235
+ await store.delete(current.key);
236
+ await cookies.delete(cookieName);
237
+ }
238
+ await clearSplitCookies(cookies);
239
+ },
240
+ async exclusive(cookies, fn) {
241
+ assertSessionSecret(secret);
242
+ const current = await locate(cookies);
243
+ if (!current) return fn();
244
+ const running = inflight.get(current.key);
245
+ if (running) return running;
246
+ const run = (store.withLock ? store.withLock(current.key, fn) : fn()).finally(() => {
247
+ inflight.delete(current.key);
248
+ });
249
+ inflight.set(current.key, run);
250
+ return run;
251
+ }
252
+ };
253
+ }
254
+ // src/server/session.ts
255
+ var logger$6 = createLogger({ namespace: "fides-auth:server:session" });
20
256
  /** Seconds until the access token expires, for the `expiresIn` log field. */
21
257
  function expiresInSeconds(session) {
22
258
  const at = session.tokens?.accessTokenExpiresAt;
@@ -25,38 +261,36 @@ function expiresInSeconds(session) {
25
261
  return Number.isFinite(ms) ? Math.round((ms - Date.now()) / 1e3) : void 0;
26
262
  }
27
263
  /**
28
- * Encrypts a session and writes it across the "session", "session_at" and
29
- * "session_it" cookies, clearing the split-out cookies the session has no token for.
264
+ * Persists a session and raises its lifecycle event.
30
265
  *
31
- * @returns The encrypted JWT stored in the main "session" cookie.
266
+ * With a secret, or {@link cookiePersistence}, the session is encrypted across
267
+ * the "session", "session_at" and "session_it" cookies. With
268
+ * {@link serverPersistence} it goes to the store and the browser gets a handle.
269
+ *
270
+ * @returns The value written to the main session cookie: the encrypted session,
271
+ * or the handle. Either is a bearer secret.
32
272
  */
33
- async function persistSession(store, session, secret, options = {}) {
34
- const encoded = await encodeSessionCookies(session, secret);
35
- const values = [
36
- [SESSION_COOKIE_NAME, encoded.session],
37
- [ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],
38
- [ID_TOKEN_COOKIE_NAME, encoded.idToken]
39
- ];
40
- let cookieBytes = 0;
41
- for (const [name, value] of values) if (value) cookieBytes += checkSize(name, value);
42
- for (const [name, value] of values) if (value) await store.set(name, value, defaultSessionCookieOptions);
43
- else await store.delete(name);
44
- if (options.event === SESSION_EVENT.CREATED) logSessionEvent(logger$4, {
273
+ async function persistSession(store, session, persistence, options = {}) {
274
+ const target = resolvePersistence(persistence);
275
+ const written = await target.write(store, session, { continuation: options.event === SESSION_EVENT.REFRESHED });
276
+ if (options.event === SESSION_EVENT.CREATED) logSessionEvent(logger$6, {
45
277
  event: SESSION_EVENT.CREATED,
46
278
  sid: session.sid,
47
279
  hasRefreshToken: !!session.tokens?.refreshToken,
48
280
  scopes: session.scopes,
49
281
  expiresIn: expiresInSeconds(session),
50
- cookieBytes
282
+ cookieBytes: written.cookieBytes,
283
+ persistence: target.mode
51
284
  });
52
- else if (options.event === SESSION_EVENT.REFRESHED) logSessionEvent(logger$4, {
285
+ else if (options.event === SESSION_EVENT.REFRESHED) logSessionEvent(logger$6, {
53
286
  event: SESSION_EVENT.REFRESHED,
54
287
  sid: session.sid,
55
288
  expiresIn: expiresInSeconds(session),
56
289
  rotatedRefreshToken: options.rotatedRefreshToken ?? false,
57
- cookieBytes
290
+ cookieBytes: written.cookieBytes,
291
+ persistence: target.mode
58
292
  });
59
- return encoded.session;
293
+ return written.cookieValue;
60
294
  }
61
295
  /**
62
296
  * Reads and reassembles the current session, reporting why when there isn't one.
@@ -66,16 +300,13 @@ async function persistSession(store, session, secret, options = {}) {
66
300
  * endpoint logs. Does not log on its own — the caller owns the request context
67
301
  * and decides whether a missing session is worth a line.
68
302
  */
69
- async function tryReadSession(store, secret) {
303
+ async function tryReadSession(store, persistence) {
70
304
  try {
71
- return await tryDecodeSessionCookies({
72
- session: await store.get("session") ?? null,
73
- accessToken: await store.get("session_at") ?? null,
74
- idToken: await store.get("session_it") ?? null
75
- }, secret);
305
+ return await resolvePersistence(persistence).read(store);
76
306
  } catch (error) {
77
- if (error instanceof Error && error.message.includes("worker")) logger$4.error({ error }, "Worker thread error reading session");
78
- else logger$4.error({ error }, "Unexpected error reading session");
307
+ if (error instanceof SessionSecretError) throw error;
308
+ if (error instanceof Error && error.message.includes("worker")) logger$6.error({ error }, "Worker thread error reading session");
309
+ else logger$6.error({ error }, "Unexpected error reading session");
79
310
  return {
80
311
  session: null,
81
312
  reason: "unreadable_session"
@@ -83,14 +314,13 @@ async function tryReadSession(store, secret) {
83
314
  }
84
315
  }
85
316
  /**
86
- * Reads and reassembles the current session from the cookie store, or null when
87
- * there is none. Decode/validation rules live in `decodeSessionCookies`;
88
- * the returned session may carry an expired access token.
317
+ * Reads and reassembles the current session, or null when there is none. The
318
+ * returned session may carry an expired access token.
89
319
  *
90
320
  * Prefer {@link tryReadSession} when you want to log *why* there is no session.
91
321
  */
92
- async function readSession(store, secret) {
93
- return (await tryReadSession(store, secret)).session;
322
+ async function readSession(store, persistence) {
323
+ return (await tryReadSession(store, persistence)).session;
94
324
  }
95
325
  /**
96
326
  * Refreshes the stored session and persists the result, reporting why on failure.
@@ -100,14 +330,50 @@ async function readSession(store, secret) {
100
330
  * provider" into the same `null`, and a caller that logs the user out on both
101
331
  * ends sessions that were never actually invalid. Only `invalid_grant` is
102
332
  * terminal — see {@link classifyRefreshFailure}.
333
+ *
334
+ * Under server persistence, refreshes of one session are serialized: concurrent
335
+ * callers get the first refresh's result rather than each spending the refresh
336
+ * token, which with rotation would leave all but one holding a dead one.
103
337
  */
104
- async function tryRefreshSessionInStore(store, config, secret, options = {}) {
105
- const read = await tryReadSession(store, secret);
338
+ async function tryRefreshSessionInStore(store, config, persistence, options = {}) {
339
+ const target = resolvePersistence(persistence);
340
+ const read = await tryReadSession(store, target);
106
341
  const current = read.session;
107
342
  if (!current) return {
108
343
  ok: false,
109
344
  reason: read.reason
110
345
  };
346
+ if (!current.tokens?.refreshToken) return {
347
+ ok: false,
348
+ reason: "no_refresh_token",
349
+ sid: current.sid
350
+ };
351
+ if (!target.exclusive) return refreshNow(store, target, config, current, options);
352
+ return target.exclusive(store, async () => {
353
+ const latest = await tryReadSession(store, target);
354
+ if (!latest.session) return {
355
+ ok: false,
356
+ reason: latest.reason,
357
+ sid: current.sid
358
+ };
359
+ if (refreshedSince(current, latest.session)) return alreadyRefreshed(current, latest.session);
360
+ return refreshNow(store, target, config, latest.session, options);
361
+ });
362
+ }
363
+ /** Whether `after` carries tokens `before` did not — someone refreshed in between. */
364
+ function refreshedSince(before, after) {
365
+ return after.tokens?.accessToken !== before.tokens?.accessToken || after.tokens?.refreshToken !== before.tokens?.refreshToken;
366
+ }
367
+ function alreadyRefreshed(before, after) {
368
+ logger$6.debug({ sid: after.sid }, "Session already refreshed by a concurrent request");
369
+ return {
370
+ ok: true,
371
+ session: after,
372
+ rotatedRefreshToken: after.tokens?.refreshToken !== before.tokens?.refreshToken
373
+ };
374
+ }
375
+ /** The refresh itself: exchange, persist, classify. */
376
+ async function refreshNow(store, target, config, current, options) {
111
377
  if (!current.tokens?.refreshToken) return {
112
378
  ok: false,
113
379
  reason: "no_refresh_token",
@@ -116,7 +382,7 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
116
382
  try {
117
383
  const updated = await refreshSession(current, config, options);
118
384
  if (!updated) {
119
- logSessionEvent(logger$4, {
385
+ logSessionEvent(logger$6, {
120
386
  event: SESSION_EVENT.REFRESH_FAILED,
121
387
  sid: current.sid,
122
388
  cause: "idp_error",
@@ -130,7 +396,7 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
130
396
  };
131
397
  }
132
398
  const rotatedRefreshToken = updated.tokens?.refreshToken !== current.tokens.refreshToken;
133
- await persistSession(store, updated, secret, {
399
+ await persistSession(store, updated, target, {
134
400
  event: SESSION_EVENT.REFRESHED,
135
401
  rotatedRefreshToken
136
402
  });
@@ -141,7 +407,11 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
141
407
  };
142
408
  } catch (error) {
143
409
  const cause = classifyRefreshFailure(error);
144
- logSessionEvent(logger$4, {
410
+ if (cause === "invalid_grant" && target.exclusive) {
411
+ const latest = await tryReadSession(store, target);
412
+ if (latest.session && refreshedSince(current, latest.session)) return alreadyRefreshed(current, latest.session);
413
+ }
414
+ logSessionEvent(logger$6, {
145
415
  event: SESSION_EVENT.REFRESH_FAILED,
146
416
  sid: current.sid,
147
417
  cause,
@@ -166,94 +436,445 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
166
436
  * refresh token apart from an unreachable provider, and treating the two alike
167
437
  * logs out users whose sessions are still valid.
168
438
  */
169
- async function refreshSessionInStore(store, config, secret, options = {}) {
170
- const result = await tryRefreshSessionInStore(store, config, secret, options);
439
+ async function refreshSessionInStore(store, config, persistence, options = {}) {
440
+ const result = await tryRefreshSessionInStore(store, config, persistence, options);
171
441
  return result.ok ? result.session : null;
172
442
  }
173
443
  /**
174
- * Reads the raw ID token independently of the session cookies — logout needs
175
- * the hint even when {@link readSession} returns null.
444
+ * Reads the raw ID token independently of session validity — logout needs the
445
+ * hint even when {@link readSession} returns null.
176
446
  */
177
- async function readIdToken(store, secret) {
447
+ async function readIdToken(store, persistence) {
178
448
  try {
179
- const raw = await store.get(ID_TOKEN_COOKIE_NAME);
180
- return raw ? await decodeIdTokenCookie(raw, secret) : void 0;
449
+ return await resolvePersistence(persistence).readIdToken(store);
181
450
  } catch (error) {
182
- logger$4.error({ error }, "Unexpected error reading ID token");
451
+ if (error instanceof SessionSecretError) throw error;
452
+ logger$6.error({ error }, "Unexpected error reading ID token");
183
453
  return;
184
454
  }
185
455
  }
186
- /** Deletes all session cookies. */
456
+ /** Ends the session: deletes the cookies and, under server persistence, the record. */
187
457
  async function clearSession(store, options = {}) {
188
- await store.delete(SESSION_COOKIE_NAME);
189
- await store.delete(ACCESS_TOKEN_COOKIE_NAME);
190
- await store.delete(ID_TOKEN_COOKIE_NAME);
191
- if (options.trigger) logSessionEvent(logger$4, {
458
+ if (options.persistence) await resolvePersistence(options.persistence).clear(store);
459
+ else await clearSessionCookies(store);
460
+ if (options.trigger) logSessionEvent(logger$6, {
192
461
  event: SESSION_EVENT.CLEARED,
193
462
  sid: options.sid,
194
463
  trigger: options.trigger
195
464
  });
196
465
  }
197
- //#endregion
198
- //#region src/server/oidc-login.ts
466
+ // src/server/login-transaction.ts
467
+ var logger$5 = createLogger({ namespace: "fides-auth:server:login-transaction" });
468
+ var COOKIE_NAME = "oauth_tx_";
469
+ var HOST_PREFIX = "__Host-";
470
+ /** Live logins per browser. Another login evicts the oldest. */
471
+ var MAX_TRANSACTIONS = 3;
472
+ /**
473
+ * A longer returnTo is dropped, not stored in a cookie sent with every request.
474
+ * Measured as serialized: JSON escaping grows a control character sixfold.
475
+ */
476
+ var MAX_RETURN_TO_BYTES = 1024;
477
+ var DEFAULT_MAX_AGE_MS = 6e5;
478
+ var KEY_INFO = "fides-auth login-tx";
479
+ var CallbackValidationError = class extends Error {
480
+ reason;
481
+ idpError;
482
+ idpErrorDescription;
483
+ constructor(reason, idpError, idpErrorDescription) {
484
+ super(`OIDC callback rejected: ${reason}`);
485
+ this.reason = reason;
486
+ this.idpError = idpError;
487
+ this.idpErrorDescription = idpErrorDescription;
488
+ this.name = "CallbackValidationError";
489
+ }
490
+ };
491
+ /** Starts a login transaction from the PKCE parameters sent to the authorize endpoint. */
492
+ function createTransaction(pkce, { returnTo } = {}) {
493
+ let kept = returnTo || void 0;
494
+ if (kept && new TextEncoder().encode(JSON.stringify(kept)).length > MAX_RETURN_TO_BYTES) {
495
+ logger$5.warn({ length: kept.length }, "returnTo too long, dropped");
496
+ kept = void 0;
497
+ }
498
+ return {
499
+ state: pkce.state,
500
+ codeVerifier: pkce.code_verifier,
501
+ ...kept ? { returnTo: kept } : {},
502
+ createdAt: Date.now()
503
+ };
504
+ }
505
+ /**
506
+ * Seals the transaction into a random free `__Host-oauth_tx_<n>` slot, or the
507
+ * oldest login's. Throws `CookieTooLargeError` if it would not fit in a cookie.
508
+ */
509
+ async function persistTransaction(cookies, transaction, secret, { secure = true } = {}) {
510
+ const key = await transactionKey(secret);
511
+ const slots = await readSlots(cookies, key, secure);
512
+ const free = slots.filter((candidate) => !candidate.transaction);
513
+ const slot = free[Math.floor(Math.random() * free.length)] ?? slots.reduce((oldest, candidate) => (candidate.transaction?.createdAt ?? 0) < (oldest.transaction?.createdAt ?? 0) ? candidate : oldest);
514
+ const value = await createEncryptedJWT(transaction, key);
515
+ assertCookieWithinLimit(slot.name, value);
516
+ await cookies.set(slot.name, value, {
517
+ ...defaultOAuthCookieOptions,
518
+ secure
519
+ });
520
+ }
521
+ /** The transaction for `state`, or null when none is stored or it does not open under `secret`. */
522
+ async function readTransaction(cookies, state, secret, { secure = true } = {}) {
523
+ const slots = await readSlots(cookies, await transactionKey(secret), secure);
524
+ const match = findSlot(slots, state);
525
+ if (match) return match.transaction;
526
+ if (slots.some((slot) => slot.value && !slot.transaction)) logger$5.warn("Unreadable login transaction cookie");
527
+ return null;
528
+ }
529
+ /** Deletes the transaction cookie for `state`, leaving other logins alone. */
530
+ async function clearTransaction(cookies, state, secret, { secure = true } = {}) {
531
+ const match = findSlot(await readSlots(cookies, await transactionKey(secret), secure), state);
532
+ if (!match) return;
533
+ if (secure) await cookies.set(match.name, "", {
534
+ ...defaultOAuthCookieOptions,
535
+ secure,
536
+ maxAge: 0
537
+ });
538
+ else await cookies.delete(match.name);
539
+ }
540
+ /** What the handlers use: Secure and `__Host-` unless the callback is plain http. */
541
+ function transactionCookieOptions(redirectUri) {
542
+ return { secure: !/^http:/i.test(redirectUri) };
543
+ }
544
+ /**
545
+ * Checks the callback query against its transaction and returns the authorization
546
+ * code. Throws {@link CallbackValidationError}.
547
+ */
548
+ function validateCallback(query, transaction, { maxAgeMs = DEFAULT_MAX_AGE_MS } = {}) {
549
+ if (!timingSafeEqual(query.get("state") ?? "", transaction.state)) throw new CallbackValidationError("state_mismatch");
550
+ if (Date.now() - transaction.createdAt > maxAgeMs) throw new CallbackValidationError("transaction_expired");
551
+ const error = query.get("error");
552
+ if (error !== null) throw new CallbackValidationError("idp_error", error, query.get("error_description") ?? void 0);
553
+ const code = query.get("code");
554
+ if (!code) throw new CallbackValidationError("missing_code");
555
+ return code;
556
+ }
557
+ /** HKDF-SHA-256 of the secret, so a transaction and a session never share a key. */
558
+ async function transactionKey(secret) {
559
+ assertSessionSecret(secret);
560
+ const ikm = typeof secret === "string" ? hexToUint8Array(secret) : secret;
561
+ const base = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
562
+ const bits = await crypto.subtle.deriveBits({
563
+ name: "HKDF",
564
+ hash: "SHA-256",
565
+ salt: /* @__PURE__ */ new Uint8Array(),
566
+ info: new TextEncoder().encode(KEY_INFO)
567
+ }, base, 256);
568
+ return new Uint8Array(bits);
569
+ }
570
+ async function readSlots(cookies, key, secure) {
571
+ const prefix = secure ? HOST_PREFIX : "";
572
+ const names = Array.from({ length: MAX_TRANSACTIONS }, (_, i) => `${prefix}${COOKIE_NAME}${i}`);
573
+ return Promise.all(names.map(async (name) => {
574
+ const value = await cookies.get(name);
575
+ return {
576
+ name,
577
+ value,
578
+ transaction: value ? await open(value, key) : null
579
+ };
580
+ }));
581
+ }
582
+ function findSlot(slots, state) {
583
+ for (const { name, transaction } of slots) if (transaction && timingSafeEqual(state, transaction.state)) return {
584
+ name,
585
+ transaction
586
+ };
587
+ }
588
+ /** The sealed transaction, or null when the value is forged, malformed or under another key. */
589
+ async function open(value, key) {
590
+ try {
591
+ const sealed = await decryptJWT(value, key);
592
+ if (typeof sealed.state === "string" && typeof sealed.codeVerifier === "string" && typeof sealed.createdAt === "number" && (sealed.returnTo === void 0 || typeof sealed.returnTo === "string")) return {
593
+ state: sealed.state,
594
+ codeVerifier: sealed.codeVerifier,
595
+ returnTo: sealed.returnTo,
596
+ createdAt: sealed.createdAt
597
+ };
598
+ } catch {}
599
+ return null;
600
+ }
601
+ /** Compares in time that depends only on the expected value's length. */
602
+ function timingSafeEqual(received, expected) {
603
+ const encoder = new TextEncoder();
604
+ const a = encoder.encode(received);
605
+ const b = encoder.encode(expected);
606
+ let diff = a.length ^ b.length;
607
+ for (let i = 0; i < b.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
608
+ return diff === 0;
609
+ }
610
+ // src/server/authorized-fetch.ts
611
+ var logger$4 = createLogger({ namespace: "fides-auth:server:authorized-fetch" });
612
+ var DEFAULT_TIMEOUT_MS = 1e4;
613
+ /** A failed {@link authorizedFetch}. Never carries the token or the query string. */
614
+ var AuthorizedFetchError = class extends Error {
615
+ reason;
616
+ /** HTTP status, for `http_error` and `refresh_failed`. */
617
+ status;
618
+ method;
619
+ /** Origin and path only. */
620
+ url;
621
+ /** Why the refresh failed. Only `cause: 'invalid_grant'` means the user must log in again. */
622
+ refreshFailure;
623
+ constructor(reason, details = {}) {
624
+ const { status, method, url, refreshFailure, response, cause } = details;
625
+ const target = url ? ` for ${method ?? "GET"} ${url}` : "";
626
+ super(`authorizedFetch ${reason}${status ? ` (${status})` : ""}${target}`, cause === void 0 ? void 0 : { cause });
627
+ this.name = "AuthorizedFetchError";
628
+ this.reason = reason;
629
+ this.status = status;
630
+ this.method = method;
631
+ this.url = url;
632
+ this.refreshFailure = refreshFailure;
633
+ Object.defineProperty(this, "response", {
634
+ value: response,
635
+ enumerable: false
636
+ });
637
+ }
638
+ };
639
+ /**
640
+ * Calls an API with the session's access token.
641
+ *
642
+ * @throws {AuthorizedFetchError} on a disallowed URL, non-2xx, timeout, network error or failed refresh.
643
+ * @throws {TypeError} when `allowedBase` is not an http(s) URL without credentials.
644
+ */
645
+ async function authorizedFetch(session, url, options) {
646
+ const { allowedBase, parse, timeoutMs = DEFAULT_TIMEOUT_MS, refresh, ...init } = options;
647
+ const target = resolveAllowedUrl(url, allowedBase);
648
+ const request = {
649
+ method: (init.method ?? "GET").toUpperCase(),
650
+ url: redactUrl(target)
651
+ };
652
+ let current = session;
653
+ let attempt = await send(current, target, init, timeoutMs, request);
654
+ let refreshed = false;
655
+ if (attempt.response.status === 401 && refresh) {
656
+ await attempt.response.body?.cancel().catch(() => void 0);
657
+ attempt.unlink();
658
+ logger$4.debug(request, "Got 401, refreshing the session once");
659
+ current = await refreshOnce(refresh, request);
660
+ refreshed = true;
661
+ attempt = await send(current, target, init, timeoutMs, request);
662
+ }
663
+ const { response, timeout, unlink } = attempt;
664
+ if (!response.ok) throw new AuthorizedFetchError("http_error", {
665
+ ...request,
666
+ status: response.status,
667
+ response
668
+ });
669
+ try {
670
+ const data = parse ? await parse(response) : void 0;
671
+ if (!response.body || response.bodyUsed) unlink();
672
+ return {
673
+ response,
674
+ data,
675
+ session: current,
676
+ refreshed
677
+ };
678
+ } catch (error) {
679
+ unlink();
680
+ if (init.signal?.aborted) throw error;
681
+ throw new AuthorizedFetchError(error === timeout.reason ? "timeout" : "parse_failed", {
682
+ ...request,
683
+ status: response.status,
684
+ cause: error
685
+ });
686
+ }
687
+ }
688
+ /** Resolves `url` against the base and throws unless it stays under it. */
689
+ function resolveAllowedUrl(url, allowedBase) {
690
+ const base = new URL(allowedBase);
691
+ if (base.protocol !== "https:" && base.protocol !== "http:") throw new TypeError("authorizedFetch: allowedBase must be an http(s) URL");
692
+ if (base.username || base.password) throw new TypeError("authorizedFetch: allowedBase must not carry credentials");
693
+ if (!base.pathname.endsWith("/")) base.pathname += "/";
694
+ let target;
695
+ try {
696
+ target = new URL(url, base);
697
+ } catch {
698
+ throw new AuthorizedFetchError("url_not_allowed");
699
+ }
700
+ const underBase = target.pathname.startsWith(base.pathname) || `${target.pathname}/` === base.pathname;
701
+ if (target.username || target.password || target.origin !== base.origin || !underBase) {
702
+ logger$4.warn({
703
+ url: redactUrl(target),
704
+ allowedBase: redactUrl(base)
705
+ }, "Refused to send the access token outside allowedBase");
706
+ throw new AuthorizedFetchError("url_not_allowed", { url: redactUrl(target) });
707
+ }
708
+ return target;
709
+ }
710
+ async function send(session, target, init, timeoutMs, request) {
711
+ const token = session.tokens?.accessToken;
712
+ if (!token) throw new AuthorizedFetchError("no_access_token", request);
713
+ const headers = new Headers(init.headers);
714
+ headers.set("Authorization", `Bearer ${token}`);
715
+ const timeout = AbortSignal.timeout(timeoutMs);
716
+ const { signal, unlink } = linkSignals(timeout, init.signal);
717
+ try {
718
+ return {
719
+ response: await fetch(target, {
720
+ ...init,
721
+ headers,
722
+ signal,
723
+ redirect: "error"
724
+ }),
725
+ timeout,
726
+ unlink
727
+ };
728
+ } catch (error) {
729
+ unlink();
730
+ if (init.signal?.aborted) throw error;
731
+ throw new AuthorizedFetchError(timeout.aborted ? "timeout" : "network_error", {
732
+ ...request,
733
+ cause: error
734
+ });
735
+ }
736
+ }
737
+ /**
738
+ * A signal that aborts when the timeout or the caller's signal does, with that signal's
739
+ * reason, and `unlink` to remove the listeners. `AbortSignal.any` would need Node 18.17.
740
+ */
741
+ function linkSignals(timeout, caller) {
742
+ if (!caller) return {
743
+ signal: timeout,
744
+ unlink: () => void 0
745
+ };
746
+ const sources = [caller, timeout];
747
+ const controller = new AbortController();
748
+ const unlink = () => {
749
+ for (const source of sources) source.removeEventListener("abort", onAbort);
750
+ };
751
+ const onAbort = () => {
752
+ unlink();
753
+ controller.abort(sources.find((source) => source.aborted)?.reason);
754
+ };
755
+ if (sources.some((source) => source.aborted)) onAbort();
756
+ else for (const source of sources) source.addEventListener("abort", onAbort);
757
+ return {
758
+ signal: controller.signal,
759
+ unlink
760
+ };
761
+ }
762
+ async function refreshOnce(refresh, request) {
763
+ let result;
764
+ try {
765
+ result = toRefreshResult(await refresh());
766
+ } catch (error) {
767
+ throw new AuthorizedFetchError("refresh_failed", {
768
+ ...request,
769
+ status: 401,
770
+ cause: error
771
+ });
772
+ }
773
+ if (!result.ok) {
774
+ const refreshFailure = {
775
+ reason: result.reason,
776
+ cause: result.cause
777
+ };
778
+ throw new AuthorizedFetchError("refresh_failed", {
779
+ ...request,
780
+ status: 401,
781
+ refreshFailure
782
+ });
783
+ }
784
+ return result.session;
785
+ }
786
+ /** Accepts both `tryRefreshSessionInStore` and `refreshSessionInStore` results. */
787
+ function toRefreshResult(value) {
788
+ if (!value) return {
789
+ ok: false,
790
+ reason: "refresh_failed"
791
+ };
792
+ if ("ok" in value) return value;
793
+ return {
794
+ ok: true,
795
+ session: value,
796
+ rotatedRefreshToken: false
797
+ };
798
+ }
799
+ /** Origin and path: never credentials, query or fragment. */
800
+ function redactUrl(url) {
801
+ return `${url.origin}${url.pathname}`;
802
+ }
803
+ // src/server/oidc-login.ts
199
804
  var logger$3 = createLogger({ namespace: "fides-auth:server:oidc-login" });
200
- var defaultValidateReturnTo = (raw) => /^\/(?!\/)/.test(raw) ? raw : null;
805
+ var defaultValidateReturnTo = (raw) => /^\/(?!\/)[\x20-\x7e]*$/.test(raw) ? raw : null;
201
806
  /**
202
- * Handles OIDC login initiation: rate-limit, build PKCE, persist it, and redirect
203
- * to the authorization endpoint.
807
+ * Handles OIDC login initiation: rate-limit, build PKCE, seal it into a login
808
+ * transaction, and redirect to the authorization endpoint.
204
809
  */
205
810
  async function handleOidcLogin(request, config) {
206
811
  const { oauthConfig, cookies, rateLimit, validateReturnTo = defaultValidateReturnTo } = config;
812
+ assertSessionSecret(config.secret);
207
813
  if (rateLimit && !await rateLimit()) {
208
814
  logger$3.warn("Rate limit exceeded");
209
815
  return new Response("Too many requests", { status: 429 });
210
816
  }
211
817
  const rawReturnTo = new URL(request.url).searchParams.get("returnTo");
212
818
  const returnTo = rawReturnTo ? validateReturnTo(rawReturnTo) : null;
213
- const pkce = await buildPKCEOptions(oauthConfig);
819
+ const parameters = await config.authorizationParameters?.(request);
820
+ const pkce = await buildPKCEOptions(oauthConfig, { parameters });
214
821
  const authorizationUrl = await discoverAndBuildAuthorizationUrl(oauthConfig, pkce);
822
+ const transaction = createTransaction(pkce, { returnTo });
823
+ const secret = config.secret;
824
+ const cookieOptions = transactionCookieOptions(oauthConfig.redirect_uri);
825
+ try {
826
+ await persistTransaction(cookies, transaction, secret, cookieOptions);
827
+ } catch (error) {
828
+ if (!(error instanceof CookieTooLargeError) || !transaction.returnTo) throw error;
829
+ logger$3.warn({ size: error.size }, "Login transaction too large; dropped returnTo");
830
+ await persistTransaction(cookies, {
831
+ ...transaction,
832
+ returnTo: void 0
833
+ }, secret, cookieOptions);
834
+ }
215
835
  logger$3.info("Redirecting to OIDC provider for login");
216
- await cookies.set("oauth_state", pkce.state, defaultOAuthCookieOptions);
217
- await cookies.set("oauth_code_verifier", pkce.code_verifier, defaultOAuthCookieOptions);
218
- if (returnTo) await cookies.set("returnTo", returnTo, defaultOAuthCookieOptions);
219
- else await cookies.delete("returnTo");
220
836
  return new Response(null, {
221
837
  status: 302,
222
838
  headers: { Location: authorizationUrl.toString() }
223
839
  });
224
840
  }
225
- //#endregion
226
- //#region src/server/oidc-callback.ts
841
+ // src/server/oidc-callback.ts
227
842
  var logger$2 = createLogger({ namespace: "fides-auth:server:oidc-callback" });
843
+ var IDP_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
228
844
  /**
229
845
  * Handles the OIDC callback: rate-limit, exchange the code for tokens, persist the
230
846
  * session across the cookie store, and redirect to a validated returnTo.
231
847
  */
232
848
  async function handleOidcCallback(request, config) {
233
- const { oauthConfig, applicationUrl, cookies, rateLimit, secret = getSessionSecret(), rolesClaim = "roles", defaultRedirectPath = "/", validateReturnTo } = config;
849
+ const { oauthConfig, applicationUrl, cookies, secret, rateLimit, rolesClaim = "roles", mapSession, defaultRedirectPath = "/", validateReturnTo } = config;
850
+ assertSessionSecret(secret);
851
+ const persistence = config.persistence ?? secret;
852
+ const txCookie = transactionCookieOptions(oauthConfig.redirect_uri);
234
853
  if (rateLimit && !await rateLimit()) {
235
854
  logger$2.warn("Rate limit exceeded");
236
855
  return new Response("Too many requests", { status: 429 });
237
856
  }
857
+ const currentUrl = new URL(request.url);
858
+ const state = currentUrl.searchParams.get("state");
859
+ const idpError = currentUrl.searchParams.get("error") ?? void 0;
860
+ const loginEnded = () => idpError === void 0 ? new Response("Please restart the login process.", { status: 400 }) : loginErrorRedirect(applicationUrl, defaultRedirectPath, "login_expired");
238
861
  try {
239
- const currentUrl = new URL(request.url);
240
862
  const publicUrl = new URL(applicationUrl);
241
863
  publicUrl.pathname = currentUrl.pathname;
242
864
  publicUrl.search = currentUrl.search;
243
- const storedState = await cookies.get("oauth_state");
244
- const storedCodeVerifier = await cookies.get("oauth_code_verifier");
245
- if (!storedState || !storedCodeVerifier) {
246
- logger$2.warn("Missing state or code verifier");
247
- return new Response("Please restart the login process.", { status: 400 });
865
+ const transaction = state ? await readTransaction(cookies, state, secret, txCookie) : null;
866
+ if (!transaction) {
867
+ logger$2.warn({ error: idpError }, "Missing login transaction");
868
+ return loginEnded();
248
869
  }
249
- const tokens = await exchangeAuthorizationCode(oauthConfig, publicUrl, storedCodeVerifier, storedState);
250
- const session = buildSessionFromTokens(tokens, rolesClaim);
251
- await persistSession(cookies, session, secret, { event: SESSION_EVENT.CREATED });
252
- const returnTo = await cookies.get("returnTo");
253
- await cookies.delete("oauth_state");
254
- await cookies.delete("oauth_code_verifier");
255
- await cookies.delete("returnTo");
256
- const redirectUrl = validateReturnUrl(returnTo ?? null, applicationUrl, defaultRedirectPath, validateReturnTo);
870
+ validateCallback(currentUrl.searchParams, transaction);
871
+ const tokens = await exchangeAuthorizationCode(oauthConfig, publicUrl, transaction.codeVerifier, transaction.state);
872
+ const session = buildSessionFromTokens(tokens, {
873
+ rolesClaim,
874
+ mapSession
875
+ });
876
+ await persistSession(cookies, session, persistence, { event: SESSION_EVENT.CREATED });
877
+ const redirectUrl = validateReturnUrl(transaction.returnTo ?? null, applicationUrl, defaultRedirectPath, validateReturnTo);
257
878
  redirectUrl.searchParams.set("login", "success");
258
879
  logger$2.info({
259
880
  sid: session.sid,
@@ -264,6 +885,22 @@ async function handleOidcCallback(request, config) {
264
885
  headers: { Location: redirectUrl.toString() }
265
886
  });
266
887
  } catch (error) {
888
+ if (error instanceof CallbackValidationError) {
889
+ if (error.reason === "idp_error") {
890
+ logger$2.warn({
891
+ reason: error.reason,
892
+ error: error.idpError,
893
+ errorDescription: error.idpErrorDescription
894
+ }, "Identity provider returned an error");
895
+ const code = error.idpError ?? "";
896
+ return loginErrorRedirect(applicationUrl, defaultRedirectPath, IDP_ERROR_CODE.test(code) ? code : "idp_error");
897
+ }
898
+ logger$2.warn({
899
+ reason: error.reason,
900
+ error: idpError
901
+ }, "Rejected OIDC callback");
902
+ return error.reason === "transaction_expired" ? loginEnded() : new Response("Please restart the login process.", { status: 400 });
903
+ }
267
904
  if (error instanceof CookieTooLargeError) {
268
905
  logger$2.error({
269
906
  error,
@@ -274,10 +911,21 @@ async function handleOidcCallback(request, config) {
274
911
  }
275
912
  logger$2.error({ error }, "OIDC callback error");
276
913
  return new Response("An unexpected error occurred. Please restart the login process.", { status: 500 });
914
+ } finally {
915
+ if (state) await clearTransaction(cookies, state, secret, txCookie);
277
916
  }
278
917
  }
279
- //#endregion
280
- //#region src/server/oidc-logout.ts
918
+ /** Redirects to the default path with `login=error` and a safe error code, never a description. */
919
+ function loginErrorRedirect(applicationUrl, defaultRedirectPath, code) {
920
+ const redirectUrl = new URL(defaultRedirectPath, applicationUrl);
921
+ redirectUrl.searchParams.set("login", "error");
922
+ redirectUrl.searchParams.set("error", code);
923
+ return new Response(null, {
924
+ status: 302,
925
+ headers: { Location: redirectUrl.toString() }
926
+ });
927
+ }
928
+ // src/server/oidc-logout.ts
281
929
  var logger$1 = createLogger({ namespace: "fides-auth:server:oidc-logout" });
282
930
  /**
283
931
  * Whether the request came from the application itself. POST alone is no defence:
@@ -304,7 +952,8 @@ function isSameOrigin(request, applicationUrl) {
304
952
  * unavailable, so the user is always logged out of this application.
305
953
  */
306
954
  async function handleOidcLogout(request, config) {
307
- const { oauthConfig, cookies, postLogoutRedirectUri, secret = getSessionSecret(), rateLimit, state, logoutHint, includeClientId, fallbackRedirectUri = postLogoutRedirectUri, allowedMethods = ["POST"], applicationUrl } = config;
955
+ const { oauthConfig, cookies, postLogoutRedirectUri, rateLimit, state, logoutHint, includeClientId, fallbackRedirectUri = postLogoutRedirectUri, allowedMethods = ["POST"], applicationUrl } = config;
956
+ const persistence = persistenceFrom(config);
308
957
  if (!allowedMethods.includes(request.method)) return new Response(null, {
309
958
  status: 405,
310
959
  headers: { Allow: allowedMethods.join(", ") }
@@ -320,12 +969,14 @@ async function handleOidcLogout(request, config) {
320
969
  logger$1.warn("Rate limit exceeded");
321
970
  return new Response("Too many requests", { status: 429 });
322
971
  }
323
- const idTokenHint = await readIdToken(cookies, secret);
324
- const sid = (await tryReadSession(cookies, secret)).session?.sid;
972
+ const read = await tryReadSession(cookies, persistence);
973
+ const idTokenHint = read.session?.tokens?.idToken ?? await readIdToken(cookies, persistence);
974
+ const sid = read.session?.sid;
325
975
  try {
326
976
  await clearSession(cookies, {
327
977
  trigger: "logout",
328
- sid
978
+ sid,
979
+ persistence
329
980
  });
330
981
  } catch (error) {
331
982
  logger$1.error({ error }, "Failed to clear session cookies");
@@ -351,8 +1002,7 @@ async function handleOidcLogout(request, config) {
351
1002
  headers: { Location: logoutUrl.toString() }
352
1003
  });
353
1004
  }
354
- //#endregion
355
- //#region src/server/heartbeat-handler.ts
1005
+ // src/server/heartbeat-handler.ts
356
1006
  var logger = createLogger({ namespace: "fides-auth:server:heartbeat" });
357
1007
  /**
358
1008
  * Handles a heartbeat request — refresh the active session if there is one.
@@ -369,7 +1019,8 @@ var logger = createLogger({ namespace: "fides-auth:server:heartbeat" });
369
1019
  * failures with backoff.
370
1020
  */
371
1021
  async function handleHeartbeat(request, config) {
372
- const { oauthConfig, cookies, rateLimit, secret = getSessionSecret() } = config;
1022
+ const { oauthConfig, cookies, rateLimit } = config;
1023
+ const persistence = persistenceFrom(config);
373
1024
  if (request.method !== "POST") return new Response(null, {
374
1025
  status: 405,
375
1026
  headers: { Allow: "POST" }
@@ -378,7 +1029,7 @@ async function handleHeartbeat(request, config) {
378
1029
  logger.warn("Heartbeat rate limit exceeded");
379
1030
  return new Response(null, { status: 429 });
380
1031
  }
381
- const result = await tryRefreshSessionInStore(cookies, oauthConfig, secret);
1032
+ const result = await tryRefreshSessionInStore(cookies, oauthConfig, persistence);
382
1033
  if (result.ok) {
383
1034
  logger.debug({ sid: result.session.sid }, "Heartbeat refresh succeeded");
384
1035
  return Response.json({ accessTokenExpiresAt: result.session.tokens?.accessTokenExpiresAt ?? null }, { headers: { "Cache-Control": "private, no-store" } });
@@ -415,7 +1066,6 @@ function unauthorized(reason) {
415
1066
  headers: { "Cache-Control": "private, no-store" }
416
1067
  });
417
1068
  }
418
- //#endregion
419
- export { clearSession, handleHeartbeat, handleOidcCallback, handleOidcLogin, handleOidcLogout, persistSession, readIdToken, readSession, refreshSessionInStore, tryReadSession, tryRefreshSessionInStore };
1069
+ export { AuthorizedFetchError, CallbackValidationError, authorizedFetch, clearSession, clearTransaction, cookiePersistence, createMemorySessionStore, createTransaction, handleHeartbeat, handleOidcCallback, handleOidcLogin, handleOidcLogout, persistSession, persistTransaction, readIdToken, readSession, readTransaction, refreshSessionInStore, serverPersistence, transactionCookieOptions, tryReadSession, tryRefreshSessionInStore, validateCallback };
420
1070
 
421
1071
  //# sourceMappingURL=index.js.map