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