@payez/next-mvp 4.10.0 → 4.11.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 (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/api-handlers/auth/refresh.d.ts +17 -58
  3. package/dist/api-handlers/auth/refresh.js +71 -772
  4. package/dist/auth/utils/token-utils.d.ts +1 -1
  5. package/dist/auth/utils/token-utils.js +3 -2
  6. package/dist/lib/api-handler.js +4 -3
  7. package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
  8. package/dist/lib/ensure-fresh-access-token.js +194 -28
  9. package/dist/lib/idp-fetch.js +3 -2
  10. package/dist/lib/refresh-in-doubt.d.ts +31 -0
  11. package/dist/lib/refresh-in-doubt.js +81 -0
  12. package/dist/lib/refresh-token-validator.js +2 -1
  13. package/dist/lib/refresh-window.d.ts +16 -0
  14. package/dist/lib/refresh-window.js +19 -0
  15. package/dist/lib/safe-trace-id.d.ts +11 -0
  16. package/dist/lib/safe-trace-id.js +23 -0
  17. package/dist/lib/session-store.js +14 -4
  18. package/dist/lib/token-lifecycle.js +3 -2
  19. package/dist/models/SessionModel.d.ts +7 -0
  20. package/dist/routes/auth/refresh.d.ts +1 -15
  21. package/dist/server/auth.d.ts +1 -1
  22. package/dist/stores/authStore.js +3 -2
  23. package/package.json +1 -1
  24. package/scripts/verify-release-provenance.mjs +1 -0
  25. package/src/api-handlers/auth/refresh.ts +72 -812
  26. package/src/auth/utils/token-utils.ts +3 -2
  27. package/src/lib/api-handler.ts +4 -3
  28. package/src/lib/ensure-fresh-access-token.ts +213 -29
  29. package/src/lib/idp-fetch.ts +3 -2
  30. package/src/lib/refresh-in-doubt.ts +79 -0
  31. package/src/lib/refresh-token-validator.ts +2 -1
  32. package/src/lib/refresh-window.ts +16 -0
  33. package/src/lib/safe-trace-id.ts +21 -0
  34. package/src/lib/session-store.ts +15 -3
  35. package/src/lib/token-lifecycle.ts +3 -2
  36. package/src/models/SessionModel.ts +7 -0
  37. package/src/server/auth.ts +1 -1
  38. package/src/stores/authStore.ts +3 -2
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { jwtDecode, decodeJwtHeader, extractKidFromToken, JwtHeader } from '../../lib/jwt-decode';
11
11
  import type { DecodedIdpAccessToken } from '../types/auth-types';
12
+ import { REFRESH_WINDOW_MS } from '../../lib/refresh-window';
12
13
 
13
14
  // Re-export header utilities for consumers
14
15
  export { decodeJwtHeader, extractKidFromToken, type JwtHeader } from '../../lib/jwt-decode';
@@ -137,12 +138,12 @@ export function extractAmrFromToken(decoded: DecodedIdpAccessToken): string[] {
137
138
  * Check if a token expiry timestamp indicates the token needs refresh.
138
139
  *
139
140
  * @param expiresAt - Token expiry timestamp (Unix milliseconds)
140
- * @param bufferMs - How early to trigger refresh (default 5 minutes)
141
+ * @param bufferMs - How early to trigger refresh (default: the one refresh window, `REFRESH_WINDOW_MS`, 5 minutes)
141
142
  * @returns true if token is expired or will expire within buffer period
142
143
  */
143
144
  export function tokenNeedsRefresh(
144
145
  expiresAt: number | undefined,
145
- bufferMs: number = 5 * 60 * 1000
146
+ bufferMs: number = REFRESH_WINDOW_MS
146
147
  ): boolean {
147
148
  if (!expiresAt) {
148
149
  return true; // No expiry info = assume needs refresh
@@ -26,6 +26,7 @@ import {
26
26
  import { getJwtCookieName } from './app-slug';
27
27
  import { getTokenTestAware } from './test-aware-get-token';
28
28
  import { internalRefresh } from './internal-api';
29
+ import { REFRESH_WINDOW_MS } from './refresh-window';
29
30
 
30
31
  // ============================================================================
31
32
  // Types
@@ -284,7 +285,7 @@ export class ApiHandler {
284
285
  // Skip the optimization (hasRefreshToken check) when access token is missing —
285
286
  // some session shapes store the refresh token under different field names,
286
287
  // and we still want a refresh attempt to populate the access token.
287
- const thresholdMs = 5 * 60 * 1000;
288
+ const thresholdMs = REFRESH_WINDOW_MS;
288
289
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
290
  const expires = sessionData.idpAccessTokenExpires || (sessionData as any).accessTokenExpires || 0;
290
291
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -426,7 +427,7 @@ export class ApiHandler {
426
427
  try {
427
428
  // Double-check if still needs refresh
428
429
  const latest = await getSession(sessionToken);
429
- const thresholdMs = 5 * 60 * 1000;
430
+ const thresholdMs = REFRESH_WINDOW_MS;
430
431
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
431
432
  const latestAny = latest as any;
432
433
  // Sessions may store the access token under either `accessToken` or `idpAccessToken`,
@@ -481,7 +482,7 @@ export class ApiHandler {
481
482
  const session = await getSession(sessionToken);
482
483
  if (session?.accessToken) {
483
484
  const expires = session.accessTokenExpires || 0;
484
- if ((expires - Date.now()) > (5 * 60 * 1000)) {
485
+ if ((expires - Date.now()) > REFRESH_WINDOW_MS) {
485
486
  return { success: true };
486
487
  }
487
488
  }
@@ -25,6 +25,16 @@ import {
25
25
  import { computeTokenExpiries } from './token-expiry';
26
26
  import { extractKidFromToken } from '../auth/utils/token-utils';
27
27
  import { rotatedRowIsUsable } from './refresh-rotation';
28
+ import { REFRESH_WINDOW_MS } from './refresh-window';
29
+ import { sanitizeTraceId } from './safe-trace-id';
30
+ import {
31
+ REFRESH_FETCH_TIMEOUT_MS,
32
+ REFRESH_OUTCOME_UNKNOWN_CODE,
33
+ UPSTREAM_TIMEOUT_CODE,
34
+ inDoubtPatch,
35
+ isRefreshInDoubt,
36
+ requestNeverLeft,
37
+ } from './refresh-in-doubt';
28
38
 
29
39
  export interface EnsureFreshConfig {
30
40
  idpBaseUrl: string;
@@ -33,10 +43,15 @@ export interface EnsureFreshConfig {
33
43
  }
34
44
 
35
45
  export interface EnsureFreshOptions {
36
- /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
46
+ /** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
37
47
  safetyWindowMs?: number;
38
48
  /** Max wait while another caller holds the refresh lock. Default 5000. */
39
49
  lockWaitMs?: number;
50
+ /**
51
+ * The refresh ROUTE sets this (BAPert 76449, R-over-E PR 2): a session with no refresh token is refused with the terminal NO_REFRESH_TOKEN
52
+ * even when its access token is still fresh, as the route always did. Other callers leave it unset and get `already_fresh` for a fresh token.
53
+ */
54
+ requireRefreshToken?: boolean;
40
55
  /**
41
56
  * Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
42
57
  * crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
@@ -51,6 +66,14 @@ export type EnsureFreshResult =
51
66
  accessTokenExpires: number;
52
67
  /** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
53
68
  refreshed: boolean;
69
+ /**
70
+ * WHY this result is ok (BAPert 76449, additive; `api-handlers/auth/refresh` reproduces its bodies from it and `lib/token-lifecycle` reads
71
+ * `already_fresh`): 'already_fresh' = nothing was needed; 'completed_by_concurrent_request' = another request refreshed and we returned
72
+ * its result; 'refreshed' = THIS call refreshed.
73
+ */
74
+ reason: 'refreshed' | 'already_fresh' | 'completed_by_concurrent_request';
75
+ /** Whether the stored session holds a refresh token (the refresh route reports it in its body). */
76
+ hasRefreshToken: boolean;
54
77
  /**
55
78
  * CARD 320072. Is the token in THIS result actually in the session store?
56
79
  *
@@ -89,7 +112,25 @@ export type EnsureFreshResult =
89
112
  resolution?: string;
90
113
  };
91
114
 
92
- const DEFAULT_SAFETY_WINDOW_MS = 60_000;
115
+ /**
116
+ * The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
117
+ * `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
118
+ */
119
+ export function checkRefreshEnvelope(body: any, httpOk: boolean): { ok: true } | { ok: false; why: string } {
120
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, why: 'body is not an object' };
121
+ if (typeof body.success !== 'boolean') return { ok: false, why: 'success is not a boolean' };
122
+ if (body.success === true) {
123
+ if (!httpOk) return { ok: false, why: 'success:true on a non-2xx' };
124
+ if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
125
+ return { ok: false, why: 'success:true without data.access_token' };
126
+ }
127
+ } else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
128
+ return { ok: false, why: 'success:false without error.code' };
129
+ }
130
+ return { ok: true };
131
+ }
132
+
133
+ const DEFAULT_SAFETY_WINDOW_MS = REFRESH_WINDOW_MS; // was 60_000; one window for R, E and idp-fetch (BAPert 76397 decision 1)
93
134
  const DEFAULT_LOCK_WAIT_MS = 5000;
94
135
 
95
136
  function decodeJwtExp(token: string): number {
@@ -117,18 +158,33 @@ export async function ensureFreshAccessToken(
117
158
  // reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
118
159
  // collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
119
160
  const requestId = `ensure_fresh_${randomUUID()}`;
120
- const traceId = options.requestId;
161
+ // Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
162
+ const traceId = sanitizeTraceId(options.requestId);
121
163
 
122
- let session = await getSession(sessionToken);
164
+ const canonicalFirst = await getSession(sessionToken);
165
+ let session = canonicalFirst;
123
166
  if (!session || !session.idpRefreshToken) {
124
167
  const baSession = await getBetterAuthSession(sessionToken);
125
168
  if (baSession?.idpRefreshToken) {
126
169
  session = baSession;
127
170
  }
128
171
  }
172
+ // `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
173
+ // persist writes.
174
+ const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
129
175
  if (!session) {
130
176
  return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
131
177
  }
178
+ if (options.requireRefreshToken && !session.idpRefreshToken) {
179
+ return {
180
+ ok: false,
181
+ code: 'NO_REFRESH_TOKEN',
182
+ message: 'No refresh token available',
183
+ status: 401,
184
+ terminal: true,
185
+ resolution: 'User must re-authenticate',
186
+ };
187
+ }
132
188
 
133
189
  const storedToken = session.idpAccessToken;
134
190
  if (!storedToken) {
@@ -136,22 +192,28 @@ export async function ensureFreshAccessToken(
136
192
  // session with no access token and mfaVerified !== true is a user
137
193
  // mid-2FA, not dead. Only terminal once the session itself claims 2FA
138
194
  // is already done.
195
+ //
196
+ // BAPert 76449 (A): a pending-2FA row keeps NO_TOKEN_PENDING_2FA EVEN WITH a refresh token: refresh must never be a way around 2FA.
197
+ // A row that is NOT pending 2FA and has a refresh token is refreshed below (a missing access token is the reason to refresh, as the
198
+ // refresh route always did); with no refresh token it is NO_TOKEN as before.
139
199
  if (session.mfaVerified !== true) {
140
200
  return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
141
201
  }
142
- return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
202
+ if (!session.idpRefreshToken) {
203
+ return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
204
+ }
143
205
  }
144
206
 
145
207
  const now = Date.now();
146
208
  const redisExpires = session.idpAccessTokenExpires ?? 0;
147
- const jwtExpires = decodeJwtExp(storedToken);
209
+ const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
148
210
  // Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
149
211
  const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
150
212
  const msUntilExpiry = effectiveExpires - now;
151
213
 
152
- if (msUntilExpiry > safetyWindowMs) {
153
- // Durable by construction: this token came OUT of the store and we wrote nothing.
154
- return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
214
+ if (storedToken && msUntilExpiry > safetyWindowMs) {
215
+ // This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
216
+ return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
155
217
  }
156
218
 
157
219
  if (!session.idpRefreshToken) {
@@ -176,7 +238,8 @@ export async function ensureFreshAccessToken(
176
238
  await new Promise(r => setTimeout(r, 200));
177
239
  const stillLocked = await checkRefreshLock(sessionToken);
178
240
  if (!stillLocked) {
179
- let after = await getSession(sessionToken);
241
+ const canonicalAfter = await getSession(sessionToken);
242
+ let after = canonicalAfter;
180
243
  if (!after || !after.idpRefreshToken) {
181
244
  const baAfter = await getBetterAuthSession(sessionToken);
182
245
  if (baAfter?.idpRefreshToken) after = baAfter;
@@ -189,8 +252,10 @@ export async function ensureFreshAccessToken(
189
252
  accessToken: after.idpAccessToken,
190
253
  accessTokenExpires: after.idpAccessTokenExpires,
191
254
  refreshed: true,
192
- // Read back from the store after the winner's refresh - durable.
193
- persisted: true,
255
+ reason: 'completed_by_concurrent_request',
256
+ hasRefreshToken: !!after.idpRefreshToken,
257
+ // Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
258
+ persisted: !!canonicalAfter && after === canonicalAfter,
194
259
  };
195
260
  }
196
261
  }
@@ -212,7 +277,8 @@ export async function ensureFreshAccessToken(
212
277
 
213
278
  try {
214
279
  // Re-check after lock — another caller may have already refreshed.
215
- let latest = await getSession(sessionToken);
280
+ const canonicalLatest = await getSession(sessionToken);
281
+ let latest = canonicalLatest;
216
282
  if (!latest || !latest.idpRefreshToken) {
217
283
  const baLatest = await getBetterAuthSession(sessionToken);
218
284
  if (baLatest?.idpRefreshToken) latest = baLatest;
@@ -227,12 +293,54 @@ export async function ensureFreshAccessToken(
227
293
  accessToken: latest.idpAccessToken,
228
294
  accessTokenExpires: latest.idpAccessTokenExpires,
229
295
  refreshed: true,
230
- // Post-lock re-read: another caller already refreshed and stored this.
231
- persisted: true,
296
+ reason: 'completed_by_concurrent_request',
297
+ hasRefreshToken: !!latest.idpRefreshToken,
298
+ // Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
299
+ persisted: !!canonicalLatest && latest === canonicalLatest,
232
300
  };
233
301
  }
234
302
  }
235
303
 
304
+ // R-over-E (BAPert 76318 item 4 / 1432 (b2), now E's own): the token SENT is the one in the POST-lock read, so that read must still hold
305
+ // one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
306
+ // marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
307
+ if (!latest) {
308
+ return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
309
+ }
310
+ if (!latest.idpRefreshToken) {
311
+ return {
312
+ ok: false,
313
+ code: 'NO_REFRESH_TOKEN',
314
+ message: 'No refresh token available',
315
+ status: 401,
316
+ terminal: true,
317
+ resolution: 'User must re-authenticate',
318
+ };
319
+ }
320
+
321
+ // BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
322
+ // absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
323
+ // nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
324
+ // refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
325
+ // re-established.
326
+ if (!canonicalLatest) {
327
+ console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
328
+ requestId,
329
+ traceId,
330
+ betterAuthOnly: !!latest,
331
+ });
332
+ return {
333
+ ok: false,
334
+ code: 'NO_CANONICAL_SESSION',
335
+ message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
336
+ status: 401,
337
+ terminal: true,
338
+ retryable: false,
339
+ discardToken: false,
340
+ resolution: 'REAUTHENTICATE',
341
+ };
342
+ }
343
+
236
344
  // Build refresh request body — wire shape must match what the IDP expects.
237
345
  let authMethods: string[] = [];
238
346
  if (Array.isArray(session.authenticationMethods)) {
@@ -262,6 +370,27 @@ export async function ensureFreshAccessToken(
262
370
  // Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
263
371
  // the identity context of THIS request, unchanged by a token rotation.
264
372
  const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
373
+
374
+ // BAPert 76403: an earlier attempt for THIS token timed out, so Core may already have consumed it; re-sending it is a replay, and a
375
+ // replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
376
+ if (isRefreshInDoubt(canonicalLatest, sentRefreshToken)) {
377
+ console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
378
+ requestId,
379
+ traceId,
380
+ inDoubtSince: canonicalLatest.refreshInDoubtAt,
381
+ });
382
+ return {
383
+ ok: false,
384
+ code: REFRESH_OUTCOME_UNKNOWN_CODE,
385
+ message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
386
+ status: 401,
387
+ terminal: true,
388
+ retryable: false,
389
+ discardToken: false,
390
+ resolution: 'REAUTHENTICATE',
391
+ };
392
+ }
393
+
265
394
  const body: Record<string, unknown> = {
266
395
  refresh_token: sentRefreshToken,
267
396
  amr: authMethods,
@@ -271,18 +400,45 @@ export async function ensureFreshAccessToken(
271
400
  if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
272
401
  if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
273
402
 
403
+ // BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
404
+ // replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
405
+ const answerTimedOut = async (cause?: unknown): Promise<EnsureFreshResult> => {
406
+ console.error('[ENSURE-FRESH] IDP did not answer (timeout or a transport failure that does not prove the request never left) - outcome unknown, marking the refresh in doubt', {
407
+ requestId,
408
+ traceId,
409
+ timeoutMs: REFRESH_FETCH_TIMEOUT_MS,
410
+ cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
411
+ });
412
+ try {
413
+ await updateSession(sessionToken, inDoubtPatch(sentRefreshToken as string));
414
+ } catch (markError) {
415
+ console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: markError instanceof Error ? markError.message : String(markError) });
416
+ }
417
+ return {
418
+ ok: false,
419
+ code: UPSTREAM_TIMEOUT_CODE,
420
+ message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
421
+ status: 504,
422
+ terminal: false,
423
+ retryable: false,
424
+ discardToken: false,
425
+ };
426
+ };
427
+
274
428
  let idpResponse: Response;
275
429
  try {
276
430
  idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
277
431
  method: 'POST',
278
432
  headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
279
433
  body: JSON.stringify(body),
434
+ signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
280
435
  });
281
436
  } catch (err) {
437
+ if (!requestNeverLeft(err)) return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
282
438
  return {
283
439
  ok: false,
284
440
  code: 'UPSTREAM_SERVICE_UNAVAILABLE',
285
- message: err instanceof Error ? err.message : 'IDP unreachable',
441
+ message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
286
442
  status: 503,
287
443
  retryable: true,
288
444
  };
@@ -292,11 +448,30 @@ export async function ensureFreshAccessToken(
292
448
  try {
293
449
  const text = await idpResponse.text();
294
450
  if (!text.trim()) {
451
+ if (idpResponse.ok) return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
295
452
  return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
296
453
  }
297
454
  responseData = JSON.parse(text);
298
455
  } catch (err) {
299
- return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid JSON from IDP', status: 502, retryable: true };
456
+ if (!(err instanceof SyntaxError) || idpResponse.ok) return await answerTimedOut(err); // BAPert 76441: a body-READ failure is in doubt; so is a 2xx whose body does not parse (Core processed the grant, i.e. consumed the token, and the replacement is lost). A non-2xx unparseable body (a gateway HTML 502/503) did not mint: a completed failure, no marker.
457
+ return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
458
+ }
459
+
460
+ // BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
461
+ // POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
462
+ // message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
463
+ // answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
464
+ const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
465
+ if (!envelope.ok) {
466
+ console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
467
+ requestId,
468
+ traceId,
469
+ // the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
470
+ idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
471
+ status: idpResponse.status,
472
+ why: envelope.why,
473
+ });
474
+ return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
300
475
  }
301
476
 
302
477
  if (!idpResponse.ok) {
@@ -360,7 +535,9 @@ export async function ensureFreshAccessToken(
360
535
  accessToken: postRefresh.idpAccessToken,
361
536
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
362
537
  refreshed: true,
363
- // `postRefresh` is a store read, so this token is by definition stored.
538
+ reason: 'completed_by_concurrent_request',
539
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
540
+ // `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
364
541
  persisted: true,
365
542
  };
366
543
  }
@@ -424,6 +601,8 @@ export async function ensureFreshAccessToken(
424
601
  accessToken: postRefresh.idpAccessToken,
425
602
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
426
603
  refreshed: true,
604
+ reason: 'completed_by_concurrent_request',
605
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
427
606
  persisted: true,
428
607
  };
429
608
  }
@@ -471,12 +650,12 @@ export async function ensureFreshAccessToken(
471
650
  try {
472
651
  amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
473
652
  } catch {
474
- amrClaims = session.authenticationMethods || [];
653
+ amrClaims = canonicalLatest.authenticationMethods || [];
475
654
  }
476
655
  } else {
477
- amrClaims = session.authenticationMethods || [];
656
+ amrClaims = canonicalLatest.authenticationMethods || [];
478
657
  }
479
- const acrLevel = String(decoded?.acr || session.authenticationLevel || '1');
658
+ const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
480
659
  const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
481
660
  const newKid = extractKidFromToken(newAccess);
482
661
 
@@ -487,14 +666,19 @@ export async function ensureFreshAccessToken(
487
666
  // report `ok:true, refreshed:true` for a refresh it had just thrown away.
488
667
  // The full rationale for what we DO about it is at the `if (!persisted)`
489
668
  // block below; this line only makes the answer available.
669
+ //
670
+ // BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
671
+ // pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
672
+ // pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
673
+ // spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
674
+ // concurrent writer's change to anything else survives.
490
675
  const persisted = await updateSession(sessionToken, {
491
- ...session,
492
676
  idpAccessToken: newAccess,
493
677
  idpAccessTokenExpires: accessTokenExpires,
494
- idpRefreshToken: hasNewRefresh ? newRefresh : session.idpRefreshToken,
495
- idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : session.idpRefreshTokenExpires,
678
+ idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
679
+ idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
496
680
  decodedAccessToken: decoded,
497
- bearerKeyId: newKid || session.bearerKeyId,
681
+ bearerKeyId: newKid || canonicalLatest.bearerKeyId,
498
682
  authenticationMethods: amrClaims,
499
683
  authenticationLevel: acrLevel,
500
684
  // 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
@@ -502,9 +686,9 @@ export async function ensureFreshAccessToken(
502
686
  // rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
503
687
  // the same defect duplicated at a second call site.
504
688
  mfaVerified: amrClaims.includes('mfa'),
505
- mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : session.mfaCompletedAt,
506
- mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : session.mfaExpiresAt,
507
- mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
689
+ mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
690
+ mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
691
+ mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
508
692
  });
509
693
 
510
694
  /**
@@ -572,7 +756,7 @@ export async function ensureFreshAccessToken(
572
756
  // the only way out for a dropped write, so by construction anything reaching
573
757
  // this line WAS written. Keeping the field keeps the ok:true branch of
574
758
  // EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
575
- return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
759
+ return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
576
760
  } finally {
577
761
  if (weHoldLock) {
578
762
  await releaseRefreshLock(sessionToken, requestId, releaseVersion);
@@ -4,6 +4,7 @@ import { getSession } from './session-store';
4
4
  import { internalRefresh } from './internal-api';
5
5
  import { assertAbsoluteIdpUrl } from './idp-url-guard';
6
6
  import { parseRetryAfterSeconds } from './retry-after';
7
+ import { REFRESH_WINDOW_MS } from './refresh-window';
7
8
 
8
9
  // Re-exported so `idp-fetch` remains the single obvious import for IDP calling
9
10
  // concerns; the implementation lives in a dependency-free leaf so the three
@@ -119,8 +120,8 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
119
120
  const hasRefreshToken = !!session.idpRefreshToken;
120
121
  const now = Date.now();
121
122
  const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
122
- // Treat tokens as effectively expired if within 5 minutes of expiry
123
- if (timeLeft > 5 * 60 * 1000) {
123
+ // Treat tokens as effectively expired if within REFRESH_WINDOW_MS of expiry (lib/refresh-window: the one window)
124
+ if (timeLeft > REFRESH_WINDOW_MS) {
124
125
  return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
125
126
  }
126
127
  // Only attempt refresh when we actually have a refresh token to use.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The IdP fetch timeout and the "refresh in doubt" marker (BAPert 76397 decision 3, corrected by 76403; Core facts DotNetPert 76400).
3
+ *
4
+ * THE PROBLEM. Core consumes a refresh token with one atomic UPDATE and has NO reuse grace: re-presenting a used token revokes every newer
5
+ * refresh token the USER holds, on every device (RevokeDescendantTokens). So when OUR fetch to the IdP times out, we do not know whether Core
6
+ * consumed the token and minted a replacement whose answer we never saw. The store still holds the token we SENT (nothing was persisted).
7
+ * Holding the refresh lock only delays the next request by its 60 s TTL; after that the next request re-reads the same token and replays it.
8
+ *
9
+ * THE RULE. A timed-out refresh answers 504 UPSTREAM_TIMEOUT and records, on the session row, an IDENTITY (a SHA-256, never the token and
10
+ * never a prefix of it) of the token that was sent. Any later attempt whose token-to-send matches that identity refuses WITHOUT calling the
11
+ * IdP: terminal REFRESH_OUTCOME_UNKNOWN / REAUTHENTICATE. One login on one session is better than a user-wide revocation. The marker is
12
+ * dropped by the same write that stores a DIFFERENT refresh token (session-store `dropStaleDiscardMarker`); nothing tries to resolve the doubt
13
+ * without a login.
14
+ *
15
+ * Leaf module: no imports from the rest of the package, so session-store can use it without a cycle.
16
+ */
17
+ import { createHash } from 'crypto';
18
+
19
+ /** How long a refresh fetch (connect, response and body) may take. 5 s lock wait + this + the persist stays well inside the 60 s lock TTL. */
20
+ export const REFRESH_FETCH_TIMEOUT_MS = 10_000;
21
+
22
+ /** The typed terminal answer for an attempt that would re-send an in-doubt token. */
23
+ export const REFRESH_OUTCOME_UNKNOWN_CODE = 'REFRESH_OUTCOME_UNKNOWN';
24
+
25
+ /** The typed answer for the timed-out call itself. */
26
+ export const UPSTREAM_TIMEOUT_CODE = 'UPSTREAM_TIMEOUT';
27
+
28
+ /** A one-way identity for a refresh token. Not reversible and not a prefix. */
29
+ export function refreshTokenIdentity(token: string): string {
30
+ return createHash('sha256').update(token, 'utf8').digest('hex');
31
+ }
32
+
33
+ /** The row fields recorded at a timeout. */
34
+ export function inDoubtPatch(sentToken: string): { refreshInDoubtTokenHash: string; refreshInDoubtAt: number } {
35
+ return { refreshInDoubtTokenHash: refreshTokenIdentity(sentToken), refreshInDoubtAt: Date.now() };
36
+ }
37
+
38
+ /** True when the token we are about to send is the one a previous attempt left in doubt. */
39
+ export function isRefreshInDoubt(
40
+ session: { refreshInDoubtTokenHash?: string | null } | null | undefined,
41
+ tokenToSend: string | null | undefined,
42
+ ): boolean {
43
+ if (!session || typeof session.refreshInDoubtTokenHash !== 'string' || !session.refreshInDoubtTokenHash) return false;
44
+ if (typeof tokenToSend !== 'string' || tokenToSend.length === 0) return false;
45
+ return refreshTokenIdentity(tokenToSend) === session.refreshInDoubtTokenHash;
46
+ }
47
+
48
+ /** True for the error `AbortSignal.timeout` produces (a DOMException named TimeoutError); AbortError is treated the same. */
49
+ export function isTimeoutError(err: unknown): boolean {
50
+ const name = (err as { name?: string } | null | undefined)?.name;
51
+ return name === 'TimeoutError' || name === 'AbortError';
52
+ }
53
+
54
+ /**
55
+ * The ONLY transport error codes that PROVE the request never left this process (BAPert 76433 item 4): the connection was refused, or the
56
+ * name did not resolve. Anything else (ECONNRESET, UND_ERR_SOCKET, a timeout, an error with no code) may have happened AFTER the request was
57
+ * written, so the IdP may have consumed the token: outcome unknown. The cost is lopsided on purpose: a false in-doubt costs one re-login, a
58
+ * missed one costs a user-wide revocation. Do NOT add a code here without proof it can only occur before the request is sent.
59
+ */
60
+ export const REQUEST_NEVER_LEFT_CODES: ReadonlySet<string> = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
61
+
62
+ /** Every `code` on the error, its `cause`, and the members of an AggregateError (undici wraps the socket error in `cause`). */
63
+ function transportCodes(err: unknown, depth = 0, out: string[] = []): string[] {
64
+ if (!err || typeof err !== 'object' || depth > 4) return out;
65
+ const e = err as { code?: unknown; cause?: unknown; errors?: unknown };
66
+ if (typeof e.code === 'string' && e.code) out.push(e.code);
67
+ if (e.cause) transportCodes(e.cause, depth + 1, out);
68
+ if (Array.isArray(e.errors)) for (const inner of e.errors) transportCodes(inner, depth + 1, out);
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * True only when the error carries at least one cause code and EVERY code it carries is in the never-left allowlist. An error with no code
74
+ * is NOT proof, so it is in-doubt.
75
+ */
76
+ export function requestNeverLeft(err: unknown): boolean {
77
+ const codes = transportCodes(err);
78
+ return codes.length > 0 && codes.every((c) => REQUEST_NEVER_LEFT_CODES.has(c));
79
+ }
@@ -1,5 +1,6 @@
1
1
  import { jwtDecode } from './jwt-decode';
2
2
  import { tokenRefreshLogger } from '../config/logger';
3
+ import { REFRESH_WINDOW_MS } from './refresh-window';
3
4
 
4
5
  interface RefreshTokenPayload { exp: number; iat: number; jti: string; user_id: string; token_type: string; binding: string; }
5
6
 
@@ -38,7 +39,7 @@ export function checkRefreshViability(sessionData: any): RefreshViabilityCheck {
38
39
  if (typeof expiresAtMs === 'string') expiresAtMs = parseInt(expiresAtMs, 10);
39
40
  if (expiresAtMs < 1000000000000) expiresAtMs = expiresAtMs * 1000;
40
41
  accessTokenTimeRemaining = Math.floor((expiresAtMs - now) / 1000);
41
- const bufferSec = 5 * 60; // 5 minutes pre-expiry buffer
42
+ const bufferSec = REFRESH_WINDOW_MS / 1000; // the one refresh window (lib/refresh-window), in seconds
42
43
  accessTokenExpired = accessTokenTimeRemaining <= bufferSec;
43
44
  tokenRefreshLogger.debug('[REFRESH_VIABILITY] Access token expiration check', { now, expiresAtMs, accessTokenTimeRemaining, bufferSec, accessTokenExpired });
44
45
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * THE freshness window for an IdP access token (BAPert 76397 decision 1; Core facts DotNetPert 76400).
3
+ *
4
+ * An access token with this much life left or less is treated as stale and refreshed; with more, it is served as it is. One value for
5
+ * every path that decides: the mounted refresh route (`api-handlers/auth/refresh`), `ensureFreshAccessToken`, and `lib/idp-fetch`'s own
6
+ * pre-check. They used to disagree (5 min, 60 s, 5 min), so the same session was "stale" to one path and "fresh" to another, which is how
7
+ * two callers each rotated a token the other had just rotated.
8
+ *
9
+ * WHY 5 MINUTES: Core mints access tokens with a fixed 15-minute lifetime (a code constant, not configuration: Core
10
+ * `TokenConstants.ACCESS_TOKEN_LIFETIME_MINUTES = 15`, read-only in `TokenSettings`). The ruling is 5 minutes when the lifetime is at least 15
11
+ * minutes, and lifetime / 3 otherwise. If Core ever shortens the lifetime, this value MUST be revisited: a window at or above the lifetime
12
+ * makes every request refresh.
13
+ *
14
+ * A caller that needs another value passes it explicitly (`ensureFreshAccessToken`'s `safetyWindowMs` option still exists and still wins).
15
+ */
16
+ export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A caller-supplied trace id (the `x-request-id` header) is attacker-chosen text, and it goes to the log sink (PAY-2376; BAPert 76321 item 4,
3
+ * NightHawk 76316). Two problems if it is logged as it arrives: a value with a newline forges a log line, and an unbounded value is a log
4
+ * flood. This is the one place that makes it safe to log: control characters are removed, the length is capped, and anything that is not a
5
+ * non-empty string becomes `undefined` (so the log carries no trace id rather than a forged one).
6
+ *
7
+ * It is for LOG CORRELATION ONLY. It is never a lock identity and never a security decision (the lock id is minted server-side).
8
+ */
9
+
10
+ /** Longest trace id that is logged. 64 covers a UUID (36) and common trace formats; longer input is cut, not rejected. */
11
+ export const MAX_TRACE_ID_LENGTH = 64;
12
+
13
+ // C0 controls (incl. CR, LF, TAB, ESC), DEL, C1 controls (incl. NEL U+0085), and the Unicode line/paragraph separators U+2028 / U+2029.
14
+ // eslint-disable-next-line no-control-regex
15
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
16
+
17
+ export function sanitizeTraceId(value: unknown): string | undefined {
18
+ if (typeof value !== 'string') return undefined;
19
+ const cleaned = value.replace(CONTROL_CHARS, '').slice(0, MAX_TRACE_ID_LENGTH);
20
+ return cleaned.length > 0 ? cleaned : undefined;
21
+ }