@payez/next-mvp 4.8.8 → 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 +1 -1
- package/dist/lib/ensure-fresh-access-token.d.ts +4 -1
- package/dist/lib/ensure-fresh-access-token.js +11 -3
- package/dist/lib/idp-fetch.d.ts +17 -1
- package/dist/lib/idp-fetch.js +21 -7
- package/dist/lib/idp-logout.d.ts +22 -0
- package/dist/lib/idp-logout.js +47 -0
- package/dist/routes/auth/logout.d.ts +18 -1
- package/dist/routes/auth/logout.js +39 -6
- package/dist/server/auth.js +1 -1
- package/package.json +1 -1
- package/src/lib/ensure-fresh-access-token.ts +15 -4
- package/src/lib/idp-fetch.ts +35 -7
- package/src/lib/idp-logout.ts +66 -0
- package/src/routes/auth/logout.ts +48 -6
- package/src/server/auth.ts +1 -1
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/lib/idp-fetch.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/lib/idp-fetch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
155
|
-
headers: buildHeaders(req, bearer,
|
|
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
|
-
|
|
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,11 +14,28 @@
|
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
16
|
import { NextRequest, NextResponse } from 'next/server';
|
|
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
|
+
*/
|
|
25
|
+
export declare const SESSION_NOT_ENDED_CODE = "SESSION_NOT_ENDED";
|
|
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.";
|
|
17
34
|
/**
|
|
18
35
|
* POST /api/auth/logout - Sign out and clean up session
|
|
19
36
|
*
|
|
20
37
|
* Performs complete logout:
|
|
21
|
-
* 1. Revokes
|
|
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
|
|
22
39
|
* 2. Deletes session from store
|
|
23
40
|
* 3. Clears session cookies
|
|
24
41
|
*/
|
|
@@ -15,18 +15,31 @@
|
|
|
15
15
|
* @since auth-ready-v2
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.SESSION_NOT_ENDED_LOCAL_MESSAGE = exports.SESSION_NOT_ENDED_IDP_MESSAGE = exports.SESSION_NOT_ENDED_CODE = void 0;
|
|
18
19
|
exports.POST = POST;
|
|
19
20
|
const server_1 = require("next/server");
|
|
20
21
|
const auth_1 = require("../../server/auth");
|
|
21
22
|
const session_store_1 = require("../../lib/session-store");
|
|
23
|
+
const idp_logout_1 = require("../../lib/idp-logout");
|
|
22
24
|
const app_slug_1 = require("../../lib/app-slug");
|
|
23
25
|
const site_logger_1 = require("../../lib/site-logger");
|
|
24
26
|
const safe_error_response_1 = require("../../lib/safe-error-response");
|
|
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
|
+
*/
|
|
35
|
+
exports.SESSION_NOT_ENDED_CODE = 'SESSION_NOT_ENDED';
|
|
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.';
|
|
25
38
|
/**
|
|
26
39
|
* POST /api/auth/logout - Sign out and clean up session
|
|
27
40
|
*
|
|
28
41
|
* Performs complete logout:
|
|
29
|
-
* 1. Revokes
|
|
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
|
|
30
43
|
* 2. Deletes session from store
|
|
31
44
|
* 3. Clears session cookies
|
|
32
45
|
*/
|
|
@@ -41,6 +54,21 @@ async function POST(req) {
|
|
|
41
54
|
});
|
|
42
55
|
}
|
|
43
56
|
const sessionId = session.session?.token;
|
|
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
|
|
58
|
+
// cookies are still cleared, but the answer is then a typed failure instead of success:true.
|
|
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
|
+
}
|
|
44
72
|
// Delete session from EVERY store — canonical + BA secondary (181922).
|
|
45
73
|
// Canonical-only deletion let the ba: record resurrect the session.
|
|
46
74
|
if (sessionId) {
|
|
@@ -49,7 +77,8 @@ async function POST(req) {
|
|
|
49
77
|
console.info('[LOGOUT_ROUTE] Session deleted from store');
|
|
50
78
|
}
|
|
51
79
|
catch (error) {
|
|
52
|
-
|
|
80
|
+
localDeleteFailed = true;
|
|
81
|
+
console.error('[LOGOUT_ROUTE] Failed to delete session - the server session is STILL LIVE:', error);
|
|
53
82
|
}
|
|
54
83
|
}
|
|
55
84
|
// Log logout event (fire-and-forget)
|
|
@@ -65,10 +94,14 @@ async function POST(req) {
|
|
|
65
94
|
});
|
|
66
95
|
}
|
|
67
96
|
// Build response that clears session cookies
|
|
68
|
-
const response =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 })
|
|
101
|
+
: server_1.NextResponse.json({
|
|
102
|
+
success: true,
|
|
103
|
+
message: 'Logged out successfully'
|
|
104
|
+
});
|
|
72
105
|
// Clear NextAuth session cookies (using app-slug prefixed names)
|
|
73
106
|
const cookieNames = [
|
|
74
107
|
(0, app_slug_1.getSessionCookieName)(),
|
package/dist/server/auth.js
CHANGED
|
@@ -392,7 +392,7 @@ async function getFreshIdpToken(request, config) {
|
|
|
392
392
|
refreshEndpoint: config.refreshEndpoint,
|
|
393
393
|
}, {
|
|
394
394
|
safetyWindowMs: config.safetyWindowMs,
|
|
395
|
-
|
|
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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/lib/idp-fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
145
|
-
headers: buildHeaders(req, bearer,
|
|
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
|
-
|
|
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,11 +28,29 @@ import {
|
|
|
27
28
|
import { siteEvents, getClientIp } from '../../lib/site-logger';
|
|
28
29
|
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
29
30
|
|
|
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
|
+
*/
|
|
39
|
+
export const SESSION_NOT_ENDED_CODE = 'SESSION_NOT_ENDED';
|
|
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.';
|
|
48
|
+
|
|
30
49
|
/**
|
|
31
50
|
* POST /api/auth/logout - Sign out and clean up session
|
|
32
51
|
*
|
|
33
52
|
* Performs complete logout:
|
|
34
|
-
* 1. Revokes
|
|
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
|
|
35
54
|
* 2. Deletes session from store
|
|
36
55
|
* 3. Clears session cookies
|
|
37
56
|
*/
|
|
@@ -48,6 +67,21 @@ export async function POST(req: NextRequest) {
|
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
const sessionId = session.session?.token;
|
|
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
|
|
71
|
+
// cookies are still cleared, but the answer is then a typed failure instead of success:true.
|
|
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
|
+
}
|
|
51
85
|
|
|
52
86
|
// Delete session from EVERY store — canonical + BA secondary (181922).
|
|
53
87
|
// Canonical-only deletion let the ba: record resurrect the session.
|
|
@@ -56,7 +90,8 @@ export async function POST(req: NextRequest) {
|
|
|
56
90
|
await deleteSessionEverywhere(sessionId);
|
|
57
91
|
console.info('[LOGOUT_ROUTE] Session deleted from store');
|
|
58
92
|
} catch (error) {
|
|
59
|
-
|
|
93
|
+
localDeleteFailed = true;
|
|
94
|
+
console.error('[LOGOUT_ROUTE] Failed to delete session - the server session is STILL LIVE:', error);
|
|
60
95
|
}
|
|
61
96
|
}
|
|
62
97
|
|
|
@@ -74,10 +109,17 @@ export async function POST(req: NextRequest) {
|
|
|
74
109
|
}
|
|
75
110
|
|
|
76
111
|
// Build response that clears session cookies
|
|
77
|
-
const response =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
)
|
|
119
|
+
: NextResponse.json({
|
|
120
|
+
success: true,
|
|
121
|
+
message: 'Logged out successfully'
|
|
122
|
+
});
|
|
81
123
|
|
|
82
124
|
// Clear NextAuth session cookies (using app-slug prefixed names)
|
|
83
125
|
const cookieNames = [
|
package/src/server/auth.ts
CHANGED
|
@@ -442,7 +442,7 @@ export async function getFreshIdpToken(
|
|
|
442
442
|
},
|
|
443
443
|
{
|
|
444
444
|
safetyWindowMs: config.safetyWindowMs,
|
|
445
|
-
|
|
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
|
|