@payez/next-mvp 4.8.9 → 4.9.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.
package/README.md CHANGED
@@ -548,7 +548,7 @@ import { POST as logout } from '@payez/next-mvp/routes/auth/logout';
548
548
  | Variable | Required | Description |
549
549
  |----------|----------|-------------|
550
550
  | `CLIENT_ID` | ✅ Yes | Your PayEz IDP client ID (string slug, e.g., `payez_idp_admin_web`) |
551
- | `IDP_URL` | ✅ Yes | PayEz IDP base URL (e.g., `https://idp.payez.net` or `http://localhost:32785`). **No default in code.** Call `requireIdpUrl()` from `@payez/next-mvp/lib/require-idp-url` in your `instrumentation.ts` (4.8.8) and the server refuses to start, with one fixed sentence, when it is missing or not a full http(s) address. The quickstart generator writes the production address into `.env.local` and adds that file for you. |
551
+ | `IDP_URL` | ✅ Yes | PayEz IDP base URL (e.g., `https://idp.payez.net` or `http://localhost:32785`). **No default in code.** Call `requireIdpUrl()` from `@payez/next-mvp/lib/require-idp-url` in your `instrumentation.ts` (4.8.8) and the server refuses to start, with one fixed sentence, when it is missing or not a full http(s) address. `npx @payez/create-next-app` writes the production address (`https://idp.payez.net`) into `.env.local` when it provisions your dev key; an `IDP_URL` in the environment when you run it overrides that address. If provisioning does not complete, it writes `IDP_URL=` EMPTY, and the app then refuses to start with the sentence above until you set it. |
552
552
  | `AUTH_TRUST_HOST` | ✅ Yes | Must be `true` - NextAuth derives OAuth URLs from request headers |
553
553
  | `NEXTAUTH_SECRET` | 🔄 Broker | **Do NOT set** - fetched automatically from IDP at startup (see below) |
554
554
  | `CLIENT_SECRET` | ⚠️ Legacy | Not needed with broker mode - IDP handles signing |
@@ -8,7 +8,10 @@ export interface EnsureFreshOptions {
8
8
  safetyWindowMs?: number;
9
9
  /** Max wait while another caller holds the refresh lock. Default 5000. */
10
10
  lockWaitMs?: number;
11
- /** Optional caller request id for lock attribution. */
11
+ /**
12
+ * Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
13
+ * crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
14
+ */
12
15
  requestId?: string;
13
16
  }
14
17
  export type EnsureFreshResult = {
@@ -16,6 +16,7 @@ exports.ensureFreshAccessToken = ensureFreshAccessToken;
16
16
  * Single-use refresh-token semantics are preserved via Redis-backed
17
17
  * single-flight locking (see `acquireRefreshLock`).
18
18
  */
19
+ const crypto_1 = require("crypto");
19
20
  const session_store_1 = require("./session-store");
20
21
  const token_expiry_1 = require("./token-expiry");
21
22
  const token_utils_1 = require("../auth/utils/token-utils");
@@ -37,7 +38,13 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
37
38
  const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
38
39
  const safetyWindowMs = options.safetyWindowMs ?? DEFAULT_SAFETY_WINDOW_MS;
39
40
  const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
40
- const requestId = options.requestId ?? `ensure_fresh_${Date.now()}`;
41
+ // LOCK IDENTITY IS MINTED SERVER-SIDE (BAPert 76298 (b)), exactly as api-handlers/auth/refresh.ts does. It used to be
42
+ // `options.requestId ?? ensure_fresh_${Date.now()}`, and server/auth.ts passed the request's x-request-id header, so a caller chose the
43
+ // value that the branch below compared against the lock holder's id. A lock that protects against refresh-token REUSE (the IdP treats
44
+ // reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
45
+ // collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
46
+ const requestId = `ensure_fresh_${(0, crypto_1.randomUUID)()}`;
47
+ const traceId = options.requestId;
41
48
  let session = await (0, session_store_1.getSession)(sessionToken);
42
49
  if (!session || !session.idpRefreshToken) {
43
50
  const baSession = await (0, session_store_1.getBetterAuthSession)(sessionToken);
@@ -83,8 +90,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
83
90
  let weHoldLock = false;
84
91
  let releaseVersion;
85
92
  if (!lock.acquired) {
86
- const existing = await (0, session_store_1.checkRefreshLock)(sessionToken);
87
- if (!existing || existing.acquiredBy !== requestId) {
93
+ {
88
94
  const startWait = Date.now();
89
95
  while (Date.now() - startWait < lockWaitMs) {
90
96
  await new Promise(r => setTimeout(r, 200));
@@ -263,6 +269,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
263
269
  console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
264
270
  code,
265
271
  requestId,
272
+ traceId,
266
273
  });
267
274
  }
268
275
  }
@@ -403,6 +410,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
403
410
  if (!persisted) {
404
411
  console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
405
412
  requestId,
413
+ traceId,
406
414
  refreshTokenRotated: hasNewRefresh,
407
415
  persisted: false,
408
416
  });
@@ -19,5 +19,21 @@ export interface IdpFetchResult<T = any> {
19
19
  * It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
20
20
  */
21
21
  retryAfterSeconds?: number;
22
+ /** 4.8.10: set when `opts.initFor` returned null for the FIRST call: no request was made. */
23
+ skipped?: true;
24
+ /**
25
+ * 4.8.10: how the refresh that followed a 401/403 ended: the status the internal refresh answered, or null when it threw. Absent
26
+ * when no refresh was attempted. refresh.ts treats a 401 as "definitively invalid - always discard", i.e. the grant is gone.
27
+ */
28
+ refreshStatus?: number | null;
29
+ }
30
+ export interface IdpFetchOptions {
31
+ /**
32
+ * 4.8.10: a call whose BODY names a token that a refresh ROTATES (logout names the refresh token) cannot be built before the
33
+ * refresh. Called with the session token AFTER the pre-call refresh, for the first call, and again AFTER the refresh that follows a
34
+ * 401/403, for the retry. It returns the init to send, or null for "nothing to send": null on the first call returns
35
+ * `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
36
+ */
37
+ initFor?: (sessionToken: string) => Promise<RequestInit | null>;
22
38
  }
23
- export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit): Promise<IdpFetchResult<T>>;
39
+ export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit, opts?: IdpFetchOptions): Promise<IdpFetchResult<T>>;
@@ -134,11 +134,19 @@ async function ensureFreshAccessToken(req) {
134
134
  }
135
135
  return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
136
136
  }
137
- async function idpFetchJSON(req, targetUrl, init = {}) {
137
+ async function idpFetchJSON(req, targetUrl, init = {}, opts = {}) {
138
138
  let attemptedRefresh = false;
139
+ let refreshStatus;
139
140
  let { accessToken, sessionToken, hasRefreshToken } = await ensureFreshAccessToken(req);
140
141
  (0, idp_url_guard_1.assertAbsoluteIdpUrl)(targetUrl);
141
- const makeCall = async (bearer) => {
142
+ let firstInit = init;
143
+ if (opts.initFor) {
144
+ const built = sessionToken ? await opts.initFor(sessionToken) : null;
145
+ if (!built)
146
+ return { ok: false, status: 0, json: null, attemptedRefresh: false, skipped: true };
147
+ firstInit = built;
148
+ }
149
+ const makeCall = async (bearer, callInit = firstInit) => {
142
150
  // NOTE (307290): this fetch is UNGUARDED, and so is the first call to it at
143
151
  // the "First attempt" below. Only the JSON PARSE (try/catch immediately
144
152
  // after) and the RETRY are guarded. A transport-level failure - DNS, refused,
@@ -151,8 +159,8 @@ async function idpFetchJSON(req, targetUrl, init = {}) {
151
159
  // carve-out. Correcting b4b8ffae's message, which wrongly said every risky
152
160
  // branch here is try/caught.
153
161
  const res = await fetch(targetUrl, {
154
- ...init,
155
- headers: buildHeaders(req, bearer, init.headers),
162
+ ...callInit,
163
+ headers: buildHeaders(req, bearer, callInit.headers),
156
164
  });
157
165
  let json = null;
158
166
  try {
@@ -172,13 +180,19 @@ async function idpFetchJSON(req, targetUrl, init = {}) {
172
180
  try {
173
181
  // Use centralized internal API helper for server-to-server calls
174
182
  const rf = await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
183
+ refreshStatus = rf.status;
175
184
  if (rf.ok) {
176
185
  const fresh = await (0, session_store_1.getSession)(sessionToken);
177
- ({ res, json } = await makeCall(fresh?.idpAccessToken));
186
+ const retryInit = opts.initFor ? await opts.initFor(sessionToken) : firstInit;
187
+ if (retryInit)
188
+ ({ res, json } = await makeCall(fresh?.idpAccessToken, retryInit));
178
189
  }
179
190
  }
180
- catch { }
191
+ catch {
192
+ if (refreshStatus === undefined)
193
+ refreshStatus = null;
194
+ }
181
195
  }
182
196
  const retryAfterSeconds = (0, retry_after_1.parseRetryAfterSeconds)(res.headers?.get?.('Retry-After'));
183
- return { ok: res.ok, status: res.status, json: json, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
197
+ return { ok: res.ok, status: res.status, json: json, attemptedRefresh, ...(refreshStatus !== undefined ? { refreshStatus } : {}), ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
184
198
  }
@@ -0,0 +1,22 @@
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 (401/403) and the refresh that followed was answered 401: refresh.ts calls that
14
+ * "definitively invalid", the token is expired or revoked, so there is nothing left to end (BAPert 76289 item 3).
15
+ * THROWS for an UNKNOWN outcome: a transport error, a 5xx, a refresh that could not be attempted or failed some other way. The caller
16
+ * turns that into SESSION_NOT_ENDED.
17
+ *
18
+ * 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
19
+ * refresh token, and a body built earlier would name the old one (NightHawk 76288).
20
+ */
21
+ export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
22
+ 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
+ }
@@ -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'
@@ -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.9.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,6 +13,7 @@
13
13
  * Single-use refresh-token semantics are preserved via Redis-backed
14
14
  * single-flight locking (see `acquireRefreshLock`).
15
15
  */
16
+ import { randomUUID } from 'crypto';
16
17
  import {
17
18
  getSession,
18
19
  getBetterAuthSession,
@@ -35,7 +36,10 @@ export interface EnsureFreshOptions {
35
36
  safetyWindowMs?: number;
36
37
  /** Max wait while another caller holds the refresh lock. Default 5000. */
37
38
  lockWaitMs?: number;
38
- /** Optional caller request id for lock attribution. */
39
+ /**
40
+ * Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
41
+ * crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
42
+ */
39
43
  requestId?: string;
40
44
  }
41
45
 
@@ -106,7 +110,13 @@ export async function ensureFreshAccessToken(
106
110
  const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
107
111
  const safetyWindowMs = options.safetyWindowMs ?? DEFAULT_SAFETY_WINDOW_MS;
108
112
  const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
109
- const requestId = options.requestId ?? `ensure_fresh_${Date.now()}`;
113
+ // LOCK IDENTITY IS MINTED SERVER-SIDE (BAPert 76298 (b)), exactly as api-handlers/auth/refresh.ts does. It used to be
114
+ // `options.requestId ?? ensure_fresh_${Date.now()}`, and server/auth.ts passed the request's x-request-id header, so a caller chose the
115
+ // value that the branch below compared against the lock holder's id. A lock that protects against refresh-token REUSE (the IdP treats
116
+ // reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
117
+ // collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
118
+ const requestId = `ensure_fresh_${randomUUID()}`;
119
+ const traceId = options.requestId;
110
120
 
111
121
  let session = await getSession(sessionToken);
112
122
  if (!session || !session.idpRefreshToken) {
@@ -159,8 +169,7 @@ export async function ensureFreshAccessToken(
159
169
  let releaseVersion: number | undefined;
160
170
 
161
171
  if (!lock.acquired) {
162
- const existing = await checkRefreshLock(sessionToken);
163
- if (!existing || existing.acquiredBy !== requestId) {
172
+ {
164
173
  const startWait = Date.now();
165
174
  while (Date.now() - startWait < lockWaitMs) {
166
175
  await new Promise(r => setTimeout(r, 200));
@@ -336,6 +345,7 @@ export async function ensureFreshAccessToken(
336
345
  console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
337
346
  code,
338
347
  requestId,
348
+ traceId,
339
349
  });
340
350
  }
341
351
  } else {
@@ -480,6 +490,7 @@ export async function ensureFreshAccessToken(
480
490
  if (!persisted) {
481
491
  console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
482
492
  requestId,
493
+ traceId,
483
494
  refreshTokenRotated: hasNewRefresh,
484
495
  persisted: false,
485
496
  });
@@ -28,6 +28,23 @@ export interface IdpFetchResult<T = any> {
28
28
  * It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
29
29
  */
30
30
  retryAfterSeconds?: number;
31
+ /** 4.8.10: set when `opts.initFor` returned null for the FIRST call: no request was made. */
32
+ skipped?: true;
33
+ /**
34
+ * 4.8.10: how the refresh that followed a 401/403 ended: the status the internal refresh answered, or null when it threw. Absent
35
+ * when no refresh was attempted. refresh.ts treats a 401 as "definitively invalid - always discard", i.e. the grant is gone.
36
+ */
37
+ refreshStatus?: number | null;
38
+ }
39
+
40
+ export interface IdpFetchOptions {
41
+ /**
42
+ * 4.8.10: a call whose BODY names a token that a refresh ROTATES (logout names the refresh token) cannot be built before the
43
+ * refresh. Called with the session token AFTER the pre-call refresh, for the first call, and again AFTER the refresh that follows a
44
+ * 401/403, for the retry. It returns the init to send, or null for "nothing to send": null on the first call returns
45
+ * `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
46
+ */
47
+ initFor?: (sessionToken: string) => Promise<RequestInit | null>;
31
48
  }
32
49
 
33
50
  function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
@@ -121,14 +138,23 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
121
138
  export async function idpFetchJSON<T = any>(
122
139
  req: NextRequest,
123
140
  targetUrl: string,
124
- init: RequestInit = {}
141
+ init: RequestInit = {},
142
+ opts: IdpFetchOptions = {}
125
143
  ): Promise<IdpFetchResult<T>> {
126
144
  let attemptedRefresh = false;
145
+ let refreshStatus: number | null | undefined;
127
146
  let { accessToken, sessionToken, hasRefreshToken } = await ensureFreshAccessToken(req);
128
147
 
129
148
  assertAbsoluteIdpUrl(targetUrl);
130
149
 
131
- const makeCall = async (bearer?: string) => {
150
+ let firstInit: RequestInit = init;
151
+ if (opts.initFor) {
152
+ const built = sessionToken ? await opts.initFor(sessionToken) : null;
153
+ if (!built) return { ok: false, status: 0, json: null, attemptedRefresh: false, skipped: true };
154
+ firstInit = built;
155
+ }
156
+
157
+ const makeCall = async (bearer?: string, callInit: RequestInit = firstInit) => {
132
158
  // NOTE (307290): this fetch is UNGUARDED, and so is the first call to it at
133
159
  // the "First attempt" below. Only the JSON PARSE (try/catch immediately
134
160
  // after) and the RETRY are guarded. A transport-level failure - DNS, refused,
@@ -141,8 +167,8 @@ export async function idpFetchJSON<T = any>(
141
167
  // carve-out. Correcting b4b8ffae's message, which wrongly said every risky
142
168
  // branch here is try/caught.
143
169
  const res = await fetch(targetUrl, {
144
- ...init,
145
- headers: buildHeaders(req, bearer, init.headers as any),
170
+ ...callInit,
171
+ headers: buildHeaders(req, bearer, callInit.headers as any),
146
172
  });
147
173
  let json: any = null;
148
174
  try { json = await res.json(); } catch { json = null; }
@@ -158,13 +184,15 @@ export async function idpFetchJSON<T = any>(
158
184
  try {
159
185
  // Use centralized internal API helper for server-to-server calls
160
186
  const rf = await internalRefresh(req.headers.get('cookie') || '', sessionToken);
187
+ refreshStatus = rf.status;
161
188
  if (rf.ok) {
162
189
  const fresh = await getSession(sessionToken);
163
- ({ res, json } = await makeCall(fresh?.idpAccessToken));
190
+ const retryInit = opts.initFor ? await opts.initFor(sessionToken) : firstInit;
191
+ if (retryInit) ({ res, json } = await makeCall(fresh?.idpAccessToken, retryInit));
164
192
  }
165
- } catch {}
193
+ } catch { if (refreshStatus === undefined) refreshStatus = null; }
166
194
  }
167
195
 
168
196
  const retryAfterSeconds = parseRetryAfterSeconds(res.headers?.get?.('Retry-After'));
169
- return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
197
+ return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh, ...(refreshStatus !== undefined ? { refreshStatus } : {}), ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
170
198
  }
@@ -0,0 +1,66 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { idpFetchJSON } from './idp-fetch';
3
+ import { getSession, getBetterAuthSession } from './session-store';
4
+ import { ENV_CONFIG } from '../config/env';
5
+
6
+ /**
7
+ * 4.8.10 (BAPert 76255 item 1, 76289): sign-out must end the IdP grant, not only the portal session.
8
+ *
9
+ * Core: POST /api/Account/logout, Bearer, body { refresh_token } (DotNetPert 76259). It revokes that refresh token and blacklists the
10
+ * presented access token. The token is ALWAYS sent: with it omitted Core revokes the caller's NEWEST session, which on a
11
+ * multi-device user is another device's.
12
+ *
13
+ * Outcomes (all three are a SUCCESSFUL sign-out):
14
+ * 'revoked' the IdP answered 2xx.
15
+ * 'nothing-to-revoke' the session holds no IdP refresh token (a provisional pre-2FA session has none). No call is made, because
16
+ * without a token Core would pick the newest session.
17
+ * 'grant-already-dead' the revoke was refused (401/403) and the refresh that followed was answered 401: refresh.ts calls that
18
+ * "definitively invalid", the token is expired or revoked, so there is nothing left to end (BAPert 76289 item 3).
19
+ * THROWS for an UNKNOWN outcome: a transport error, a 5xx, a refresh that could not be attempted or failed some other way. The caller
20
+ * turns that into SESSION_NOT_ENDED.
21
+ *
22
+ * 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
23
+ * refresh token, and a body built earlier would name the old one (NightHawk 76288).
24
+ */
25
+ export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
26
+
27
+ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome> {
28
+ let resolvedSession: string | undefined;
29
+ const result = await idpFetchJSON(
30
+ req,
31
+ `${ENV_CONFIG.IDP_URL}/api/Account/logout`,
32
+ {},
33
+ {
34
+ initFor: async (token) => {
35
+ resolvedSession = token;
36
+ let session = await getSession(token);
37
+ if (!session?.idpRefreshToken) session = (await getBetterAuthSession(token)) ?? session;
38
+ const refreshToken = session?.idpRefreshToken;
39
+ if (!refreshToken) return null;
40
+ return {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ refresh_token: refreshToken }),
44
+ };
45
+ },
46
+ },
47
+ );
48
+ if (result.skipped) return 'nothing-to-revoke';
49
+ if (result.ok) return 'revoked';
50
+ if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession))) return 'grant-already-dead';
51
+ throw new Error(`IdP logout answered ${result.status}`);
52
+ }
53
+
54
+ /**
55
+ * A refresh 401 is not by itself "the grant is gone" (NightHawk 76295, BAPert 76297): refresh.ts also answers 401 for 'no session', for
56
+ * 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
57
+ * DISCARDS the stored refresh token and WRITES THE MARKER refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>'. The marker is the only
58
+ * 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
59
+ * IdP). So the grant counts as dead only when the session row exists AND carries that marker. Anything else is UNKNOWN.
60
+ */
61
+ async function grantWasDiscarded(sessionToken: string): Promise<boolean> {
62
+ let session = await getSession(sessionToken);
63
+ if (!session) session = await getBetterAuthSession(sessionToken);
64
+ if (!session) return false;
65
+ return String((session as any).refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
66
+ }
@@ -17,6 +17,7 @@
17
17
  import { NextRequest, NextResponse } from 'next/server';
18
18
  import { getSession } from '../../server/auth';
19
19
  import { deleteSessionEverywhere } from '../../lib/session-store';
20
+ import { revokeSessionAtIdp } from '../../lib/idp-logout';
20
21
  import {
21
22
  getSessionCookieName,
22
23
  getSecureSessionCookieName,
@@ -27,15 +28,29 @@ import {
27
28
  import { siteEvents, getClientIp } from '../../lib/site-logger';
28
29
  import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
29
30
 
30
- /** 4.8.9: answered (status 500) when the server-side session delete failed. The browser's cookies are cleared either way. */
31
+ /**
32
+ * 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
33
+ * cause, neither promising a retry (the local session is gone, so there is nothing to retry from). The browser's cookies and the local
34
+ * session are cleared either way.
35
+ * SESSION_NOT_ENDED_IDP_MESSAGE the IdP outcome is UNKNOWN (revoke refused or unreachable, and no IdP discard marker proves the grant dead)
36
+ * SESSION_NOT_ENDED_LOCAL_MESSAGE the local session-store DELETE failed (the server record is still live)
37
+ * Both causes at once: the LOCAL sentence, because a live server record is the worse and more actionable fact.
38
+ */
31
39
  export const SESSION_NOT_ENDED_CODE = 'SESSION_NOT_ENDED';
32
- export 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.';
40
+ /**
41
+ * The machine-readable cause on the 500 body (Nextpert-Scout 76300): the portal never reads response text, so it picks its notice from
42
+ * this closed set. `code` stays SESSION_NOT_ENDED for 4.8.9 callers; `cause` is additive. Both causes at once: local_delete_failed.
43
+ * A dead-grant or ordinary success carries neither.
44
+ */
45
+ export type SessionNotEndedCause = 'idp_unconfirmed' | 'local_delete_failed';
46
+ export const SESSION_NOT_ENDED_IDP_MESSAGE = 'We could not confirm the sign-out with the identity service.';
47
+ export const SESSION_NOT_ENDED_LOCAL_MESSAGE = 'We could not finish signing you out on this device.';
33
48
 
34
49
  /**
35
50
  * POST /api/auth/logout - Sign out and clean up session
36
51
  *
37
52
  * Performs complete logout:
38
- * 1. Revokes tokens at IDP (if refresh token available)
53
+ * 1. Revokes the refresh token at the IDP (POST /api/Account/logout) when the session has one; a failed revoke answers SESSION_NOT_ENDED
39
54
  * 2. Deletes session from store
40
55
  * 3. Clears session cookies
41
56
  */
@@ -54,7 +69,19 @@ export async function POST(req: NextRequest) {
54
69
  const sessionId = session.session?.token;
55
70
  // 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
56
71
  // cookies are still cleared, but the answer is then a typed failure instead of success:true.
57
- let serverDeleteFailed = false;
72
+ let localDeleteFailed = false;
73
+ let idpRevokeFailed = false;
74
+
75
+ // 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
76
+ // delete: the IdP refresh token is still valid, so the sign-out did not end the session. The local delete below still runs.
77
+ if (sessionId) {
78
+ try {
79
+ await revokeSessionAtIdp(req);
80
+ } catch (error) {
81
+ idpRevokeFailed = true;
82
+ console.error('[LOGOUT_ROUTE] IdP revoke failed - the IdP refresh token is STILL LIVE:', error);
83
+ }
84
+ }
58
85
 
59
86
  // Delete session from EVERY store — canonical + BA secondary (181922).
60
87
  // Canonical-only deletion let the ba: record resurrect the session.
@@ -63,7 +90,7 @@ export async function POST(req: NextRequest) {
63
90
  await deleteSessionEverywhere(sessionId);
64
91
  console.info('[LOGOUT_ROUTE] Session deleted from store');
65
92
  } catch (error) {
66
- serverDeleteFailed = true;
93
+ localDeleteFailed = true;
67
94
  console.error('[LOGOUT_ROUTE] Failed to delete session - the server session is STILL LIVE:', error);
68
95
  }
69
96
  }
@@ -82,8 +109,13 @@ export async function POST(req: NextRequest) {
82
109
  }
83
110
 
84
111
  // Build response that clears session cookies
85
- const response = serverDeleteFailed
86
- ? NextResponse.json({ success: false, code: SESSION_NOT_ENDED_CODE, message: SESSION_NOT_ENDED_MESSAGE }, { status: 500 })
112
+ const response = (localDeleteFailed || idpRevokeFailed)
113
+ ? NextResponse.json(
114
+ localDeleteFailed
115
+ ? { success: false, code: SESSION_NOT_ENDED_CODE, cause: 'local_delete_failed' as SessionNotEndedCause, message: SESSION_NOT_ENDED_LOCAL_MESSAGE }
116
+ : { success: false, code: SESSION_NOT_ENDED_CODE, cause: 'idp_unconfirmed' as SessionNotEndedCause, message: SESSION_NOT_ENDED_IDP_MESSAGE },
117
+ { status: 500 },
118
+ )
87
119
  : NextResponse.json({
88
120
  success: true,
89
121
  message: 'Logged out successfully'
@@ -442,7 +442,7 @@ export async function getFreshIdpToken(
442
442
  },
443
443
  {
444
444
  safetyWindowMs: config.safetyWindowMs,
445
- requestId: request?.headers?.get('x-request-id') ?? undefined,
445
+ // 4.8.10 (BAPert 76298 (b)): the request's x-request-id is NOT passed. It used to become the refresh lock's identity.
446
446
  }
447
447
  );
448
448