@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
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkRefreshEnvelope = checkRefreshEnvelope;
3
4
  exports.ensureFreshAccessToken = ensureFreshAccessToken;
4
5
  /**
5
6
  * ensureFreshAccessToken — server-side preflight refresh.
@@ -21,7 +22,32 @@ const session_store_1 = require("./session-store");
21
22
  const token_expiry_1 = require("./token-expiry");
22
23
  const token_utils_1 = require("../auth/utils/token-utils");
23
24
  const refresh_rotation_1 = require("./refresh-rotation");
24
- const DEFAULT_SAFETY_WINDOW_MS = 60_000;
25
+ const refresh_window_1 = require("./refresh-window");
26
+ const safe_trace_id_1 = require("./safe-trace-id");
27
+ const refresh_in_doubt_1 = require("./refresh-in-doubt");
28
+ const log_safe_1 = require("./log-safe");
29
+ /**
30
+ * The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
31
+ * `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
32
+ */
33
+ function checkRefreshEnvelope(body, httpOk) {
34
+ if (!body || typeof body !== 'object' || Array.isArray(body))
35
+ return { ok: false, why: 'body is not an object' };
36
+ if (typeof body.success !== 'boolean')
37
+ return { ok: false, why: 'success is not a boolean' };
38
+ if (body.success === true) {
39
+ if (!httpOk)
40
+ return { ok: false, why: 'success:true on a non-2xx' };
41
+ if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
42
+ return { ok: false, why: 'success:true without data.access_token' };
43
+ }
44
+ }
45
+ else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
46
+ return { ok: false, why: 'success:false without error.code' };
47
+ }
48
+ return { ok: true };
49
+ }
50
+ const DEFAULT_SAFETY_WINDOW_MS = refresh_window_1.REFRESH_WINDOW_MS; // was 60_000; one window for R, E and idp-fetch (BAPert 76397 decision 1)
25
51
  const DEFAULT_LOCK_WAIT_MS = 5000;
26
52
  function decodeJwtExp(token) {
27
53
  const parts = token.split('.');
@@ -45,37 +71,58 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
45
71
  // reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
46
72
  // collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
47
73
  const requestId = `ensure_fresh_${(0, crypto_1.randomUUID)()}`;
48
- const traceId = options.requestId;
49
- let session = await (0, session_store_1.getSession)(sessionToken);
74
+ // Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
75
+ const traceId = (0, safe_trace_id_1.sanitizeTraceId)(options.requestId);
76
+ const canonicalFirst = await (0, session_store_1.getSession)(sessionToken);
77
+ let session = canonicalFirst;
50
78
  if (!session || !session.idpRefreshToken) {
51
79
  const baSession = await (0, session_store_1.getBetterAuthSession)(sessionToken);
52
80
  if (baSession?.idpRefreshToken) {
53
81
  session = baSession;
54
82
  }
55
83
  }
84
+ // `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
85
+ // persist writes.
86
+ const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
56
87
  if (!session) {
57
88
  return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
58
89
  }
90
+ if (options.requireRefreshToken && !session.idpRefreshToken) {
91
+ return {
92
+ ok: false,
93
+ code: 'NO_REFRESH_TOKEN',
94
+ message: 'No refresh token available',
95
+ status: 401,
96
+ terminal: true,
97
+ resolution: 'User must re-authenticate',
98
+ };
99
+ }
59
100
  const storedToken = session.idpAccessToken;
60
101
  if (!storedToken) {
61
102
  // Card 187871: same conflation as getIdpToken (server/auth.ts) - a live
62
103
  // session with no access token and mfaVerified !== true is a user
63
104
  // mid-2FA, not dead. Only terminal once the session itself claims 2FA
64
105
  // is already done.
106
+ //
107
+ // 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.
108
+ // 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
109
+ // refresh route always did); with no refresh token it is NO_TOKEN as before.
65
110
  if (session.mfaVerified !== true) {
66
111
  return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
67
112
  }
68
- return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
113
+ if (!session.idpRefreshToken) {
114
+ return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
115
+ }
69
116
  }
70
117
  const now = Date.now();
71
118
  const redisExpires = session.idpAccessTokenExpires ?? 0;
72
- const jwtExpires = decodeJwtExp(storedToken);
119
+ const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
73
120
  // Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
74
121
  const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
75
122
  const msUntilExpiry = effectiveExpires - now;
76
- if (msUntilExpiry > safetyWindowMs) {
77
- // Durable by construction: this token came OUT of the store and we wrote nothing.
78
- return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
123
+ if (storedToken && msUntilExpiry > safetyWindowMs) {
124
+ // This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
125
+ return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
79
126
  }
80
127
  if (!session.idpRefreshToken) {
81
128
  return {
@@ -97,7 +144,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
97
144
  await new Promise(r => setTimeout(r, 200));
98
145
  const stillLocked = await (0, session_store_1.checkRefreshLock)(sessionToken);
99
146
  if (!stillLocked) {
100
- let after = await (0, session_store_1.getSession)(sessionToken);
147
+ const canonicalAfter = await (0, session_store_1.getSession)(sessionToken);
148
+ let after = canonicalAfter;
101
149
  if (!after || !after.idpRefreshToken) {
102
150
  const baAfter = await (0, session_store_1.getBetterAuthSession)(sessionToken);
103
151
  if (baAfter?.idpRefreshToken)
@@ -111,8 +159,10 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
111
159
  accessToken: after.idpAccessToken,
112
160
  accessTokenExpires: after.idpAccessTokenExpires,
113
161
  refreshed: true,
114
- // Read back from the store after the winner's refresh - durable.
115
- persisted: true,
162
+ reason: 'completed_by_concurrent_request',
163
+ hasRefreshToken: !!after.idpRefreshToken,
164
+ // Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
165
+ persisted: !!canonicalAfter && after === canonicalAfter,
116
166
  };
117
167
  }
118
168
  }
@@ -134,7 +184,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
134
184
  }
135
185
  try {
136
186
  // Re-check after lock — another caller may have already refreshed.
137
- let latest = await (0, session_store_1.getSession)(sessionToken);
187
+ const canonicalLatest = await (0, session_store_1.getSession)(sessionToken);
188
+ let latest = canonicalLatest;
138
189
  if (!latest || !latest.idpRefreshToken) {
139
190
  const baLatest = await (0, session_store_1.getBetterAuthSession)(sessionToken);
140
191
  if (baLatest?.idpRefreshToken)
@@ -150,11 +201,51 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
150
201
  accessToken: latest.idpAccessToken,
151
202
  accessTokenExpires: latest.idpAccessTokenExpires,
152
203
  refreshed: true,
153
- // Post-lock re-read: another caller already refreshed and stored this.
154
- persisted: true,
204
+ reason: 'completed_by_concurrent_request',
205
+ hasRefreshToken: !!latest.idpRefreshToken,
206
+ // Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
207
+ persisted: !!canonicalLatest && latest === canonicalLatest,
155
208
  };
156
209
  }
157
210
  }
211
+ // 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
212
+ // one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
213
+ // marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
214
+ if (!latest) {
215
+ return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
216
+ }
217
+ if (!latest.idpRefreshToken) {
218
+ return {
219
+ ok: false,
220
+ code: 'NO_REFRESH_TOKEN',
221
+ message: 'No refresh token available',
222
+ status: 401,
223
+ terminal: true,
224
+ resolution: 'User must re-authenticate',
225
+ };
226
+ }
227
+ // BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
228
+ // absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
229
+ // nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
230
+ // refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
231
+ // re-established.
232
+ if (!canonicalLatest) {
233
+ console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
234
+ requestId,
235
+ traceId,
236
+ betterAuthOnly: !!latest,
237
+ });
238
+ return {
239
+ ok: false,
240
+ code: 'NO_CANONICAL_SESSION',
241
+ message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
242
+ status: 401,
243
+ terminal: true,
244
+ retryable: false,
245
+ discardToken: false,
246
+ resolution: 'REAUTHENTICATE',
247
+ };
248
+ }
158
249
  // Build refresh request body — wire shape must match what the IDP expects.
159
250
  let authMethods = [];
160
251
  if (Array.isArray(session.authenticationMethods)) {
@@ -185,6 +276,25 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
185
276
  // Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
186
277
  // the identity context of THIS request, unchanged by a token rotation.
187
278
  const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
279
+ // 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
280
+ // replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
281
+ if ((0, refresh_in_doubt_1.isRefreshInDoubt)(canonicalLatest, sentRefreshToken)) {
282
+ console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
283
+ requestId,
284
+ traceId,
285
+ inDoubtSince: canonicalLatest.refreshInDoubtAt,
286
+ });
287
+ return {
288
+ ok: false,
289
+ code: refresh_in_doubt_1.REFRESH_OUTCOME_UNKNOWN_CODE,
290
+ message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
291
+ status: 401,
292
+ terminal: true,
293
+ retryable: false,
294
+ discardToken: false,
295
+ resolution: 'REAUTHENTICATE',
296
+ };
297
+ }
188
298
  const body = {
189
299
  refresh_token: sentRefreshToken,
190
300
  amr: authMethods,
@@ -196,19 +306,47 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
196
306
  body.two_factor_method = twoFactorMethod;
197
307
  if (session.mfaCompletedAt)
198
308
  body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
309
+ // BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
310
+ // replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
311
+ const answerTimedOut = async (cause) => {
312
+ 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', {
313
+ requestId,
314
+ traceId,
315
+ timeoutMs: refresh_in_doubt_1.REFRESH_FETCH_TIMEOUT_MS,
316
+ cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
317
+ });
318
+ try {
319
+ await (0, session_store_1.updateSession)(sessionToken, (0, refresh_in_doubt_1.inDoubtPatch)(sentRefreshToken));
320
+ }
321
+ catch (markError) {
322
+ console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: (0, log_safe_1.errForLog)(markError) });
323
+ }
324
+ return {
325
+ ok: false,
326
+ code: refresh_in_doubt_1.UPSTREAM_TIMEOUT_CODE,
327
+ message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
328
+ status: 504,
329
+ terminal: false,
330
+ retryable: false,
331
+ discardToken: false,
332
+ };
333
+ };
199
334
  let idpResponse;
200
335
  try {
201
336
  idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
202
337
  method: 'POST',
203
338
  headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
204
339
  body: JSON.stringify(body),
340
+ signal: AbortSignal.timeout(refresh_in_doubt_1.REFRESH_FETCH_TIMEOUT_MS),
205
341
  });
206
342
  }
207
343
  catch (err) {
344
+ if (!(0, refresh_in_doubt_1.requestNeverLeft)(err))
345
+ return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
208
346
  return {
209
347
  ok: false,
210
348
  code: 'UPSTREAM_SERVICE_UNAVAILABLE',
211
- message: err instanceof Error ? err.message : 'IDP unreachable',
349
+ message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
212
350
  status: 503,
213
351
  retryable: true,
214
352
  };
@@ -217,12 +355,32 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
217
355
  try {
218
356
  const text = await idpResponse.text();
219
357
  if (!text.trim()) {
358
+ if (idpResponse.ok)
359
+ return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
220
360
  return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
221
361
  }
222
362
  responseData = JSON.parse(text);
223
363
  }
224
364
  catch (err) {
225
- return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid JSON from IDP', status: 502, retryable: true };
365
+ if (!(err instanceof SyntaxError) || idpResponse.ok)
366
+ 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.
367
+ return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
368
+ }
369
+ // BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
370
+ // POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
371
+ // message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
372
+ // answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
373
+ const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
374
+ if (!envelope.ok) {
375
+ console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
376
+ requestId,
377
+ traceId,
378
+ // the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
379
+ idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
380
+ status: idpResponse.status,
381
+ why: envelope.why,
382
+ });
383
+ return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
226
384
  }
227
385
  if (!idpResponse.ok) {
228
386
  const idpError = responseData?.error || {};
@@ -285,7 +443,9 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
285
443
  accessToken: postRefresh.idpAccessToken,
286
444
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
287
445
  refreshed: true,
288
- // `postRefresh` is a store read, so this token is by definition stored.
446
+ reason: 'completed_by_concurrent_request',
447
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
448
+ // `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
289
449
  persisted: true,
290
450
  };
291
451
  }
@@ -348,6 +508,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
348
508
  accessToken: postRefresh.idpAccessToken,
349
509
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
350
510
  refreshed: true,
511
+ reason: 'completed_by_concurrent_request',
512
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
351
513
  persisted: true,
352
514
  };
353
515
  }
@@ -391,13 +553,13 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
391
553
  amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
392
554
  }
393
555
  catch {
394
- amrClaims = session.authenticationMethods || [];
556
+ amrClaims = canonicalLatest.authenticationMethods || [];
395
557
  }
396
558
  }
397
559
  else {
398
- amrClaims = session.authenticationMethods || [];
560
+ amrClaims = canonicalLatest.authenticationMethods || [];
399
561
  }
400
- const acrLevel = String(decoded?.acr || session.authenticationLevel || '1');
562
+ const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
401
563
  const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
402
564
  const newKid = (0, token_utils_1.extractKidFromToken)(newAccess);
403
565
  // *** CARD 320072. CAPTURE THIS RETURN. ***
@@ -407,14 +569,19 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
407
569
  // report `ok:true, refreshed:true` for a refresh it had just thrown away.
408
570
  // The full rationale for what we DO about it is at the `if (!persisted)`
409
571
  // block below; this line only makes the answer available.
572
+ //
573
+ // BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
574
+ // pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
575
+ // pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
576
+ // spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
577
+ // concurrent writer's change to anything else survives.
410
578
  const persisted = await (0, session_store_1.updateSession)(sessionToken, {
411
- ...session,
412
579
  idpAccessToken: newAccess,
413
580
  idpAccessTokenExpires: accessTokenExpires,
414
- idpRefreshToken: hasNewRefresh ? newRefresh : session.idpRefreshToken,
415
- idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : session.idpRefreshTokenExpires,
581
+ idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
582
+ idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
416
583
  decodedAccessToken: decoded,
417
- bearerKeyId: newKid || session.bearerKeyId,
584
+ bearerKeyId: newKid || canonicalLatest.bearerKeyId,
418
585
  authenticationMethods: amrClaims,
419
586
  authenticationLevel: acrLevel,
420
587
  // 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
@@ -422,9 +589,9 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
422
589
  // rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
423
590
  // the same defect duplicated at a second call site.
424
591
  mfaVerified: amrClaims.includes('mfa'),
425
- mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : session.mfaCompletedAt,
426
- mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : session.mfaExpiresAt,
427
- mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
592
+ mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
593
+ mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
594
+ mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
428
595
  });
429
596
  /**
430
597
  * 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
@@ -489,7 +656,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
489
656
  // the only way out for a dropped write, so by construction anything reaching
490
657
  // this line WAS written. Keeping the field keeps the ok:true branch of
491
658
  // EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
492
- return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
659
+ return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
493
660
  }
494
661
  finally {
495
662
  if (weHoldLock) {
@@ -1,22 +1,4 @@
1
1
  "use strict";
2
- /**
3
- * =============================================================================
4
- * GEOLOCATION UTILITY
5
- * =============================================================================
6
- *
7
- * Server-side utility for resolving IP addresses to geographic locations.
8
- * Uses ip-api.com (free tier: 45 requests/minute).
9
- *
10
- * USAGE:
11
- * ------
12
- * import { getLocationFromIP, extractClientIP } from '@payez/next-mvp/lib/geolocation';
13
- *
14
- * const ip = extractClientIP(request.headers);
15
- * const location = await getLocationFromIP(ip);
16
- * console.log(location?.city); // 'Mountain View'
17
- *
18
- * =============================================================================
19
- */
20
2
  Object.defineProperty(exports, "__esModule", { value: true });
21
3
  exports.isPrivateIP = isPrivateIP;
22
4
  exports.getPublicIP = getPublicIP;
@@ -25,6 +7,7 @@ exports.getLocationFromIP = getLocationFromIP;
25
7
  exports.formatLocation = formatLocation;
26
8
  exports.getCountryFlag = getCountryFlag;
27
9
  exports.clearGeoCache = clearGeoCache;
10
+ const log_safe_1 = require("./log-safe");
28
11
  // -----------------------------------------------------------------------------
29
12
  // CACHE CONFIGURATION
30
13
  // -----------------------------------------------------------------------------
@@ -90,7 +73,7 @@ async function getPublicIP() {
90
73
  // Validate it looks like an IP
91
74
  if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
92
75
  cachedPublicIP = { ip, timestamp: Date.now() };
93
- console.log(`[geolocation] Public IP resolved: ${ip}`);
76
+ console.log("[geolocation] Public IP resolved");
94
77
  return ip;
95
78
  }
96
79
  }
@@ -147,7 +130,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
147
130
  if (resolvePublicIP) {
148
131
  const publicIP = await getPublicIP();
149
132
  if (publicIP && !isPrivateIP(publicIP)) {
150
- console.log(`[geolocation] Private IP ${ip} -> using public IP ${publicIP}`);
133
+ console.log("[geolocation] Private IP -> using the resolved public IP");
151
134
  // Recursively call with public IP (but don't resolve again to prevent loops)
152
135
  return getLocationFromIP(publicIP, false);
153
136
  }
@@ -176,7 +159,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
176
159
  }
177
160
  const data = await response.json();
178
161
  if (data.status !== 'success') {
179
- console.warn(`[geolocation] Lookup failed for ${ip}: ${data.message}`);
162
+ console.warn("[geolocation] Lookup failed (upstream refused)");
180
163
  // Cache the failure to avoid repeated lookups
181
164
  geoCache.set(ip, { location: null, timestamp: Date.now() });
182
165
  return null;
@@ -196,7 +179,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
196
179
  return location;
197
180
  }
198
181
  catch (error) {
199
- console.error(`[geolocation] Error looking up ${ip}:`, error);
182
+ console.error("[geolocation] lookup failed:", (0, log_safe_1.errForLog)(error)); // no IP on a routine line (BAPert 76553 Q1)
200
183
  return null;
201
184
  }
202
185
  }
@@ -82,6 +82,7 @@ exports.getEnabledProviders = getEnabledProviders;
82
82
  require("server-only");
83
83
  const crypto_1 = require("crypto");
84
84
  const never_in_response_1 = require("./never-in-response");
85
+ const log_safe_1 = require("./log-safe");
85
86
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
86
87
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
87
88
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -276,7 +277,7 @@ async function getConfigFromRedis() {
276
277
  return parsed.config;
277
278
  }
278
279
  catch (error) {
279
- console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
280
+ console.warn('[IDP_CONFIG] Failed to read from Redis cache:', (0, log_safe_1.errForLog)(error));
280
281
  return null;
281
282
  }
282
283
  }
@@ -293,7 +294,7 @@ async function setConfigInRedis(config) {
293
294
  await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
294
295
  }
295
296
  catch (error) {
296
- console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
297
+ console.warn('[IDP_CONFIG] Failed to write to Redis cache:', (0, log_safe_1.errForLog)(error));
297
298
  }
298
299
  }
299
300
  // ============================================================================
@@ -529,7 +530,7 @@ async function clearConfigRedisCache() {
529
530
  console.log('[IDP_CONFIG] Redis cache cleared:', key);
530
531
  }
531
532
  catch (error) {
532
- console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
533
+ console.warn('[IDP_CONFIG] Failed to clear Redis cache:', (0, log_safe_1.errForLog)(error));
533
534
  }
534
535
  }
535
536
  /**
@@ -35,5 +35,16 @@ export interface IdpFetchOptions {
35
35
  * `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
36
36
  */
37
37
  initFor?: (sessionToken: string) => Promise<RequestInit | null>;
38
+ /**
39
+ * 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
40
+ * calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
41
+ * invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
42
+ * 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).
43
+ * Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
44
+ */
45
+ refreshOnAuthFailure?: (sessionToken: string) => Promise<{
46
+ ok: boolean;
47
+ status: number;
48
+ }>;
38
49
  }
39
50
  export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit, opts?: IdpFetchOptions): Promise<IdpFetchResult<T>>;
@@ -42,6 +42,7 @@ const idp_url_guard_1 = require("./idp-url-guard");
42
42
  Object.defineProperty(exports, "assertAbsoluteIdpUrl", { enumerable: true, get: function () { return idp_url_guard_1.assertAbsoluteIdpUrl; } });
43
43
  const retry_after_1 = require("./retry-after");
44
44
  Object.defineProperty(exports, "parseRetryAfterSeconds", { enumerable: true, get: function () { return retry_after_1.parseRetryAfterSeconds; } });
45
+ const refresh_window_1 = require("./refresh-window");
45
46
  function buildHeaders(req, bearer, extra) {
46
47
  const headers = {
47
48
  Accept: 'application/json',
@@ -118,8 +119,8 @@ async function ensureFreshAccessToken(req) {
118
119
  const hasRefreshToken = !!session.idpRefreshToken;
119
120
  const now = Date.now();
120
121
  const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
121
- // Treat tokens as effectively expired if within 5 minutes of expiry
122
- if (timeLeft > 5 * 60 * 1000) {
122
+ // Treat tokens as effectively expired if within REFRESH_WINDOW_MS of expiry (lib/refresh-window: the one window)
123
+ if (timeLeft > refresh_window_1.REFRESH_WINDOW_MS) {
123
124
  return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
124
125
  }
125
126
  // Only attempt refresh when we actually have a refresh token to use.
@@ -179,7 +180,9 @@ async function idpFetchJSON(req, targetUrl, init = {}, opts = {}) {
179
180
  attemptedRefresh = true;
180
181
  try {
181
182
  // Use centralized internal API helper for server-to-server calls
182
- const rf = await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
183
+ const rf = opts.refreshOnAuthFailure
184
+ ? await opts.refreshOnAuthFailure(sessionToken)
185
+ : await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
183
186
  refreshStatus = rf.status;
184
187
  if (rf.ok) {
185
188
  const fresh = await (0, session_store_1.getSession)(sessionToken);
@@ -21,4 +21,14 @@ import type { NextRequest } from 'next/server';
21
21
  * refresh token, and a body built earlier would name the old one (NightHawk 76288).
22
22
  */
23
23
  export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
24
+ /**
25
+ * 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
26
+ * 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
27
+ * 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.
28
+ */
29
+ export declare class IdpLogoutUnconfirmedError extends Error {
30
+ readonly grantLive: boolean;
31
+ readonly code = "IDP_LOGOUT_UNCONFIRMED";
32
+ constructor(status: number, grantLive: boolean);
33
+ }
24
34
  export declare function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome>;
@@ -1,12 +1,64 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IdpLogoutUnconfirmedError = void 0;
3
37
  exports.revokeSessionAtIdp = revokeSessionAtIdp;
4
38
  const idp_fetch_1 = require("./idp-fetch");
5
39
  const session_store_1 = require("./session-store");
6
40
  const env_1 = require("../config/env");
41
+ /**
42
+ * 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
43
+ * 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
44
+ * 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.
45
+ */
46
+ class IdpLogoutUnconfirmedError extends Error {
47
+ grantLive;
48
+ code = 'IDP_LOGOUT_UNCONFIRMED';
49
+ constructor(status, grantLive) {
50
+ super(`IdP logout answered ${status}`);
51
+ this.grantLive = grantLive;
52
+ this.name = 'IdpLogoutUnconfirmedError';
53
+ }
54
+ }
55
+ exports.IdpLogoutUnconfirmedError = IdpLogoutUnconfirmedError;
7
56
  async function revokeSessionAtIdp(req) {
8
57
  let resolvedSession;
9
58
  const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/logout`, {}, {
59
+ // 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
60
+ // in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
61
+ refreshOnAuthFailure: forceRefreshAtIdp,
10
62
  initFor: async (token) => {
11
63
  resolvedSession = token;
12
64
  let session = await (0, session_store_1.getSession)(token);
@@ -28,7 +80,27 @@ async function revokeSessionAtIdp(req) {
28
80
  return 'revoked';
29
81
  if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession)))
30
82
  return 'grant-already-dead';
31
- throw new Error(`IdP logout answered ${result.status}`);
83
+ const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
84
+ throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
85
+ }
86
+ /**
87
+ * Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
88
+ * implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
89
+ * (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
90
+ * 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).
91
+ * Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
92
+ */
93
+ async function forceRefreshAtIdp(sessionToken) {
94
+ const idpBaseUrl = process.env.IDP_URL;
95
+ const clientId = process.env.CLIENT_ID;
96
+ if (!idpBaseUrl || !clientId)
97
+ throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
98
+ const { ensureFreshAccessToken } = await Promise.resolve().then(() => __importStar(require('./ensure-fresh-access-token')));
99
+ const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
100
+ if (r.ok)
101
+ return { ok: true, status: 200 };
102
+ const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
103
+ return { ok: false, status };
32
104
  }
33
105
  /**
34
106
  * A refresh 401 is not by itself "the grant is gone" (NightHawk 76295, BAPert 76297): refresh.ts also answers 401 for 'no session', for