@payez/next-mvp 4.8.9 → 4.10.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.
@@ -0,0 +1,24 @@
1
+ import type { NextRequest } from 'next/server';
2
+ /**
3
+ * 4.8.10 (BAPert 76255 item 1, 76289): sign-out must end the IdP grant, not only the portal session.
4
+ *
5
+ * Core: POST /api/Account/logout, Bearer, body { refresh_token } (DotNetPert 76259). It revokes that refresh token and blacklists the
6
+ * presented access token. The token is ALWAYS sent: with it omitted Core revokes the caller's NEWEST session, which on a
7
+ * multi-device user is another device's.
8
+ *
9
+ * Outcomes (all three are a SUCCESSFUL sign-out):
10
+ * 'revoked' the IdP answered 2xx.
11
+ * 'nothing-to-revoke' the session holds no IdP refresh token (a provisional pre-2FA session has none). No call is made, because
12
+ * without a token Core would pick the newest session.
13
+ * 'grant-already-dead' the revoke was refused AND the refresh that followed was answered 401 AND the session carries the IdP's own
14
+ * discard marker (refreshTokenClearedReason starting 'IDP_DISCARD_TOKEN'). A refresh 401 alone is NOT proof
15
+ * (refresh.ts also answers 401 for 'no session', the x-session-token rejection and NO_REFRESH_TOKEN), and neither
16
+ * is an empty token (BAPert 76297). Anything else is UNKNOWN.
17
+ * THROWS for an UNKNOWN outcome: a transport error, a 5xx, a refresh that could not be attempted or failed some other way. The caller
18
+ * turns that into SESSION_NOT_ENDED.
19
+ *
20
+ * The session is the one idpFetchJSON resolves from the request cookie. The body is built by `initFor`, AFTER idpFetchJSON's own refresh and again after the refresh that follows a 401: a refresh ROTATES the
21
+ * refresh token, and a body built earlier would name the old one (NightHawk 76288).
22
+ */
23
+ export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
24
+ export declare function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.revokeSessionAtIdp = revokeSessionAtIdp;
4
+ const idp_fetch_1 = require("./idp-fetch");
5
+ const session_store_1 = require("./session-store");
6
+ const env_1 = require("../config/env");
7
+ async function revokeSessionAtIdp(req) {
8
+ let resolvedSession;
9
+ const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/logout`, {}, {
10
+ initFor: async (token) => {
11
+ resolvedSession = token;
12
+ let session = await (0, session_store_1.getSession)(token);
13
+ if (!session?.idpRefreshToken)
14
+ session = (await (0, session_store_1.getBetterAuthSession)(token)) ?? session;
15
+ const refreshToken = session?.idpRefreshToken;
16
+ if (!refreshToken)
17
+ return null;
18
+ return {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ refresh_token: refreshToken }),
22
+ };
23
+ },
24
+ });
25
+ if (result.skipped)
26
+ return 'nothing-to-revoke';
27
+ if (result.ok)
28
+ return 'revoked';
29
+ if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession)))
30
+ return 'grant-already-dead';
31
+ throw new Error(`IdP logout answered ${result.status}`);
32
+ }
33
+ /**
34
+ * A refresh 401 is not by itself "the grant is gone" (NightHawk 76295, BAPert 76297): refresh.ts also answers 401 for 'no session', for
35
+ * the x-session-token rejection and for NO_REFRESH_TOKEN, none of which says anything about the IdP's token. Only its IdP-401 branch
36
+ * DISCARDS the stored refresh token and WRITES THE MARKER refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>'. The marker is the only
37
+ * proof: an EMPTY token is not (NO_REFRESH_TOKEN has an empty store, and so does a dropped persist with the grant still live at the
38
+ * IdP). So the grant counts as dead only when the session row exists AND carries that marker. Anything else is UNKNOWN.
39
+ */
40
+ async function grantWasDiscarded(sessionToken) {
41
+ let session = await (0, session_store_1.getSession)(sessionToken);
42
+ if (!session)
43
+ session = await (0, session_store_1.getBetterAuthSession)(sessionToken);
44
+ if (!session)
45
+ return false;
46
+ return String(session.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
47
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 4.9.0 (BAPert 76359): ONE predicate for "a concurrent refresh already rotated the refresh token, and the row it left is usable", shared by
3
+ * all four places that decide it (api-handlers/auth/refresh.ts and lib/ensure-fresh-access-token.ts, each for the HTTP-401 branch and the
4
+ * 200 success:false branch). Before, two said "usable" meant PRESENT (an access token and an expiry exist) and two said UNEXPIRED, so a rotated
5
+ * row whose access token had already expired was answered as a successful refresh by some branches and not by others.
6
+ *
7
+ * Usable = an access token exists AND its recorded expiry is in the future. A row that is rotated but NOT usable is answered with the
8
+ * retryable 502, its (rotated) refresh token KEPT and NO discard marker, in every branch. The CAS that decides "rotated" (a different
9
+ * stored refresh token than the one we sent) is unchanged: a different token is never discarded.
10
+ *
11
+ * A leaf module: no imports.
12
+ */
13
+ export declare function rotatedRowIsUsable<T extends {
14
+ idpAccessToken?: string | null;
15
+ idpAccessTokenExpires?: number | null;
16
+ }>(session: T | null | undefined, now?: number): session is T & {
17
+ idpAccessToken: string;
18
+ idpAccessTokenExpires: number;
19
+ };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rotatedRowIsUsable = rotatedRowIsUsable;
4
+ /**
5
+ * 4.9.0 (BAPert 76359): ONE predicate for "a concurrent refresh already rotated the refresh token, and the row it left is usable", shared by
6
+ * all four places that decide it (api-handlers/auth/refresh.ts and lib/ensure-fresh-access-token.ts, each for the HTTP-401 branch and the
7
+ * 200 success:false branch). Before, two said "usable" meant PRESENT (an access token and an expiry exist) and two said UNEXPIRED, so a rotated
8
+ * row whose access token had already expired was answered as a successful refresh by some branches and not by others.
9
+ *
10
+ * Usable = an access token exists AND its recorded expiry is in the future. A row that is rotated but NOT usable is answered with the
11
+ * retryable 502, its (rotated) refresh token KEPT and NO discard marker, in every branch. The CAS that decides "rotated" (a different
12
+ * stored refresh token than the one we sent) is unchanged: a different token is never discarded.
13
+ *
14
+ * A leaf module: no imports.
15
+ */
16
+ function rotatedRowIsUsable(session, now = Date.now()) {
17
+ return !!(session && session.idpAccessToken && session.idpAccessTokenExpires && session.idpAccessTokenExpires > now);
18
+ }
@@ -93,11 +93,6 @@ export declare function deleteSession(sessionToken: string): Promise<void>;
93
93
  * them differently, pass appSlug explicitly.
94
94
  */
95
95
  export declare function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void>;
96
- /**
97
- * Sets a session directly (for testing or migrations).
98
- * @param sessionToken The session token.
99
- * @param data The session data.
100
- */
101
96
  export declare function setSession(sessionToken: string, data: SessionData): Promise<void>;
102
97
  /**
103
98
  * Updates tokens within an existing session.
@@ -271,7 +271,24 @@ async function deleteSessionEverywhere(sessionToken, appSlug) {
271
271
  * @param sessionToken The session token.
272
272
  * @param data The session data.
273
273
  */
274
+ /**
275
+ * 4.9.0 (BAPert 76308 item 2): A LEFTOVER DISCARD MARKER MUST NEVER OUTLIVE A TOKEN. A discard (`refresh.ts`, `ensure-fresh-access-token.ts`)
276
+ * writes `idpRefreshToken: ''` TOGETHER WITH `refreshTokenClearedReason: 'IDP_DISCARD_TOKEN:<code>'`; logout's dead-grant check reads that marker
277
+ * as proof the IdP's grant is gone. If a later write stores a NEW refresh token on the same row (a successful refresh, a re-sign-in into the
278
+ * same row) and the marker stays, a later own-auth 401 would read as "grant already dead" while the grant is live. So any write that leaves a
279
+ * non-empty refresh token on the row drops the marker, in the same write. No code path was found that gives a discarded row a new token
280
+ * today; this is the defence BAPert ruled to keep anyway.
281
+ */
282
+ function dropStaleDiscardMarker(session) {
283
+ const s = session;
284
+ if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
285
+ const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
286
+ return rest;
287
+ }
288
+ return session;
289
+ }
274
290
  async function setSession(sessionToken, data) {
291
+ data = dropStaleDiscardMarker(data);
275
292
  const key = getSessionKey(sessionToken);
276
293
  const versionKey = getSessionVersionKey(sessionToken);
277
294
  await redis_1.default.multi()
@@ -405,7 +422,7 @@ async function updateSession(sessionToken, updates) {
405
422
  });
406
423
  }
407
424
  // Merge current session with updates
408
- const updatedSession = { ...currentSession, ...updates };
425
+ const updatedSession = dropStaleDiscardMarker({ ...currentSession, ...updates });
409
426
  // Write the entire updated session back to Redis with version increment
410
427
  await redis_1.default.multi()
411
428
  .setex(key, exports.SESSION_TTL, JSON.stringify(updatedSession))
@@ -14,14 +14,28 @@
14
14
  * @since auth-ready-v2
15
15
  */
16
16
  import { NextRequest, NextResponse } from 'next/server';
17
- /** 4.8.9: answered (status 500) when the server-side session delete failed. The browser's cookies are cleared either way. */
17
+ /**
18
+ * 4.8.9: answered (status 500) when a sign-out did not finish. 4.8.10 (BAPert 76297): TWO sentences, one per cause, each true for its
19
+ * cause, neither promising a retry (the local session is gone, so there is nothing to retry from). The browser's cookies and the local
20
+ * session are cleared either way.
21
+ * SESSION_NOT_ENDED_IDP_MESSAGE the IdP outcome is UNKNOWN (revoke refused or unreachable, and no IdP discard marker proves the grant dead)
22
+ * SESSION_NOT_ENDED_LOCAL_MESSAGE the local session-store DELETE failed (the server record is still live)
23
+ * Both causes at once: the LOCAL sentence, because a live server record is the worse and more actionable fact.
24
+ */
18
25
  export declare const SESSION_NOT_ENDED_CODE = "SESSION_NOT_ENDED";
19
- export declare const SESSION_NOT_ENDED_MESSAGE = "You were signed out on this device, but we could not end your session on the server. Try signing out again.";
26
+ /**
27
+ * The machine-readable cause on the 500 body (Nextpert-Scout 76300): the portal never reads response text, so it picks its notice from
28
+ * this closed set. `code` stays SESSION_NOT_ENDED for 4.8.9 callers; `cause` is additive. Both causes at once: local_delete_failed.
29
+ * A dead-grant or ordinary success carries neither.
30
+ */
31
+ export type SessionNotEndedCause = 'idp_unconfirmed' | 'local_delete_failed';
32
+ export declare const SESSION_NOT_ENDED_IDP_MESSAGE = "We could not confirm the sign-out with the identity service.";
33
+ export declare const SESSION_NOT_ENDED_LOCAL_MESSAGE = "We could not finish signing you out on this device.";
20
34
  /**
21
35
  * POST /api/auth/logout - Sign out and clean up session
22
36
  *
23
37
  * Performs complete logout:
24
- * 1. Revokes tokens at IDP (if refresh token available)
38
+ * 1. Revokes the refresh token at the IDP (POST /api/Account/logout) when the session has one; a failed revoke answers SESSION_NOT_ENDED
25
39
  * 2. Deletes session from store
26
40
  * 3. Clears session cookies
27
41
  */
@@ -15,22 +15,31 @@
15
15
  * @since auth-ready-v2
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.SESSION_NOT_ENDED_MESSAGE = exports.SESSION_NOT_ENDED_CODE = void 0;
18
+ exports.SESSION_NOT_ENDED_LOCAL_MESSAGE = exports.SESSION_NOT_ENDED_IDP_MESSAGE = exports.SESSION_NOT_ENDED_CODE = void 0;
19
19
  exports.POST = POST;
20
20
  const server_1 = require("next/server");
21
21
  const auth_1 = require("../../server/auth");
22
22
  const session_store_1 = require("../../lib/session-store");
23
+ const idp_logout_1 = require("../../lib/idp-logout");
23
24
  const app_slug_1 = require("../../lib/app-slug");
24
25
  const site_logger_1 = require("../../lib/site-logger");
25
26
  const safe_error_response_1 = require("../../lib/safe-error-response");
26
- /** 4.8.9: answered (status 500) when the server-side session delete failed. The browser's cookies are cleared either way. */
27
+ /**
28
+ * 4.8.9: answered (status 500) when a sign-out did not finish. 4.8.10 (BAPert 76297): TWO sentences, one per cause, each true for its
29
+ * cause, neither promising a retry (the local session is gone, so there is nothing to retry from). The browser's cookies and the local
30
+ * session are cleared either way.
31
+ * SESSION_NOT_ENDED_IDP_MESSAGE the IdP outcome is UNKNOWN (revoke refused or unreachable, and no IdP discard marker proves the grant dead)
32
+ * SESSION_NOT_ENDED_LOCAL_MESSAGE the local session-store DELETE failed (the server record is still live)
33
+ * Both causes at once: the LOCAL sentence, because a live server record is the worse and more actionable fact.
34
+ */
27
35
  exports.SESSION_NOT_ENDED_CODE = 'SESSION_NOT_ENDED';
28
- exports.SESSION_NOT_ENDED_MESSAGE = 'You were signed out on this device, but we could not end your session on the server. Try signing out again.';
36
+ exports.SESSION_NOT_ENDED_IDP_MESSAGE = 'We could not confirm the sign-out with the identity service.';
37
+ exports.SESSION_NOT_ENDED_LOCAL_MESSAGE = 'We could not finish signing you out on this device.';
29
38
  /**
30
39
  * POST /api/auth/logout - Sign out and clean up session
31
40
  *
32
41
  * Performs complete logout:
33
- * 1. Revokes tokens at IDP (if refresh token available)
42
+ * 1. Revokes the refresh token at the IDP (POST /api/Account/logout) when the session has one; a failed revoke answers SESSION_NOT_ENDED
34
43
  * 2. Deletes session from store
35
44
  * 3. Clears session cookies
36
45
  */
@@ -47,7 +56,19 @@ async function POST(req) {
47
56
  const sessionId = session.session?.token;
48
57
  // 4.8.9: a sign-out that did not end the session must not say it did. The delete below can fail (a faulting store); the
49
58
  // cookies are still cleared, but the answer is then a typed failure instead of success:true.
50
- let serverDeleteFailed = false;
59
+ let localDeleteFailed = false;
60
+ let idpRevokeFailed = false;
61
+ // 4.8.10: end the IdP grant FIRST, while the session still holds its tokens. A failed revoke is the same typed failure as a failed
62
+ // delete: the IdP refresh token is still valid, so the sign-out did not end the session. The local delete below still runs.
63
+ if (sessionId) {
64
+ try {
65
+ await (0, idp_logout_1.revokeSessionAtIdp)(req);
66
+ }
67
+ catch (error) {
68
+ idpRevokeFailed = true;
69
+ console.error('[LOGOUT_ROUTE] IdP revoke failed - the IdP refresh token is STILL LIVE:', error);
70
+ }
71
+ }
51
72
  // Delete session from EVERY store — canonical + BA secondary (181922).
52
73
  // Canonical-only deletion let the ba: record resurrect the session.
53
74
  if (sessionId) {
@@ -56,7 +77,7 @@ async function POST(req) {
56
77
  console.info('[LOGOUT_ROUTE] Session deleted from store');
57
78
  }
58
79
  catch (error) {
59
- serverDeleteFailed = true;
80
+ localDeleteFailed = true;
60
81
  console.error('[LOGOUT_ROUTE] Failed to delete session - the server session is STILL LIVE:', error);
61
82
  }
62
83
  }
@@ -73,8 +94,10 @@ async function POST(req) {
73
94
  });
74
95
  }
75
96
  // Build response that clears session cookies
76
- const response = serverDeleteFailed
77
- ? server_1.NextResponse.json({ success: false, code: exports.SESSION_NOT_ENDED_CODE, message: exports.SESSION_NOT_ENDED_MESSAGE }, { status: 500 })
97
+ const response = (localDeleteFailed || idpRevokeFailed)
98
+ ? server_1.NextResponse.json(localDeleteFailed
99
+ ? { success: false, code: exports.SESSION_NOT_ENDED_CODE, cause: 'local_delete_failed', message: exports.SESSION_NOT_ENDED_LOCAL_MESSAGE }
100
+ : { success: false, code: exports.SESSION_NOT_ENDED_CODE, cause: 'idp_unconfirmed', message: exports.SESSION_NOT_ENDED_IDP_MESSAGE }, { status: 500 })
78
101
  : server_1.NextResponse.json({
79
102
  success: true,
80
103
  message: 'Logged out successfully'
@@ -23,6 +23,7 @@ export declare function POST(req: NextRequest): Promise<import("next/server").Ne
23
23
  discardToken: boolean;
24
24
  retryable: boolean;
25
25
  resolution: any;
26
+ status: number;
26
27
  }> | import("next/server").NextResponse<{
27
28
  refreshed: boolean;
28
29
  accessTokenExpires: number;
@@ -392,7 +392,7 @@ async function getFreshIdpToken(request, config) {
392
392
  refreshEndpoint: config.refreshEndpoint,
393
393
  }, {
394
394
  safetyWindowMs: config.safetyWindowMs,
395
- requestId: request?.headers?.get('x-request-id') ?? undefined,
395
+ // 4.8.10 (BAPert 76298 (b)): the request's x-request-id is NOT passed. It used to become the refresh lock's identity.
396
396
  });
397
397
  if (!result.ok) {
398
398
  if (result.code === 'NO_SESSION')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.8.9",
3
+ "version": "4.10.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,7 +8,8 @@
8
8
  "dist",
9
9
  "scripts",
10
10
  "src",
11
- "POST-INSTALL.md"
11
+ "POST-INSTALL.md",
12
+ "CHANGELOG.md"
12
13
  ],
13
14
  "scripts": {
14
15
  "build": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\" && npx tsc --project tsconfig.json",
@@ -669,16 +670,6 @@
669
670
  "require": "./dist/stores/authStore.js",
670
671
  "default": "./stores/authStore.js"
671
672
  },
672
- "./api": {
673
- "types": "./dist/api/index.d.ts",
674
- "require": "./dist/api/index.js",
675
- "default": "./dist/api/index.js"
676
- },
677
- "./api/auth-handler": {
678
- "types": "./dist/api/auth-handler.d.ts",
679
- "require": "./dist/api/auth-handler.js",
680
- "default": "./dist/api/auth-handler.js"
681
- },
682
673
  "./routes/auth/refresh": {
683
674
  "types": "./dist/routes/auth/refresh.d.ts",
684
675
  "require": "./dist/routes/auth/refresh.js",
@@ -41,6 +41,7 @@ import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, chec
41
41
  import { computeTokenExpiries } from '../../lib/token-expiry';
42
42
  import { extractKidFromToken } from '../../auth/utils/token-utils';
43
43
  import { tokenLogId } from '../../lib/token-log-id';
44
+ import { rotatedRowIsUsable } from '../../lib/refresh-rotation';
44
45
 
45
46
  interface RefreshConfig {
46
47
  idpBaseUrl: string;
@@ -333,7 +334,43 @@ export function createRefreshHandler(config: RefreshConfig) {
333
334
  // Note: Token is NOT cleared preemptively - it will be replaced on success.
334
335
  // The lock mechanism prevents concurrent refresh attempts within the same session.
335
336
  // If IDP call fails, user can retry with the same token.
336
- const originalRefreshToken = currentSession.idpRefreshToken as string;
337
+ // 4.9.0 (BAPert 76298 (b2), 76330): the token we SEND is the one read AFTER the lock was taken (`latestAfterLock`), and it is the ONLY
338
+ // source: there is NO fallback to `currentSession`, which was read BEFORE the lock. Between those reads another request can rotate the
339
+ // refresh token (sending the pre-lock value would send a SPENT token) or DISCARD it (the IdP answered 401 for it; re-sending it is a
340
+ // reuse). The IdP treats reuse as compromise and revokes descendants across devices. So:
341
+ // - the post-lock read has NO ROW: the session vanished while we waited; it is not ours to refresh -> the 'No session' 401, no IdP call
342
+ // - the row has NO refresh token: -> the same terminal NO_REFRESH_TOKEN as the pre-lock guard, no IdP call; the row is left exactly as
343
+ // it is (a discard marker stays on it)
344
+ // (A row whose access token is still fresh was answered `already_fresh` above and never reaches here.)
345
+ if (!latestAfterLock) {
346
+ console.warn('[AUTH_REFRESH] Session vanished while waiting for the refresh lock; not refreshing', { userId });
347
+ if (weAcquiredLock) {
348
+ await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
349
+ weAcquiredLock = false;
350
+ }
351
+ return NextResponse.json({
352
+ error: 'No session available for refresh',
353
+ code: 'UNAUTHORIZED'
354
+ }, { status: 401 });
355
+ }
356
+ if (!latestAfterLock.idpRefreshToken) {
357
+ console.warn('[AUTH_REFRESH] No refresh token after the lock was taken; not calling the IdP', { userId });
358
+ if (weAcquiredLock) {
359
+ await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
360
+ weAcquiredLock = false;
361
+ }
362
+ return NextResponse.json({
363
+ error: 'No refresh token available',
364
+ code: 'NO_REFRESH_TOKEN',
365
+ terminal: true,
366
+ resolution: 'User must re-authenticate'
367
+ }, { status: 401 });
368
+ }
369
+ const originalRefreshToken = latestAfterLock.idpRefreshToken as string;
370
+ // The base of everything WRITTEN BACK after a successful exchange is the same post-lock read (BAPert 76318 item 4): writing
371
+ // `...currentSession` over a rotated row would put a SPENT token back. With no new refresh token from the IdP the stored token is the
372
+ // one that was SENT (`originalRefreshToken`).
373
+ const baseSession = latestAfterLock;
337
374
 
338
375
  try {
339
376
  // Extract 2FA claims from current session
@@ -565,7 +602,7 @@ export function createRefreshHandler(config: RefreshConfig) {
565
602
  // (race-fix follow-up #1).
566
603
  discardToken = false;
567
604
  console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
568
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
605
+ if (rotatedRowIsUsable(postRefresh)) {
569
606
  return NextResponse.json({
570
607
  refreshed: true,
571
608
  reason: 'completed_by_concurrent_request',
@@ -573,6 +610,16 @@ export function createRefreshHandler(config: RefreshConfig) {
573
610
  hasRefreshToken: !!postRefresh.idpRefreshToken,
574
611
  });
575
612
  }
613
+ // Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept,
614
+ // no marker. Same meaning in every branch (lib/refresh-rotation).
615
+ return NextResponse.json({
616
+ error: errorMessage,
617
+ code: errorCode,
618
+ discardToken: false,
619
+ retryable: true,
620
+ resolution,
621
+ status: 502
622
+ }, { status: 502 });
576
623
  }
577
624
  }
578
625
 
@@ -631,30 +678,59 @@ export function createRefreshHandler(config: RefreshConfig) {
631
678
  errorKeys: Object.keys(idpError),
632
679
  });
633
680
 
681
+ // 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations. This branch used to answer the upstream
682
+ // status, i.e. HTTP 200, with an error body, so `internalRefresh` reported `ok` and idpFetchJSON's 401-retry trusted a refresh that
683
+ // had failed; and it wrote a DIFFERENT marker (IDP_SUCCESS_FALSE_DISCARD) than the IdP-401 branch, which the logout dead-grant check
684
+ // does not recognise. Now:
685
+ // success:false + discard_token -> the stored token is discarded (same CAS as the 401 branch) with the IDP_DISCARD_TOKEN marker, and the
686
+ // answer is 401 (never 200 with an error body)
687
+ // success:false, no discard_token -> 502 retryable, the token is KEPT
634
688
  if (discardToken) {
635
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
636
- reason: 'IDP_SUCCESS_FALSE_DISCARD',
637
- errorCode,
638
- userId,
639
- sessionToken: tokenLogId(sessionToken),
640
- hadRefreshToken: !!currentSession.idpRefreshToken,
641
- stack: new Error().stack
642
- });
643
- await updateSession(sessionToken, {
644
- idpRefreshToken: '',
645
- idpRefreshTokenExpires: undefined,
646
- refreshTokenClearedAt: Date.now(),
647
- refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
648
- });
689
+ const postRefresh = await getSession(sessionToken);
690
+ const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
691
+ if (tokenUnchanged) {
692
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
693
+ reason: 'IDP_DISCARD_TOKEN',
694
+ errorCode,
695
+ userId,
696
+ sessionToken: tokenLogId(sessionToken),
697
+ hadRefreshToken: !!currentSession.idpRefreshToken,
698
+ stack: new Error().stack
699
+ });
700
+ await updateSession(sessionToken, {
701
+ idpRefreshToken: '',
702
+ idpRefreshTokenExpires: undefined,
703
+ refreshTokenClearedAt: Date.now(),
704
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
705
+ });
706
+ return NextResponse.json({
707
+ error: idpError.message || 'Token refresh failed',
708
+ code: errorCode,
709
+ discardToken: true,
710
+ retryable: false,
711
+ resolution: idpError.resolution || null,
712
+ status: 401
713
+ }, { status: 401 });
714
+ }
715
+ // A concurrent refresh already rotated the token: ours is stale. Return the fresh tokens if there are any (as the 401 branch does).
716
+ if (rotatedRowIsUsable(postRefresh)) {
717
+ return NextResponse.json({
718
+ refreshed: true,
719
+ reason: 'completed_by_concurrent_request',
720
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
721
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
722
+ });
723
+ }
649
724
  }
650
725
 
651
726
  return NextResponse.json({
652
727
  error: idpError.message || 'Token refresh failed',
653
728
  code: errorCode,
654
- discardToken,
655
- retryable,
656
- resolution: idpError.resolution || null
657
- }, { status: refreshResponse.status || 400 });
729
+ discardToken: false,
730
+ retryable: true,
731
+ resolution: idpError.resolution || null,
732
+ status: 502
733
+ }, { status: 502 });
658
734
  }
659
735
 
660
736
  // Extract tokens from response
@@ -701,24 +777,24 @@ export function createRefreshHandler(config: RefreshConfig) {
701
777
  : decodedAccessToken.amr;
702
778
  } catch (e) {
703
779
  console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
704
- amrClaims = currentSession.authenticationMethods || [];
780
+ amrClaims = baseSession.authenticationMethods || [];
705
781
  }
706
782
  } else {
707
- amrClaims = currentSession.authenticationMethods || [];
783
+ amrClaims = baseSession.authenticationMethods || [];
708
784
  }
709
- const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
785
+ const acrLevel = String(decodedAccessToken.acr || baseSession.authenticationLevel || '1');
710
786
 
711
787
  // Extract MFA timing claims
712
- const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : currentSession.mfaCompletedAt;
713
- const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
714
- const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
788
+ const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : baseSession.mfaCompletedAt;
789
+ const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : baseSession.mfaExpiresAt;
790
+ const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : baseSession.mfaValidityHours;
715
791
 
716
792
  // Update session with new tokens
717
793
  const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
718
794
  const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
719
795
 
720
796
  // CRITICAL: Log if we're about to clear the refresh token due to missing new token
721
- if (!hasNewRefresh && currentSession.idpRefreshToken) {
797
+ if (!hasNewRefresh && baseSession.idpRefreshToken) {
722
798
  console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
723
799
  reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
724
800
  userId,
@@ -747,14 +823,14 @@ export function createRefreshHandler(config: RefreshConfig) {
747
823
  }
748
824
 
749
825
  const sessionUpdate: any = {
750
- ...currentSession,
826
+ ...baseSession,
751
827
  idpAccessToken: accessToken,
752
828
  idpAccessTokenExpires: accessTokenExpires,
753
- idpRefreshToken: hasNewRefresh ? (refreshToken as string) : currentSession.idpRefreshToken,
754
- idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
829
+ idpRefreshToken: hasNewRefresh ? (refreshToken as string) : originalRefreshToken,
830
+ idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : baseSession.idpRefreshTokenExpires,
755
831
  decodedAccessToken: decodedAccessToken,
756
832
  // Bearer key ID from JWT header (may change on key rotation)
757
- bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
833
+ bearerKeyId: newBearerKeyId || baseSession.bearerKeyId,
758
834
  authenticationMethods: amrClaims,
759
835
  authenticationLevel: acrLevel,
760
836
  // 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator