@payez/next-mvp 4.10.0 → 4.12.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 (123) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/api-handlers/account/recovery/forward.js +2 -1
  3. package/dist/api-handlers/account/recovery/initiate.js +2 -1
  4. package/dist/api-handlers/account/reset-password.js +2 -1
  5. package/dist/api-handlers/account/send-code.js +2 -1
  6. package/dist/api-handlers/account/update-phone.js +2 -1
  7. package/dist/api-handlers/account/validate-password.js +2 -1
  8. package/dist/api-handlers/account/verify-email.js +2 -1
  9. package/dist/api-handlers/account/verify-sms.js +2 -1
  10. package/dist/api-handlers/anon/preferences.js +3 -2
  11. package/dist/api-handlers/auth/credential-login-email-code.js +3 -2
  12. package/dist/api-handlers/auth/credential-login.js +6 -5
  13. package/dist/api-handlers/auth/refresh.d.ts +17 -58
  14. package/dist/api-handlers/auth/refresh.js +73 -773
  15. package/dist/api-handlers/auth/signout.js +43 -6
  16. package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
  17. package/dist/api-handlers/mfa/totp-verify.js +2 -1
  18. package/dist/api-handlers/session/refresh-viability.js +3 -2
  19. package/dist/api-handlers/session/viability.js +2 -1
  20. package/dist/auth/better-auth.js +7 -6
  21. package/dist/auth/idp-oauth.js +4 -2
  22. package/dist/auth/utils/idp-client.js +5 -4
  23. package/dist/auth/utils/token-utils.d.ts +1 -1
  24. package/dist/auth/utils/token-utils.js +7 -5
  25. package/dist/config/logger.js +2 -1
  26. package/dist/config/logging-config.js +4 -3
  27. package/dist/config/vibe-log-transport.js +2 -1
  28. package/dist/lib/api-handler.js +4 -3
  29. package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
  30. package/dist/lib/ensure-fresh-access-token.js +195 -28
  31. package/dist/lib/geolocation.js +5 -22
  32. package/dist/lib/idp-client-config.js +4 -3
  33. package/dist/lib/idp-fetch.d.ts +11 -0
  34. package/dist/lib/idp-fetch.js +6 -3
  35. package/dist/lib/idp-logout.d.ts +10 -0
  36. package/dist/lib/idp-logout.js +73 -1
  37. package/dist/lib/jwt-decode-client.js +2 -1
  38. package/dist/lib/jwt-decode.js +2 -1
  39. package/dist/lib/log-safe.d.ts +19 -0
  40. package/dist/lib/log-safe.js +32 -0
  41. package/dist/lib/redis.js +2 -1
  42. package/dist/lib/refresh-in-doubt.d.ts +31 -0
  43. package/dist/lib/refresh-in-doubt.js +81 -0
  44. package/dist/lib/refresh-token-validator.js +2 -1
  45. package/dist/lib/refresh-window.d.ts +16 -0
  46. package/dist/lib/refresh-window.js +19 -0
  47. package/dist/lib/safe-error-response.d.ts +5 -1
  48. package/dist/lib/safe-error-response.js +7 -2
  49. package/dist/lib/safe-trace-id.d.ts +11 -0
  50. package/dist/lib/safe-trace-id.js +23 -0
  51. package/dist/lib/session-store.js +16 -5
  52. package/dist/lib/site-logger.d.ts +2 -8
  53. package/dist/lib/site-logger.js +11 -5
  54. package/dist/lib/standardized-client-api.js +3 -2
  55. package/dist/lib/token-lifecycle.js +6 -4
  56. package/dist/middleware/create-middleware.js +2 -1
  57. package/dist/middleware/rbac-check.d.ts +0 -13
  58. package/dist/middleware/rbac-check.js +6 -5
  59. package/dist/models/SessionModel.d.ts +7 -0
  60. package/dist/routes/auth/logout.js +6 -2
  61. package/dist/routes/auth/refresh.d.ts +1 -15
  62. package/dist/routes/auth/settings.js +2 -1
  63. package/dist/server/auth-guard.js +4 -3
  64. package/dist/server/auth.d.ts +1 -1
  65. package/dist/server/auth.js +5 -9
  66. package/dist/server/decode-session.js +3 -2
  67. package/dist/server/with-auth.js +2 -1
  68. package/dist/stores/authStore.js +3 -2
  69. package/package.json +1 -1
  70. package/scripts/verify-release-provenance.mjs +1 -0
  71. package/src/api-handlers/account/recovery/forward.ts +2 -1
  72. package/src/api-handlers/account/recovery/initiate.ts +2 -1
  73. package/src/api-handlers/account/reset-password.ts +2 -1
  74. package/src/api-handlers/account/send-code.ts +2 -1
  75. package/src/api-handlers/account/update-phone.ts +2 -1
  76. package/src/api-handlers/account/validate-password.ts +2 -1
  77. package/src/api-handlers/account/verify-email.ts +2 -1
  78. package/src/api-handlers/account/verify-sms.ts +2 -1
  79. package/src/api-handlers/anon/preferences.ts +3 -2
  80. package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
  81. package/src/api-handlers/auth/credential-login.ts +6 -5
  82. package/src/api-handlers/auth/refresh.ts +74 -813
  83. package/src/api-handlers/auth/signout.ts +54 -7
  84. package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
  85. package/src/api-handlers/mfa/totp-verify.ts +2 -1
  86. package/src/api-handlers/session/refresh-viability.ts +3 -2
  87. package/src/api-handlers/session/viability.ts +2 -1
  88. package/src/auth/better-auth.ts +7 -6
  89. package/src/auth/idp-oauth.ts +7 -2
  90. package/src/auth/utils/idp-client.ts +5 -4
  91. package/src/auth/utils/token-utils.ts +7 -5
  92. package/src/config/logger.ts +2 -1
  93. package/src/config/logging-config.ts +4 -3
  94. package/src/config/vibe-log-transport.ts +2 -1
  95. package/src/lib/api-handler.ts +4 -3
  96. package/src/lib/ensure-fresh-access-token.ts +214 -29
  97. package/src/lib/geolocation.ts +5 -4
  98. package/src/lib/idp-client-config.ts +4 -3
  99. package/src/lib/idp-fetch.ts +14 -3
  100. package/src/lib/idp-logout.ts +36 -1
  101. package/src/lib/jwt-decode-client.ts +3 -1
  102. package/src/lib/jwt-decode.ts +2 -1
  103. package/src/lib/log-safe.ts +30 -0
  104. package/src/lib/redis.ts +2 -1
  105. package/src/lib/refresh-in-doubt.ts +79 -0
  106. package/src/lib/refresh-token-validator.ts +2 -1
  107. package/src/lib/refresh-window.ts +16 -0
  108. package/src/lib/safe-error-response.ts +7 -2
  109. package/src/lib/safe-trace-id.ts +21 -0
  110. package/src/lib/session-store.ts +17 -4
  111. package/src/lib/site-logger.ts +13 -7
  112. package/src/lib/standardized-client-api.ts +3 -2
  113. package/src/lib/token-lifecycle.ts +6 -4
  114. package/src/middleware/create-middleware.ts +2 -1
  115. package/src/middleware/rbac-check.ts +2 -1
  116. package/src/models/SessionModel.ts +7 -0
  117. package/src/routes/auth/logout.ts +9 -2
  118. package/src/routes/auth/settings.ts +2 -1
  119. package/src/server/auth-guard.ts +4 -3
  120. package/src/server/auth.ts +6 -11
  121. package/src/server/decode-session.ts +3 -2
  122. package/src/server/with-auth.ts +2 -1
  123. package/src/stores/authStore.ts +3 -2
@@ -25,6 +25,17 @@ 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';
38
+ import { errForLog } from './log-safe';
28
39
 
29
40
  export interface EnsureFreshConfig {
30
41
  idpBaseUrl: string;
@@ -33,10 +44,15 @@ export interface EnsureFreshConfig {
33
44
  }
34
45
 
35
46
  export interface EnsureFreshOptions {
36
- /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
47
+ /** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
37
48
  safetyWindowMs?: number;
38
49
  /** Max wait while another caller holds the refresh lock. Default 5000. */
39
50
  lockWaitMs?: number;
51
+ /**
52
+ * 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
53
+ * 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.
54
+ */
55
+ requireRefreshToken?: boolean;
40
56
  /**
41
57
  * Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
42
58
  * crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
@@ -51,6 +67,14 @@ export type EnsureFreshResult =
51
67
  accessTokenExpires: number;
52
68
  /** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
53
69
  refreshed: boolean;
70
+ /**
71
+ * WHY this result is ok (BAPert 76449, additive; `api-handlers/auth/refresh` reproduces its bodies from it and `lib/token-lifecycle` reads
72
+ * `already_fresh`): 'already_fresh' = nothing was needed; 'completed_by_concurrent_request' = another request refreshed and we returned
73
+ * its result; 'refreshed' = THIS call refreshed.
74
+ */
75
+ reason: 'refreshed' | 'already_fresh' | 'completed_by_concurrent_request';
76
+ /** Whether the stored session holds a refresh token (the refresh route reports it in its body). */
77
+ hasRefreshToken: boolean;
54
78
  /**
55
79
  * CARD 320072. Is the token in THIS result actually in the session store?
56
80
  *
@@ -89,7 +113,25 @@ export type EnsureFreshResult =
89
113
  resolution?: string;
90
114
  };
91
115
 
92
- const DEFAULT_SAFETY_WINDOW_MS = 60_000;
116
+ /**
117
+ * The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
118
+ * `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
119
+ */
120
+ export function checkRefreshEnvelope(body: any, httpOk: boolean): { ok: true } | { ok: false; why: string } {
121
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, why: 'body is not an object' };
122
+ if (typeof body.success !== 'boolean') return { ok: false, why: 'success is not a boolean' };
123
+ if (body.success === true) {
124
+ if (!httpOk) return { ok: false, why: 'success:true on a non-2xx' };
125
+ if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
126
+ return { ok: false, why: 'success:true without data.access_token' };
127
+ }
128
+ } else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
129
+ return { ok: false, why: 'success:false without error.code' };
130
+ }
131
+ return { ok: true };
132
+ }
133
+
134
+ const DEFAULT_SAFETY_WINDOW_MS = REFRESH_WINDOW_MS; // was 60_000; one window for R, E and idp-fetch (BAPert 76397 decision 1)
93
135
  const DEFAULT_LOCK_WAIT_MS = 5000;
94
136
 
95
137
  function decodeJwtExp(token: string): number {
@@ -117,18 +159,33 @@ export async function ensureFreshAccessToken(
117
159
  // reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
118
160
  // collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
119
161
  const requestId = `ensure_fresh_${randomUUID()}`;
120
- const traceId = options.requestId;
162
+ // Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
163
+ const traceId = sanitizeTraceId(options.requestId);
121
164
 
122
- let session = await getSession(sessionToken);
165
+ const canonicalFirst = await getSession(sessionToken);
166
+ let session = canonicalFirst;
123
167
  if (!session || !session.idpRefreshToken) {
124
168
  const baSession = await getBetterAuthSession(sessionToken);
125
169
  if (baSession?.idpRefreshToken) {
126
170
  session = baSession;
127
171
  }
128
172
  }
173
+ // `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
174
+ // persist writes.
175
+ const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
129
176
  if (!session) {
130
177
  return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
131
178
  }
179
+ if (options.requireRefreshToken && !session.idpRefreshToken) {
180
+ return {
181
+ ok: false,
182
+ code: 'NO_REFRESH_TOKEN',
183
+ message: 'No refresh token available',
184
+ status: 401,
185
+ terminal: true,
186
+ resolution: 'User must re-authenticate',
187
+ };
188
+ }
132
189
 
133
190
  const storedToken = session.idpAccessToken;
134
191
  if (!storedToken) {
@@ -136,22 +193,28 @@ export async function ensureFreshAccessToken(
136
193
  // session with no access token and mfaVerified !== true is a user
137
194
  // mid-2FA, not dead. Only terminal once the session itself claims 2FA
138
195
  // is already done.
196
+ //
197
+ // 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.
198
+ // 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
199
+ // refresh route always did); with no refresh token it is NO_TOKEN as before.
139
200
  if (session.mfaVerified !== true) {
140
201
  return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
141
202
  }
142
- return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
203
+ if (!session.idpRefreshToken) {
204
+ return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
205
+ }
143
206
  }
144
207
 
145
208
  const now = Date.now();
146
209
  const redisExpires = session.idpAccessTokenExpires ?? 0;
147
- const jwtExpires = decodeJwtExp(storedToken);
210
+ const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
148
211
  // Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
149
212
  const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
150
213
  const msUntilExpiry = effectiveExpires - now;
151
214
 
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 };
215
+ if (storedToken && msUntilExpiry > safetyWindowMs) {
216
+ // This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
217
+ return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
155
218
  }
156
219
 
157
220
  if (!session.idpRefreshToken) {
@@ -176,7 +239,8 @@ export async function ensureFreshAccessToken(
176
239
  await new Promise(r => setTimeout(r, 200));
177
240
  const stillLocked = await checkRefreshLock(sessionToken);
178
241
  if (!stillLocked) {
179
- let after = await getSession(sessionToken);
242
+ const canonicalAfter = await getSession(sessionToken);
243
+ let after = canonicalAfter;
180
244
  if (!after || !after.idpRefreshToken) {
181
245
  const baAfter = await getBetterAuthSession(sessionToken);
182
246
  if (baAfter?.idpRefreshToken) after = baAfter;
@@ -189,8 +253,10 @@ export async function ensureFreshAccessToken(
189
253
  accessToken: after.idpAccessToken,
190
254
  accessTokenExpires: after.idpAccessTokenExpires,
191
255
  refreshed: true,
192
- // Read back from the store after the winner's refresh - durable.
193
- persisted: true,
256
+ reason: 'completed_by_concurrent_request',
257
+ hasRefreshToken: !!after.idpRefreshToken,
258
+ // Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
259
+ persisted: !!canonicalAfter && after === canonicalAfter,
194
260
  };
195
261
  }
196
262
  }
@@ -212,7 +278,8 @@ export async function ensureFreshAccessToken(
212
278
 
213
279
  try {
214
280
  // Re-check after lock — another caller may have already refreshed.
215
- let latest = await getSession(sessionToken);
281
+ const canonicalLatest = await getSession(sessionToken);
282
+ let latest = canonicalLatest;
216
283
  if (!latest || !latest.idpRefreshToken) {
217
284
  const baLatest = await getBetterAuthSession(sessionToken);
218
285
  if (baLatest?.idpRefreshToken) latest = baLatest;
@@ -227,12 +294,54 @@ export async function ensureFreshAccessToken(
227
294
  accessToken: latest.idpAccessToken,
228
295
  accessTokenExpires: latest.idpAccessTokenExpires,
229
296
  refreshed: true,
230
- // Post-lock re-read: another caller already refreshed and stored this.
231
- persisted: true,
297
+ reason: 'completed_by_concurrent_request',
298
+ hasRefreshToken: !!latest.idpRefreshToken,
299
+ // Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
300
+ persisted: !!canonicalLatest && latest === canonicalLatest,
232
301
  };
233
302
  }
234
303
  }
235
304
 
305
+ // 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
306
+ // one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
307
+ // marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
308
+ if (!latest) {
309
+ return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
310
+ }
311
+ if (!latest.idpRefreshToken) {
312
+ return {
313
+ ok: false,
314
+ code: 'NO_REFRESH_TOKEN',
315
+ message: 'No refresh token available',
316
+ status: 401,
317
+ terminal: true,
318
+ resolution: 'User must re-authenticate',
319
+ };
320
+ }
321
+
322
+ // BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
323
+ // absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
324
+ // nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
325
+ // refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
326
+ // re-established.
327
+ if (!canonicalLatest) {
328
+ console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
329
+ requestId,
330
+ traceId,
331
+ betterAuthOnly: !!latest,
332
+ });
333
+ return {
334
+ ok: false,
335
+ code: 'NO_CANONICAL_SESSION',
336
+ message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
337
+ status: 401,
338
+ terminal: true,
339
+ retryable: false,
340
+ discardToken: false,
341
+ resolution: 'REAUTHENTICATE',
342
+ };
343
+ }
344
+
236
345
  // Build refresh request body — wire shape must match what the IDP expects.
237
346
  let authMethods: string[] = [];
238
347
  if (Array.isArray(session.authenticationMethods)) {
@@ -262,6 +371,27 @@ export async function ensureFreshAccessToken(
262
371
  // Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
263
372
  // the identity context of THIS request, unchanged by a token rotation.
264
373
  const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
374
+
375
+ // 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
376
+ // replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
377
+ if (isRefreshInDoubt(canonicalLatest, sentRefreshToken)) {
378
+ console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
379
+ requestId,
380
+ traceId,
381
+ inDoubtSince: canonicalLatest.refreshInDoubtAt,
382
+ });
383
+ return {
384
+ ok: false,
385
+ code: REFRESH_OUTCOME_UNKNOWN_CODE,
386
+ message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
387
+ status: 401,
388
+ terminal: true,
389
+ retryable: false,
390
+ discardToken: false,
391
+ resolution: 'REAUTHENTICATE',
392
+ };
393
+ }
394
+
265
395
  const body: Record<string, unknown> = {
266
396
  refresh_token: sentRefreshToken,
267
397
  amr: authMethods,
@@ -271,18 +401,45 @@ export async function ensureFreshAccessToken(
271
401
  if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
272
402
  if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
273
403
 
404
+ // BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
405
+ // replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
406
+ const answerTimedOut = async (cause?: unknown): Promise<EnsureFreshResult> => {
407
+ 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', {
408
+ requestId,
409
+ traceId,
410
+ timeoutMs: REFRESH_FETCH_TIMEOUT_MS,
411
+ cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
412
+ });
413
+ try {
414
+ await updateSession(sessionToken, inDoubtPatch(sentRefreshToken as string));
415
+ } catch (markError) {
416
+ console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: errForLog(markError) });
417
+ }
418
+ return {
419
+ ok: false,
420
+ code: UPSTREAM_TIMEOUT_CODE,
421
+ message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
422
+ status: 504,
423
+ terminal: false,
424
+ retryable: false,
425
+ discardToken: false,
426
+ };
427
+ };
428
+
274
429
  let idpResponse: Response;
275
430
  try {
276
431
  idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
277
432
  method: 'POST',
278
433
  headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
279
434
  body: JSON.stringify(body),
435
+ signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
280
436
  });
281
437
  } catch (err) {
438
+ if (!requestNeverLeft(err)) return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
282
439
  return {
283
440
  ok: false,
284
441
  code: 'UPSTREAM_SERVICE_UNAVAILABLE',
285
- message: err instanceof Error ? err.message : 'IDP unreachable',
442
+ message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
286
443
  status: 503,
287
444
  retryable: true,
288
445
  };
@@ -292,11 +449,30 @@ export async function ensureFreshAccessToken(
292
449
  try {
293
450
  const text = await idpResponse.text();
294
451
  if (!text.trim()) {
452
+ if (idpResponse.ok) return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
295
453
  return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
296
454
  }
297
455
  responseData = JSON.parse(text);
298
456
  } catch (err) {
299
- return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid JSON from IDP', status: 502, retryable: true };
457
+ 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.
458
+ return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
459
+ }
460
+
461
+ // BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
462
+ // POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
463
+ // message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
464
+ // answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
465
+ const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
466
+ if (!envelope.ok) {
467
+ console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
468
+ requestId,
469
+ traceId,
470
+ // the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
471
+ idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
472
+ status: idpResponse.status,
473
+ why: envelope.why,
474
+ });
475
+ return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
300
476
  }
301
477
 
302
478
  if (!idpResponse.ok) {
@@ -360,7 +536,9 @@ export async function ensureFreshAccessToken(
360
536
  accessToken: postRefresh.idpAccessToken,
361
537
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
362
538
  refreshed: true,
363
- // `postRefresh` is a store read, so this token is by definition stored.
539
+ reason: 'completed_by_concurrent_request',
540
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
541
+ // `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
364
542
  persisted: true,
365
543
  };
366
544
  }
@@ -424,6 +602,8 @@ export async function ensureFreshAccessToken(
424
602
  accessToken: postRefresh.idpAccessToken,
425
603
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
426
604
  refreshed: true,
605
+ reason: 'completed_by_concurrent_request',
606
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
427
607
  persisted: true,
428
608
  };
429
609
  }
@@ -471,12 +651,12 @@ export async function ensureFreshAccessToken(
471
651
  try {
472
652
  amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
473
653
  } catch {
474
- amrClaims = session.authenticationMethods || [];
654
+ amrClaims = canonicalLatest.authenticationMethods || [];
475
655
  }
476
656
  } else {
477
- amrClaims = session.authenticationMethods || [];
657
+ amrClaims = canonicalLatest.authenticationMethods || [];
478
658
  }
479
- const acrLevel = String(decoded?.acr || session.authenticationLevel || '1');
659
+ const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
480
660
  const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
481
661
  const newKid = extractKidFromToken(newAccess);
482
662
 
@@ -487,14 +667,19 @@ export async function ensureFreshAccessToken(
487
667
  // report `ok:true, refreshed:true` for a refresh it had just thrown away.
488
668
  // The full rationale for what we DO about it is at the `if (!persisted)`
489
669
  // block below; this line only makes the answer available.
670
+ //
671
+ // BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
672
+ // pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
673
+ // pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
674
+ // spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
675
+ // concurrent writer's change to anything else survives.
490
676
  const persisted = await updateSession(sessionToken, {
491
- ...session,
492
677
  idpAccessToken: newAccess,
493
678
  idpAccessTokenExpires: accessTokenExpires,
494
- idpRefreshToken: hasNewRefresh ? newRefresh : session.idpRefreshToken,
495
- idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : session.idpRefreshTokenExpires,
679
+ idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
680
+ idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
496
681
  decodedAccessToken: decoded,
497
- bearerKeyId: newKid || session.bearerKeyId,
682
+ bearerKeyId: newKid || canonicalLatest.bearerKeyId,
498
683
  authenticationMethods: amrClaims,
499
684
  authenticationLevel: acrLevel,
500
685
  // 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
@@ -502,9 +687,9 @@ export async function ensureFreshAccessToken(
502
687
  // rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
503
688
  // the same defect duplicated at a second call site.
504
689
  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,
690
+ mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
691
+ mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
692
+ mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
508
693
  });
509
694
 
510
695
  /**
@@ -572,7 +757,7 @@ export async function ensureFreshAccessToken(
572
757
  // the only way out for a dropped write, so by construction anything reaching
573
758
  // this line WAS written. Keeping the field keeps the ok:true branch of
574
759
  // EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
575
- return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
760
+ return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
576
761
  } finally {
577
762
  if (weHoldLock) {
578
763
  await releaseRefreshLock(sessionToken, requestId, releaseVersion);
@@ -1,3 +1,4 @@
1
+ import { errForLog } from './log-safe';
1
2
  /**
2
3
  * =============================================================================
3
4
  * GEOLOCATION UTILITY
@@ -103,7 +104,7 @@ export async function getPublicIP(): Promise<string | null> {
103
104
  // Validate it looks like an IP
104
105
  if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
105
106
  cachedPublicIP = { ip, timestamp: Date.now() };
106
- console.log(`[geolocation] Public IP resolved: ${ip}`);
107
+ console.log("[geolocation] Public IP resolved");
107
108
  return ip;
108
109
  }
109
110
  }
@@ -163,7 +164,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
163
164
  if (resolvePublicIP) {
164
165
  const publicIP = await getPublicIP();
165
166
  if (publicIP && !isPrivateIP(publicIP)) {
166
- console.log(`[geolocation] Private IP ${ip} -> using public IP ${publicIP}`);
167
+ console.log("[geolocation] Private IP -> using the resolved public IP");
167
168
  // Recursively call with public IP (but don't resolve again to prevent loops)
168
169
  return getLocationFromIP(publicIP, false);
169
170
  }
@@ -201,7 +202,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
201
202
  const data = await response.json();
202
203
 
203
204
  if (data.status !== 'success') {
204
- console.warn(`[geolocation] Lookup failed for ${ip}: ${data.message}`);
205
+ console.warn("[geolocation] Lookup failed (upstream refused)");
205
206
  // Cache the failure to avoid repeated lookups
206
207
  geoCache.set(ip, { location: null, timestamp: Date.now() });
207
208
  return null;
@@ -224,7 +225,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
224
225
  return location;
225
226
 
226
227
  } catch (error) {
227
- console.error(`[geolocation] Error looking up ${ip}:`, error);
228
+ console.error("[geolocation] lookup failed:", errForLog(error)); // no IP on a routine line (BAPert 76553 Q1)
228
229
  return null;
229
230
  }
230
231
  }
@@ -43,6 +43,7 @@
43
43
  import 'server-only';
44
44
  import { createHash, randomUUID } from 'crypto';
45
45
  import { markNeverInResponse } from './never-in-response';
46
+ import { errForLog } from './log-safe';
46
47
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
47
48
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
48
49
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -360,7 +361,7 @@ async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
360
361
 
361
362
  return parsed.config;
362
363
  } catch (error) {
363
- console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
364
+ console.warn('[IDP_CONFIG] Failed to read from Redis cache:', errForLog(error));
364
365
  return null;
365
366
  }
366
367
  }
@@ -377,7 +378,7 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
377
378
  // Store with TTL slightly longer than the logical expiry to allow for clock skew
378
379
  await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
379
380
  } catch (error) {
380
- console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
381
+ console.warn('[IDP_CONFIG] Failed to write to Redis cache:', errForLog(error));
381
382
  }
382
383
  }
383
384
 
@@ -633,7 +634,7 @@ export async function clearConfigRedisCache(): Promise<void> {
633
634
  await (await getRedis()).del(key);
634
635
  console.log('[IDP_CONFIG] Redis cache cleared:', key);
635
636
  } catch (error) {
636
- console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
637
+ console.warn('[IDP_CONFIG] Failed to clear Redis cache:', errForLog(error));
637
638
  }
638
639
  }
639
640
 
@@ -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
@@ -45,6 +46,14 @@ export interface IdpFetchOptions {
45
46
  * `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
46
47
  */
47
48
  initFor?: (sessionToken: string) => Promise<RequestInit | null>;
49
+ /**
50
+ * 4.12: the refresh that follows a 401/403 is done by THIS function instead of the HTTP self-call to /api/auth/refresh. Sign-out supplies one that
51
+ * calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
52
+ * invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
53
+ * the grant is live) can say which. No header on the public refresh route, so nothing a browser sends can force a refresh (BAPert 77022).
54
+ * Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
55
+ */
56
+ refreshOnAuthFailure?: (sessionToken: string) => Promise<{ ok: boolean; status: number }>;
48
57
  }
49
58
 
50
59
  function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
@@ -119,8 +128,8 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
119
128
  const hasRefreshToken = !!session.idpRefreshToken;
120
129
  const now = Date.now();
121
130
  const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
122
- // Treat tokens as effectively expired if within 5 minutes of expiry
123
- if (timeLeft > 5 * 60 * 1000) {
131
+ // Treat tokens as effectively expired if within REFRESH_WINDOW_MS of expiry (lib/refresh-window: the one window)
132
+ if (timeLeft > REFRESH_WINDOW_MS) {
124
133
  return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
125
134
  }
126
135
  // Only attempt refresh when we actually have a refresh token to use.
@@ -183,7 +192,9 @@ export async function idpFetchJSON<T = any>(
183
192
  attemptedRefresh = true;
184
193
  try {
185
194
  // Use centralized internal API helper for server-to-server calls
186
- const rf = await internalRefresh(req.headers.get('cookie') || '', sessionToken);
195
+ const rf = opts.refreshOnAuthFailure
196
+ ? await opts.refreshOnAuthFailure(sessionToken)
197
+ : await internalRefresh(req.headers.get('cookie') || '', sessionToken);
187
198
  refreshStatus = rf.status;
188
199
  if (rf.ok) {
189
200
  const fresh = await getSession(sessionToken);
@@ -26,6 +26,19 @@ import { ENV_CONFIG } from '../config/env';
26
26
  */
27
27
  export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
28
28
 
29
+ /**
30
+ * The revoke could not be confirmed. `grantLive` is true ONLY when the IdP itself showed the grant is alive: the forced refresh SUCCEEDED (it issued
31
+ * new tokens) and the logout still did not end it. Every other unknown (a transport error, a 5xx, a refresh that threw or answered something
32
+ * else) leaves it false: the grant may be live or dead, and the log must not claim either (BAPert 77013). The closed `code` is what errForLog prints.
33
+ */
34
+ export class IdpLogoutUnconfirmedError extends Error {
35
+ readonly code = 'IDP_LOGOUT_UNCONFIRMED';
36
+ constructor(status: number, readonly grantLive: boolean) {
37
+ super(`IdP logout answered ${status}`);
38
+ this.name = 'IdpLogoutUnconfirmedError';
39
+ }
40
+ }
41
+
29
42
  export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome> {
30
43
  let resolvedSession: string | undefined;
31
44
  const result = await idpFetchJSON(
@@ -33,6 +46,9 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
33
46
  `${ENV_CONFIG.IDP_URL}/api/Account/logout`,
34
47
  {},
35
48
  {
49
+ // A logout 401 with a locally-fresh token must reach a REAL refresh, so the IdP's answer can prove the grant dead (BAPert 77013). Done
50
+ // in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
51
+ refreshOnAuthFailure: forceRefreshAtIdp,
36
52
  initFor: async (token) => {
37
53
  resolvedSession = token;
38
54
  let session = await getSession(token);
@@ -50,7 +66,26 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
50
66
  if (result.skipped) return 'nothing-to-revoke';
51
67
  if (result.ok) return 'revoked';
52
68
  if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession))) return 'grant-already-dead';
53
- throw new Error(`IdP logout answered ${result.status}`);
69
+ const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
70
+ throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
71
+ }
72
+
73
+ /**
74
+ * Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
75
+ * implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
76
+ * (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
77
+ * NO_SESSION / NO_REFRESH_TOKEN, 409 for a lost lock, otherwise E's own status (a discarded IdP 401 is 401 with the marker written).
78
+ * Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
79
+ */
80
+ async function forceRefreshAtIdp(sessionToken: string): Promise<{ ok: boolean; status: number }> {
81
+ const idpBaseUrl = process.env.IDP_URL;
82
+ const clientId = process.env.CLIENT_ID;
83
+ if (!idpBaseUrl || !clientId) throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
84
+ const { ensureFreshAccessToken } = await import('./ensure-fresh-access-token');
85
+ const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
86
+ if (r.ok) return { ok: true, status: 200 };
87
+ const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
88
+ return { ok: false, status };
54
89
  }
55
90
 
56
91
  /**
@@ -3,6 +3,8 @@
3
3
  * This is a lightweight version for browser usage
4
4
  */
5
5
 
6
+ import { errForLog } from './log-safe';
7
+
6
8
  // Decode base64url
7
9
  function base64urlDecode(base64url: string): string {
8
10
  try {
@@ -39,7 +41,7 @@ export function jwtDecode<T = any>(token: string): T | null {
39
41
 
40
42
  return parsedPayload;
41
43
  } catch (e) {
42
- console.error('[JWT] Decode failed:', e instanceof Error ? e.message : 'Unknown error');
44
+ console.error('[JWT] Decode failed:', errForLog(e));
43
45
  return null;
44
46
  }
45
47
  }
@@ -1,4 +1,5 @@
1
1
  import { jwtDecode as originalJwtDecode } from 'jwt-decode';
2
+ import { errForLog } from './log-safe';
2
3
 
3
4
  // Define JwtPayload interface since jwt-decode v4 doesn't export it
4
5
  export interface JwtPayload {
@@ -65,7 +66,7 @@ export function decodeJwtHeader(token: string): JwtHeader | null {
65
66
 
66
67
  return JSON.parse(headerJson) as JwtHeader;
67
68
  } catch (error) {
68
- console.error('[JWT_DECODE] Failed to decode JWT header:', error);
69
+ console.error('[JWT_DECODE] Failed to decode JWT header:', errForLog(error));
69
70
  return null;
70
71
  }
71
72
  }