@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.
- package/CHANGELOG.md +105 -0
- package/README.md +1 -1
- package/dist/api-handlers/auth/refresh.d.ts +2 -0
- package/dist/api-handlers/auth/refresh.js +107 -31
- package/dist/lib/ensure-fresh-access-token.d.ts +4 -1
- package/dist/lib/ensure-fresh-access-token.js +72 -4
- package/dist/lib/idp-fetch.d.ts +17 -1
- package/dist/lib/idp-fetch.js +21 -7
- package/dist/lib/idp-logout.d.ts +24 -0
- package/dist/lib/idp-logout.js +47 -0
- package/dist/lib/refresh-rotation.d.ts +19 -0
- package/dist/lib/refresh-rotation.js +18 -0
- package/dist/lib/session-store.d.ts +0 -5
- package/dist/lib/session-store.js +18 -1
- package/dist/routes/auth/logout.d.ts +17 -3
- package/dist/routes/auth/logout.js +31 -8
- package/dist/routes/auth/refresh.d.ts +1 -0
- package/dist/server/auth.js +1 -1
- package/package.json +3 -12
- package/src/api-handlers/auth/refresh.ts +107 -31
- package/src/lib/ensure-fresh-access-token.ts +77 -5
- package/src/lib/idp-fetch.ts +35 -7
- package/src/lib/idp-logout.ts +68 -0
- package/src/lib/refresh-rotation.ts +18 -0
- package/src/lib/session-store.ts +19 -1
- package/src/routes/auth/logout.ts +39 -7
- package/src/server/auth.ts +1 -1
- package/dist/api/auth-handler.d.ts +0 -64
- package/dist/api/auth-handler.js +0 -402
- package/dist/api/index.d.ts +0 -10
- package/dist/api/index.js +0 -19
- package/src/api/auth-handler.ts +0 -551
- package/src/api/index.ts +0 -18
|
@@ -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,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
} from './session-store';
|
|
24
25
|
import { computeTokenExpiries } from './token-expiry';
|
|
25
26
|
import { extractKidFromToken } from '../auth/utils/token-utils';
|
|
27
|
+
import { rotatedRowIsUsable } from './refresh-rotation';
|
|
26
28
|
|
|
27
29
|
export interface EnsureFreshConfig {
|
|
28
30
|
idpBaseUrl: string;
|
|
@@ -35,7 +37,10 @@ export interface EnsureFreshOptions {
|
|
|
35
37
|
safetyWindowMs?: number;
|
|
36
38
|
/** Max wait while another caller holds the refresh lock. Default 5000. */
|
|
37
39
|
lockWaitMs?: number;
|
|
38
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
|
|
42
|
+
* crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
|
|
43
|
+
*/
|
|
39
44
|
requestId?: string;
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -106,7 +111,13 @@ export async function ensureFreshAccessToken(
|
|
|
106
111
|
const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
|
|
107
112
|
const safetyWindowMs = options.safetyWindowMs ?? DEFAULT_SAFETY_WINDOW_MS;
|
|
108
113
|
const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
|
|
109
|
-
|
|
114
|
+
// LOCK IDENTITY IS MINTED SERVER-SIDE (BAPert 76298 (b)), exactly as api-handlers/auth/refresh.ts does. It used to be
|
|
115
|
+
// `options.requestId ?? ensure_fresh_${Date.now()}`, and server/auth.ts passed the request's x-request-id header, so a caller chose the
|
|
116
|
+
// value that the branch below compared against the lock holder's id. A lock that protects against refresh-token REUSE (the IdP treats
|
|
117
|
+
// reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
|
|
118
|
+
// collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
|
|
119
|
+
const requestId = `ensure_fresh_${randomUUID()}`;
|
|
120
|
+
const traceId = options.requestId;
|
|
110
121
|
|
|
111
122
|
let session = await getSession(sessionToken);
|
|
112
123
|
if (!session || !session.idpRefreshToken) {
|
|
@@ -159,8 +170,7 @@ export async function ensureFreshAccessToken(
|
|
|
159
170
|
let releaseVersion: number | undefined;
|
|
160
171
|
|
|
161
172
|
if (!lock.acquired) {
|
|
162
|
-
|
|
163
|
-
if (!existing || existing.acquiredBy !== requestId) {
|
|
173
|
+
{
|
|
164
174
|
const startWait = Date.now();
|
|
165
175
|
while (Date.now() - startWait < lockWaitMs) {
|
|
166
176
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -336,6 +346,7 @@ export async function ensureFreshAccessToken(
|
|
|
336
346
|
console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
|
|
337
347
|
code,
|
|
338
348
|
requestId,
|
|
349
|
+
traceId,
|
|
339
350
|
});
|
|
340
351
|
}
|
|
341
352
|
} else {
|
|
@@ -343,7 +354,7 @@ export async function ensureFreshAccessToken(
|
|
|
343
354
|
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
344
355
|
// (race-fix follow-up #1).
|
|
345
356
|
discardToken = false;
|
|
346
|
-
if (postRefresh
|
|
357
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
347
358
|
return {
|
|
348
359
|
ok: true,
|
|
349
360
|
accessToken: postRefresh.idpAccessToken,
|
|
@@ -353,6 +364,17 @@ export async function ensureFreshAccessToken(
|
|
|
353
364
|
persisted: true,
|
|
354
365
|
};
|
|
355
366
|
}
|
|
367
|
+
// Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept, no
|
|
368
|
+
// marker. Same meaning in every branch (lib/refresh-rotation).
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
code,
|
|
372
|
+
message: idpError.message || 'Token refresh failed',
|
|
373
|
+
status: 502,
|
|
374
|
+
discardToken: false,
|
|
375
|
+
retryable: true,
|
|
376
|
+
resolution: idpError.resolution,
|
|
377
|
+
};
|
|
356
378
|
}
|
|
357
379
|
}
|
|
358
380
|
return {
|
|
@@ -368,6 +390,55 @@ export async function ensureFreshAccessToken(
|
|
|
368
390
|
}
|
|
369
391
|
|
|
370
392
|
// Validate canonical envelope.
|
|
393
|
+
// 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations (api-handlers/auth/refresh.ts has the twin of
|
|
394
|
+
// this block). A 200 envelope with success:false used to be a generic 502 here, with discard_token ignored, while the other twin
|
|
395
|
+
// honoured it. Now: success:false + discard_token discards the stored token (same CAS as the HTTP-401 branch above, same
|
|
396
|
+
// IDP_DISCARD_TOKEN marker) and answers terminal 401; success:false without discard_token is a retryable 502 and the token is KEPT.
|
|
397
|
+
if (responseData && typeof responseData === 'object' && responseData.success === false) {
|
|
398
|
+
const idpError = responseData.error || {};
|
|
399
|
+
const code = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
|
|
400
|
+
if (idpError.discard_token === true) {
|
|
401
|
+
const postRefresh = await getSession(sessionToken);
|
|
402
|
+
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
403
|
+
if (tokenUnchanged) {
|
|
404
|
+
await updateSession(sessionToken, {
|
|
405
|
+
idpRefreshToken: '',
|
|
406
|
+
idpRefreshTokenExpires: undefined,
|
|
407
|
+
refreshTokenClearedAt: Date.now(),
|
|
408
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
code,
|
|
413
|
+
message: idpError.message || 'Token refresh failed',
|
|
414
|
+
status: 401,
|
|
415
|
+
discardToken: true,
|
|
416
|
+
retryable: false,
|
|
417
|
+
resolution: idpError.resolution,
|
|
418
|
+
terminal: true,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
accessToken: postRefresh.idpAccessToken,
|
|
425
|
+
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
426
|
+
refreshed: true,
|
|
427
|
+
persisted: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
code,
|
|
434
|
+
message: idpError.message || 'Token refresh failed',
|
|
435
|
+
status: 502,
|
|
436
|
+
discardToken: false,
|
|
437
|
+
retryable: true,
|
|
438
|
+
resolution: idpError.resolution,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
371
442
|
if (
|
|
372
443
|
!responseData ||
|
|
373
444
|
typeof responseData !== 'object' ||
|
|
@@ -480,6 +551,7 @@ export async function ensureFreshAccessToken(
|
|
|
480
551
|
if (!persisted) {
|
|
481
552
|
console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
|
|
482
553
|
requestId,
|
|
554
|
+
traceId,
|
|
483
555
|
refreshTokenRotated: hasNewRefresh,
|
|
484
556
|
persisted: false,
|
|
485
557
|
});
|
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,68 @@
|
|
|
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 AND the refresh that followed was answered 401 AND the session carries the IdP's own
|
|
18
|
+
* discard marker (refreshTokenClearedReason starting 'IDP_DISCARD_TOKEN'). A refresh 401 alone is NOT proof
|
|
19
|
+
* (refresh.ts also answers 401 for 'no session', the x-session-token rejection and NO_REFRESH_TOKEN), and neither
|
|
20
|
+
* is an empty token (BAPert 76297). Anything else is UNKNOWN.
|
|
21
|
+
* THROWS for an UNKNOWN outcome: a transport error, a 5xx, a refresh that could not be attempted or failed some other way. The caller
|
|
22
|
+
* turns that into SESSION_NOT_ENDED.
|
|
23
|
+
*
|
|
24
|
+
* 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
|
|
25
|
+
* refresh token, and a body built earlier would name the old one (NightHawk 76288).
|
|
26
|
+
*/
|
|
27
|
+
export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
|
|
28
|
+
|
|
29
|
+
export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome> {
|
|
30
|
+
let resolvedSession: string | undefined;
|
|
31
|
+
const result = await idpFetchJSON(
|
|
32
|
+
req,
|
|
33
|
+
`${ENV_CONFIG.IDP_URL}/api/Account/logout`,
|
|
34
|
+
{},
|
|
35
|
+
{
|
|
36
|
+
initFor: async (token) => {
|
|
37
|
+
resolvedSession = token;
|
|
38
|
+
let session = await getSession(token);
|
|
39
|
+
if (!session?.idpRefreshToken) session = (await getBetterAuthSession(token)) ?? session;
|
|
40
|
+
const refreshToken = session?.idpRefreshToken;
|
|
41
|
+
if (!refreshToken) return null;
|
|
42
|
+
return {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
if (result.skipped) return 'nothing-to-revoke';
|
|
51
|
+
if (result.ok) return 'revoked';
|
|
52
|
+
if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession))) return 'grant-already-dead';
|
|
53
|
+
throw new Error(`IdP logout answered ${result.status}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A refresh 401 is not by itself "the grant is gone" (NightHawk 76295, BAPert 76297): refresh.ts also answers 401 for 'no session', for
|
|
58
|
+
* 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
|
|
59
|
+
* DISCARDS the stored refresh token and WRITES THE MARKER refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>'. The marker is the only
|
|
60
|
+
* 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
|
|
61
|
+
* IdP). So the grant counts as dead only when the session row exists AND carries that marker. Anything else is UNKNOWN.
|
|
62
|
+
*/
|
|
63
|
+
async function grantWasDiscarded(sessionToken: string): Promise<boolean> {
|
|
64
|
+
let session = await getSession(sessionToken);
|
|
65
|
+
if (!session) session = await getBetterAuthSession(sessionToken);
|
|
66
|
+
if (!session) return false;
|
|
67
|
+
return String((session as any).refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
|
|
68
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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 function rotatedRowIsUsable<T extends { idpAccessToken?: string | null; idpAccessTokenExpires?: number | null }>(
|
|
14
|
+
session: T | null | undefined,
|
|
15
|
+
now: number = Date.now(),
|
|
16
|
+
): session is T & { idpAccessToken: string; idpAccessTokenExpires: number } {
|
|
17
|
+
return !!(session && session.idpAccessToken && session.idpAccessTokenExpires && session.idpAccessTokenExpires > now);
|
|
18
|
+
}
|
package/src/lib/session-store.ts
CHANGED
|
@@ -284,7 +284,25 @@ export async function deleteSessionEverywhere(sessionToken: string, appSlug?: st
|
|
|
284
284
|
* @param sessionToken The session token.
|
|
285
285
|
* @param data The session data.
|
|
286
286
|
*/
|
|
287
|
+
/**
|
|
288
|
+
* 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`)
|
|
289
|
+
* writes `idpRefreshToken: ''` TOGETHER WITH `refreshTokenClearedReason: 'IDP_DISCARD_TOKEN:<code>'`; logout's dead-grant check reads that marker
|
|
290
|
+
* 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
|
|
291
|
+
* 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
|
|
292
|
+
* 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
|
|
293
|
+
* today; this is the defence BAPert ruled to keep anyway.
|
|
294
|
+
*/
|
|
295
|
+
function dropStaleDiscardMarker<T extends object>(session: T): T {
|
|
296
|
+
const s = session as any;
|
|
297
|
+
if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
|
|
298
|
+
const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
|
|
299
|
+
return rest as T;
|
|
300
|
+
}
|
|
301
|
+
return session;
|
|
302
|
+
}
|
|
303
|
+
|
|
287
304
|
export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
|
|
305
|
+
data = dropStaleDiscardMarker(data);
|
|
288
306
|
const key = getSessionKey(sessionToken);
|
|
289
307
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
290
308
|
await redis.multi()
|
|
@@ -424,7 +442,7 @@ export async function updateSession(
|
|
|
424
442
|
}
|
|
425
443
|
|
|
426
444
|
// Merge current session with updates
|
|
427
|
-
const updatedSession = { ...currentSession, ...updates };
|
|
445
|
+
const updatedSession = dropStaleDiscardMarker({ ...currentSession, ...updates });
|
|
428
446
|
|
|
429
447
|
// Write the entire updated session back to Redis with version increment
|
|
430
448
|
await redis.multi()
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
86
|
-
? NextResponse.json(
|
|
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'
|
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
|
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Enhanced Auth Handler with Coordinated Token Refresh
|
|
3
|
-
*
|
|
4
|
-
* Provides a middleware wrapper that automatically handles token lifecycle:
|
|
5
|
-
* - Checks token expiry before each request
|
|
6
|
-
* - Automatically refreshes expired or near-expired tokens
|
|
7
|
-
* - Uses Redis locks for coordinated refresh (prevents race conditions)
|
|
8
|
-
* - Retries requests on 401 responses with fresh tokens
|
|
9
|
-
*
|
|
10
|
-
* Pattern ported from website-membership simple-api-handler.ts
|
|
11
|
-
*
|
|
12
|
-
* @version 2.1.0
|
|
13
|
-
* @since auth-ready-v2
|
|
14
|
-
*/
|
|
15
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
-
export interface AuthContext {
|
|
17
|
-
token: any;
|
|
18
|
-
accessToken: string;
|
|
19
|
-
userId: string;
|
|
20
|
-
sessionToken: string;
|
|
21
|
-
refreshToken?: string;
|
|
22
|
-
}
|
|
23
|
-
export interface AuthHandlerOptions {
|
|
24
|
-
/** Whether authentication is required for this route (default: true) */
|
|
25
|
-
requireAuth?: boolean;
|
|
26
|
-
/** Automatically refresh expired or near-expired tokens (default: true) */
|
|
27
|
-
autoRefresh?: boolean;
|
|
28
|
-
/** Buffer time in seconds before token expiry to trigger refresh (default: 300 = 5 minutes) */
|
|
29
|
-
refreshBuffer?: number;
|
|
30
|
-
/** Retry request on 401 response after refreshing token (default: true) */
|
|
31
|
-
retryOn401?: boolean;
|
|
32
|
-
/** Maximum number of retry attempts on 401 (default: 1) */
|
|
33
|
-
maxRetries?: number;
|
|
34
|
-
/** IDP base URL for refresh requests */
|
|
35
|
-
idpBaseUrl?: string;
|
|
36
|
-
/** OAuth client ID */
|
|
37
|
-
clientId?: string;
|
|
38
|
-
}
|
|
39
|
-
export type HandlerFunction = (req: NextRequest, context: any, auth: AuthContext) => Promise<NextResponse | Response>;
|
|
40
|
-
/**
|
|
41
|
-
* Creates an auth-aware handler with automatic token refresh
|
|
42
|
-
*
|
|
43
|
-
* @example
|
|
44
|
-
* ```typescript
|
|
45
|
-
* import { createAuthHandler } from '@payez/next-mvp/api';
|
|
46
|
-
*
|
|
47
|
-
* const handler = createAuthHandler({ requireAuth: true });
|
|
48
|
-
*
|
|
49
|
-
* export const GET = handler.handle(async (req, context, auth) => {
|
|
50
|
-
* // auth.accessToken is guaranteed to be fresh
|
|
51
|
-
* const response = await fetch('https://api.example.com/data', {
|
|
52
|
-
* headers: { 'Authorization': `Bearer ${auth.accessToken}` }
|
|
53
|
-
* });
|
|
54
|
-
* return NextResponse.json(await response.json());
|
|
55
|
-
* });
|
|
56
|
-
* ```
|
|
57
|
-
*/
|
|
58
|
-
export declare function createAuthHandler(options?: AuthHandlerOptions): {
|
|
59
|
-
handle: (handler: HandlerFunction) => (req: NextRequest, context?: any) => Promise<Response>;
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Default export for convenience
|
|
63
|
-
*/
|
|
64
|
-
export default createAuthHandler;
|