@payez/next-mvp 4.9.0 → 4.11.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 +136 -0
- package/dist/api-handlers/auth/refresh.d.ts +17 -56
- package/dist/api-handlers/auth/refresh.js +71 -696
- package/dist/auth/utils/token-utils.d.ts +1 -1
- package/dist/auth/utils/token-utils.js +3 -2
- package/dist/lib/api-handler.js +4 -3
- package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
- package/dist/lib/ensure-fresh-access-token.js +255 -29
- package/dist/lib/idp-fetch.js +3 -2
- package/dist/lib/idp-logout.d.ts +4 -2
- package/dist/lib/refresh-in-doubt.d.ts +31 -0
- package/dist/lib/refresh-in-doubt.js +81 -0
- package/dist/lib/refresh-rotation.d.ts +19 -0
- package/dist/lib/refresh-rotation.js +18 -0
- package/dist/lib/refresh-token-validator.js +2 -1
- package/dist/lib/refresh-window.d.ts +16 -0
- package/dist/lib/refresh-window.js +19 -0
- package/dist/lib/safe-trace-id.d.ts +11 -0
- package/dist/lib/safe-trace-id.js +23 -0
- package/dist/lib/session-store.d.ts +0 -5
- package/dist/lib/session-store.js +28 -1
- package/dist/lib/token-lifecycle.js +3 -2
- package/dist/models/SessionModel.d.ts +7 -0
- package/dist/routes/auth/refresh.d.ts +1 -14
- package/dist/server/auth.d.ts +1 -1
- package/dist/stores/authStore.js +3 -2
- package/package.json +3 -12
- package/scripts/verify-release-provenance.mjs +1 -0
- package/src/api-handlers/auth/refresh.ts +72 -736
- package/src/auth/utils/token-utils.ts +3 -2
- package/src/lib/api-handler.ts +4 -3
- package/src/lib/ensure-fresh-access-token.ts +275 -30
- package/src/lib/idp-fetch.ts +3 -2
- package/src/lib/idp-logout.ts +4 -2
- package/src/lib/refresh-in-doubt.ts +79 -0
- package/src/lib/refresh-rotation.ts +18 -0
- package/src/lib/refresh-token-validator.ts +2 -1
- package/src/lib/refresh-window.ts +16 -0
- package/src/lib/safe-trace-id.ts +21 -0
- package/src/lib/session-store.ts +31 -1
- package/src/lib/token-lifecycle.ts +3 -2
- package/src/models/SessionModel.ts +7 -0
- package/src/server/auth.ts +1 -1
- package/src/stores/authStore.ts +3 -2
- 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
|
@@ -46,7 +46,7 @@ export declare function extractAmrFromToken(decoded: DecodedIdpAccessToken): str
|
|
|
46
46
|
* Check if a token expiry timestamp indicates the token needs refresh.
|
|
47
47
|
*
|
|
48
48
|
* @param expiresAt - Token expiry timestamp (Unix milliseconds)
|
|
49
|
-
* @param bufferMs - How early to trigger refresh (default 5 minutes)
|
|
49
|
+
* @param bufferMs - How early to trigger refresh (default: the one refresh window, `REFRESH_WINDOW_MS`, 5 minutes)
|
|
50
50
|
* @returns true if token is expired or will expire within buffer period
|
|
51
51
|
*/
|
|
52
52
|
export declare function tokenNeedsRefresh(expiresAt: number | undefined, bufferMs?: number): boolean;
|
|
@@ -20,6 +20,7 @@ exports.msUntilExpiry = msUntilExpiry;
|
|
|
20
20
|
exports.expClaimToMs = expClaimToMs;
|
|
21
21
|
exports.validateTokenExpiry = validateTokenExpiry;
|
|
22
22
|
const jwt_decode_1 = require("../../lib/jwt-decode");
|
|
23
|
+
const refresh_window_1 = require("../../lib/refresh-window");
|
|
23
24
|
// Re-export header utilities for consumers
|
|
24
25
|
var jwt_decode_2 = require("../../lib/jwt-decode");
|
|
25
26
|
Object.defineProperty(exports, "decodeJwtHeader", { enumerable: true, get: function () { return jwt_decode_2.decodeJwtHeader; } });
|
|
@@ -129,10 +130,10 @@ function extractAmrFromToken(decoded) {
|
|
|
129
130
|
* Check if a token expiry timestamp indicates the token needs refresh.
|
|
130
131
|
*
|
|
131
132
|
* @param expiresAt - Token expiry timestamp (Unix milliseconds)
|
|
132
|
-
* @param bufferMs - How early to trigger refresh (default 5 minutes)
|
|
133
|
+
* @param bufferMs - How early to trigger refresh (default: the one refresh window, `REFRESH_WINDOW_MS`, 5 minutes)
|
|
133
134
|
* @returns true if token is expired or will expire within buffer period
|
|
134
135
|
*/
|
|
135
|
-
function tokenNeedsRefresh(expiresAt, bufferMs =
|
|
136
|
+
function tokenNeedsRefresh(expiresAt, bufferMs = refresh_window_1.REFRESH_WINDOW_MS) {
|
|
136
137
|
if (!expiresAt) {
|
|
137
138
|
return true; // No expiry info = assume needs refresh
|
|
138
139
|
}
|
package/dist/lib/api-handler.js
CHANGED
|
@@ -21,6 +21,7 @@ const nanoid_1 = require("nanoid");
|
|
|
21
21
|
const session_store_1 = require("./session-store");
|
|
22
22
|
const test_aware_get_token_1 = require("./test-aware-get-token");
|
|
23
23
|
const internal_api_1 = require("./internal-api");
|
|
24
|
+
const refresh_window_1 = require("./refresh-window");
|
|
24
25
|
// ============================================================================
|
|
25
26
|
// API Response Builder (for backward compatibility)
|
|
26
27
|
// ============================================================================
|
|
@@ -189,7 +190,7 @@ class ApiHandler {
|
|
|
189
190
|
// Skip the optimization (hasRefreshToken check) when access token is missing —
|
|
190
191
|
// some session shapes store the refresh token under different field names,
|
|
191
192
|
// and we still want a refresh attempt to populate the access token.
|
|
192
|
-
const thresholdMs =
|
|
193
|
+
const thresholdMs = refresh_window_1.REFRESH_WINDOW_MS;
|
|
193
194
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
194
195
|
const expires = sessionData.idpAccessTokenExpires || sessionData.accessTokenExpires || 0;
|
|
195
196
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -305,7 +306,7 @@ class ApiHandler {
|
|
|
305
306
|
try {
|
|
306
307
|
// Double-check if still needs refresh
|
|
307
308
|
const latest = await (0, session_store_1.getSession)(sessionToken);
|
|
308
|
-
const thresholdMs =
|
|
309
|
+
const thresholdMs = refresh_window_1.REFRESH_WINDOW_MS;
|
|
309
310
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
310
311
|
const latestAny = latest;
|
|
311
312
|
// Sessions may store the access token under either `accessToken` or `idpAccessToken`,
|
|
@@ -348,7 +349,7 @@ class ApiHandler {
|
|
|
348
349
|
const session = await (0, session_store_1.getSession)(sessionToken);
|
|
349
350
|
if (session?.accessToken) {
|
|
350
351
|
const expires = session.accessTokenExpires || 0;
|
|
351
|
-
if ((expires - Date.now()) >
|
|
352
|
+
if ((expires - Date.now()) > refresh_window_1.REFRESH_WINDOW_MS) {
|
|
352
353
|
return { success: true };
|
|
353
354
|
}
|
|
354
355
|
}
|
|
@@ -4,10 +4,15 @@ export interface EnsureFreshConfig {
|
|
|
4
4
|
refreshEndpoint?: string;
|
|
5
5
|
}
|
|
6
6
|
export interface EnsureFreshOptions {
|
|
7
|
-
/** Refresh if the access token is within this many ms of expiry. Default
|
|
7
|
+
/** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
|
|
8
8
|
safetyWindowMs?: number;
|
|
9
9
|
/** Max wait while another caller holds the refresh lock. Default 5000. */
|
|
10
10
|
lockWaitMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* The refresh ROUTE sets this (BAPert 76449, R-over-E PR 2): a session with no refresh token is refused with the terminal NO_REFRESH_TOKEN
|
|
13
|
+
* even when its access token is still fresh, as the route always did. Other callers leave it unset and get `already_fresh` for a fresh token.
|
|
14
|
+
*/
|
|
15
|
+
requireRefreshToken?: boolean;
|
|
11
16
|
/**
|
|
12
17
|
* Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
|
|
13
18
|
* crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
|
|
@@ -20,6 +25,14 @@ export type EnsureFreshResult = {
|
|
|
20
25
|
accessTokenExpires: number;
|
|
21
26
|
/** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
|
|
22
27
|
refreshed: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* WHY this result is ok (BAPert 76449, additive; `api-handlers/auth/refresh` reproduces its bodies from it and `lib/token-lifecycle` reads
|
|
30
|
+
* `already_fresh`): 'already_fresh' = nothing was needed; 'completed_by_concurrent_request' = another request refreshed and we returned
|
|
31
|
+
* its result; 'refreshed' = THIS call refreshed.
|
|
32
|
+
*/
|
|
33
|
+
reason: 'refreshed' | 'already_fresh' | 'completed_by_concurrent_request';
|
|
34
|
+
/** Whether the stored session holds a refresh token (the refresh route reports it in its body). */
|
|
35
|
+
hasRefreshToken: boolean;
|
|
23
36
|
/**
|
|
24
37
|
* CARD 320072. Is the token in THIS result actually in the session store?
|
|
25
38
|
*
|
|
@@ -56,4 +69,14 @@ export type EnsureFreshResult = {
|
|
|
56
69
|
retryable?: boolean;
|
|
57
70
|
resolution?: string;
|
|
58
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
|
|
74
|
+
* `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
|
|
75
|
+
*/
|
|
76
|
+
export declare function checkRefreshEnvelope(body: any, httpOk: boolean): {
|
|
77
|
+
ok: true;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
why: string;
|
|
81
|
+
};
|
|
59
82
|
export declare function ensureFreshAccessToken(sessionToken: string, config: EnsureFreshConfig, options?: EnsureFreshOptions): Promise<EnsureFreshResult>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkRefreshEnvelope = checkRefreshEnvelope;
|
|
3
4
|
exports.ensureFreshAccessToken = ensureFreshAccessToken;
|
|
4
5
|
/**
|
|
5
6
|
* ensureFreshAccessToken — server-side preflight refresh.
|
|
@@ -20,7 +21,32 @@ const crypto_1 = require("crypto");
|
|
|
20
21
|
const session_store_1 = require("./session-store");
|
|
21
22
|
const token_expiry_1 = require("./token-expiry");
|
|
22
23
|
const token_utils_1 = require("../auth/utils/token-utils");
|
|
23
|
-
const
|
|
24
|
+
const refresh_rotation_1 = require("./refresh-rotation");
|
|
25
|
+
const refresh_window_1 = require("./refresh-window");
|
|
26
|
+
const safe_trace_id_1 = require("./safe-trace-id");
|
|
27
|
+
const refresh_in_doubt_1 = require("./refresh-in-doubt");
|
|
28
|
+
/**
|
|
29
|
+
* The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
|
|
30
|
+
* `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
|
|
31
|
+
*/
|
|
32
|
+
function checkRefreshEnvelope(body, httpOk) {
|
|
33
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
34
|
+
return { ok: false, why: 'body is not an object' };
|
|
35
|
+
if (typeof body.success !== 'boolean')
|
|
36
|
+
return { ok: false, why: 'success is not a boolean' };
|
|
37
|
+
if (body.success === true) {
|
|
38
|
+
if (!httpOk)
|
|
39
|
+
return { ok: false, why: 'success:true on a non-2xx' };
|
|
40
|
+
if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
|
|
41
|
+
return { ok: false, why: 'success:true without data.access_token' };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
|
|
45
|
+
return { ok: false, why: 'success:false without error.code' };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
const DEFAULT_SAFETY_WINDOW_MS = refresh_window_1.REFRESH_WINDOW_MS; // was 60_000; one window for R, E and idp-fetch (BAPert 76397 decision 1)
|
|
24
50
|
const DEFAULT_LOCK_WAIT_MS = 5000;
|
|
25
51
|
function decodeJwtExp(token) {
|
|
26
52
|
const parts = token.split('.');
|
|
@@ -44,37 +70,58 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
44
70
|
// reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
|
|
45
71
|
// collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
|
|
46
72
|
const requestId = `ensure_fresh_${(0, crypto_1.randomUUID)()}`;
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
// Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
|
|
74
|
+
const traceId = (0, safe_trace_id_1.sanitizeTraceId)(options.requestId);
|
|
75
|
+
const canonicalFirst = await (0, session_store_1.getSession)(sessionToken);
|
|
76
|
+
let session = canonicalFirst;
|
|
49
77
|
if (!session || !session.idpRefreshToken) {
|
|
50
78
|
const baSession = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
51
79
|
if (baSession?.idpRefreshToken) {
|
|
52
80
|
session = baSession;
|
|
53
81
|
}
|
|
54
82
|
}
|
|
83
|
+
// `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
|
|
84
|
+
// persist writes.
|
|
85
|
+
const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
|
|
55
86
|
if (!session) {
|
|
56
87
|
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
57
88
|
}
|
|
89
|
+
if (options.requireRefreshToken && !session.idpRefreshToken) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: 'NO_REFRESH_TOKEN',
|
|
93
|
+
message: 'No refresh token available',
|
|
94
|
+
status: 401,
|
|
95
|
+
terminal: true,
|
|
96
|
+
resolution: 'User must re-authenticate',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
58
99
|
const storedToken = session.idpAccessToken;
|
|
59
100
|
if (!storedToken) {
|
|
60
101
|
// Card 187871: same conflation as getIdpToken (server/auth.ts) - a live
|
|
61
102
|
// session with no access token and mfaVerified !== true is a user
|
|
62
103
|
// mid-2FA, not dead. Only terminal once the session itself claims 2FA
|
|
63
104
|
// is already done.
|
|
105
|
+
//
|
|
106
|
+
// BAPert 76449 (A): a pending-2FA row keeps NO_TOKEN_PENDING_2FA EVEN WITH a refresh token: refresh must never be a way around 2FA.
|
|
107
|
+
// A row that is NOT pending 2FA and has a refresh token is refreshed below (a missing access token is the reason to refresh, as the
|
|
108
|
+
// refresh route always did); with no refresh token it is NO_TOKEN as before.
|
|
64
109
|
if (session.mfaVerified !== true) {
|
|
65
110
|
return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
|
|
66
111
|
}
|
|
67
|
-
|
|
112
|
+
if (!session.idpRefreshToken) {
|
|
113
|
+
return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
|
|
114
|
+
}
|
|
68
115
|
}
|
|
69
116
|
const now = Date.now();
|
|
70
117
|
const redisExpires = session.idpAccessTokenExpires ?? 0;
|
|
71
|
-
const jwtExpires = decodeJwtExp(storedToken);
|
|
118
|
+
const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
|
|
72
119
|
// Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
|
|
73
120
|
const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
|
|
74
121
|
const msUntilExpiry = effectiveExpires - now;
|
|
75
|
-
if (msUntilExpiry > safetyWindowMs) {
|
|
76
|
-
//
|
|
77
|
-
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted:
|
|
122
|
+
if (storedToken && msUntilExpiry > safetyWindowMs) {
|
|
123
|
+
// This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
|
|
124
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
|
|
78
125
|
}
|
|
79
126
|
if (!session.idpRefreshToken) {
|
|
80
127
|
return {
|
|
@@ -96,7 +143,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
96
143
|
await new Promise(r => setTimeout(r, 200));
|
|
97
144
|
const stillLocked = await (0, session_store_1.checkRefreshLock)(sessionToken);
|
|
98
145
|
if (!stillLocked) {
|
|
99
|
-
|
|
146
|
+
const canonicalAfter = await (0, session_store_1.getSession)(sessionToken);
|
|
147
|
+
let after = canonicalAfter;
|
|
100
148
|
if (!after || !after.idpRefreshToken) {
|
|
101
149
|
const baAfter = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
102
150
|
if (baAfter?.idpRefreshToken)
|
|
@@ -110,8 +158,10 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
110
158
|
accessToken: after.idpAccessToken,
|
|
111
159
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
112
160
|
refreshed: true,
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
reason: 'completed_by_concurrent_request',
|
|
162
|
+
hasRefreshToken: !!after.idpRefreshToken,
|
|
163
|
+
// Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
|
|
164
|
+
persisted: !!canonicalAfter && after === canonicalAfter,
|
|
115
165
|
};
|
|
116
166
|
}
|
|
117
167
|
}
|
|
@@ -133,7 +183,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
133
183
|
}
|
|
134
184
|
try {
|
|
135
185
|
// Re-check after lock — another caller may have already refreshed.
|
|
136
|
-
|
|
186
|
+
const canonicalLatest = await (0, session_store_1.getSession)(sessionToken);
|
|
187
|
+
let latest = canonicalLatest;
|
|
137
188
|
if (!latest || !latest.idpRefreshToken) {
|
|
138
189
|
const baLatest = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
139
190
|
if (baLatest?.idpRefreshToken)
|
|
@@ -149,11 +200,51 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
149
200
|
accessToken: latest.idpAccessToken,
|
|
150
201
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
151
202
|
refreshed: true,
|
|
152
|
-
|
|
153
|
-
|
|
203
|
+
reason: 'completed_by_concurrent_request',
|
|
204
|
+
hasRefreshToken: !!latest.idpRefreshToken,
|
|
205
|
+
// Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
|
|
206
|
+
persisted: !!canonicalLatest && latest === canonicalLatest,
|
|
154
207
|
};
|
|
155
208
|
}
|
|
156
209
|
}
|
|
210
|
+
// R-over-E (BAPert 76318 item 4 / 1432 (b2), now E's own): the token SENT is the one in the POST-lock read, so that read must still hold
|
|
211
|
+
// one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
|
|
212
|
+
// marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
|
|
213
|
+
if (!latest) {
|
|
214
|
+
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
215
|
+
}
|
|
216
|
+
if (!latest.idpRefreshToken) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: 'NO_REFRESH_TOKEN',
|
|
220
|
+
message: 'No refresh token available',
|
|
221
|
+
status: 401,
|
|
222
|
+
terminal: true,
|
|
223
|
+
resolution: 'User must re-authenticate',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
|
|
227
|
+
// absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
|
|
228
|
+
// nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
|
|
229
|
+
// refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
|
|
230
|
+
// re-established.
|
|
231
|
+
if (!canonicalLatest) {
|
|
232
|
+
console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
|
|
233
|
+
requestId,
|
|
234
|
+
traceId,
|
|
235
|
+
betterAuthOnly: !!latest,
|
|
236
|
+
});
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: 'NO_CANONICAL_SESSION',
|
|
240
|
+
message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
|
|
241
|
+
status: 401,
|
|
242
|
+
terminal: true,
|
|
243
|
+
retryable: false,
|
|
244
|
+
discardToken: false,
|
|
245
|
+
resolution: 'REAUTHENTICATE',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
157
248
|
// Build refresh request body — wire shape must match what the IDP expects.
|
|
158
249
|
let authMethods = [];
|
|
159
250
|
if (Array.isArray(session.authenticationMethods)) {
|
|
@@ -184,6 +275,25 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
184
275
|
// Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
|
|
185
276
|
// the identity context of THIS request, unchanged by a token rotation.
|
|
186
277
|
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
278
|
+
// BAPert 76403: an earlier attempt for THIS token timed out, so Core may already have consumed it; re-sending it is a replay, and a
|
|
279
|
+
// replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
|
|
280
|
+
if ((0, refresh_in_doubt_1.isRefreshInDoubt)(canonicalLatest, sentRefreshToken)) {
|
|
281
|
+
console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
|
|
282
|
+
requestId,
|
|
283
|
+
traceId,
|
|
284
|
+
inDoubtSince: canonicalLatest.refreshInDoubtAt,
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
code: refresh_in_doubt_1.REFRESH_OUTCOME_UNKNOWN_CODE,
|
|
289
|
+
message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
|
|
290
|
+
status: 401,
|
|
291
|
+
terminal: true,
|
|
292
|
+
retryable: false,
|
|
293
|
+
discardToken: false,
|
|
294
|
+
resolution: 'REAUTHENTICATE',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
187
297
|
const body = {
|
|
188
298
|
refresh_token: sentRefreshToken,
|
|
189
299
|
amr: authMethods,
|
|
@@ -195,19 +305,47 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
195
305
|
body.two_factor_method = twoFactorMethod;
|
|
196
306
|
if (session.mfaCompletedAt)
|
|
197
307
|
body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
|
|
308
|
+
// BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
|
|
309
|
+
// replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
|
|
310
|
+
const answerTimedOut = async (cause) => {
|
|
311
|
+
console.error('[ENSURE-FRESH] IDP did not answer (timeout or a transport failure that does not prove the request never left) - outcome unknown, marking the refresh in doubt', {
|
|
312
|
+
requestId,
|
|
313
|
+
traceId,
|
|
314
|
+
timeoutMs: refresh_in_doubt_1.REFRESH_FETCH_TIMEOUT_MS,
|
|
315
|
+
cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
|
|
316
|
+
});
|
|
317
|
+
try {
|
|
318
|
+
await (0, session_store_1.updateSession)(sessionToken, (0, refresh_in_doubt_1.inDoubtPatch)(sentRefreshToken));
|
|
319
|
+
}
|
|
320
|
+
catch (markError) {
|
|
321
|
+
console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: markError instanceof Error ? markError.message : String(markError) });
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
code: refresh_in_doubt_1.UPSTREAM_TIMEOUT_CODE,
|
|
326
|
+
message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
|
|
327
|
+
status: 504,
|
|
328
|
+
terminal: false,
|
|
329
|
+
retryable: false,
|
|
330
|
+
discardToken: false,
|
|
331
|
+
};
|
|
332
|
+
};
|
|
198
333
|
let idpResponse;
|
|
199
334
|
try {
|
|
200
335
|
idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
|
|
201
336
|
method: 'POST',
|
|
202
337
|
headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
|
|
203
338
|
body: JSON.stringify(body),
|
|
339
|
+
signal: AbortSignal.timeout(refresh_in_doubt_1.REFRESH_FETCH_TIMEOUT_MS),
|
|
204
340
|
});
|
|
205
341
|
}
|
|
206
342
|
catch (err) {
|
|
343
|
+
if (!(0, refresh_in_doubt_1.requestNeverLeft)(err))
|
|
344
|
+
return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
|
|
207
345
|
return {
|
|
208
346
|
ok: false,
|
|
209
347
|
code: 'UPSTREAM_SERVICE_UNAVAILABLE',
|
|
210
|
-
message:
|
|
348
|
+
message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
|
|
211
349
|
status: 503,
|
|
212
350
|
retryable: true,
|
|
213
351
|
};
|
|
@@ -216,12 +354,32 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
216
354
|
try {
|
|
217
355
|
const text = await idpResponse.text();
|
|
218
356
|
if (!text.trim()) {
|
|
357
|
+
if (idpResponse.ok)
|
|
358
|
+
return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
|
|
219
359
|
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
|
|
220
360
|
}
|
|
221
361
|
responseData = JSON.parse(text);
|
|
222
362
|
}
|
|
223
363
|
catch (err) {
|
|
224
|
-
|
|
364
|
+
if (!(err instanceof SyntaxError) || idpResponse.ok)
|
|
365
|
+
return await answerTimedOut(err); // BAPert 76441: a body-READ failure is in doubt; so is a 2xx whose body does not parse (Core processed the grant, i.e. consumed the token, and the replacement is lost). A non-2xx unparseable body (a gateway HTML 502/503) did not mint: a completed failure, no marker.
|
|
366
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
|
|
367
|
+
}
|
|
368
|
+
// BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
|
|
369
|
+
// POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
|
|
370
|
+
// message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
|
|
371
|
+
// answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
|
|
372
|
+
const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
|
|
373
|
+
if (!envelope.ok) {
|
|
374
|
+
console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
|
|
375
|
+
requestId,
|
|
376
|
+
traceId,
|
|
377
|
+
// the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
|
|
378
|
+
idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
|
|
379
|
+
status: idpResponse.status,
|
|
380
|
+
why: envelope.why,
|
|
381
|
+
});
|
|
382
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
|
|
225
383
|
}
|
|
226
384
|
if (!idpResponse.ok) {
|
|
227
385
|
const idpError = responseData?.error || {};
|
|
@@ -278,16 +436,29 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
278
436
|
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
279
437
|
// (race-fix follow-up #1).
|
|
280
438
|
discardToken = false;
|
|
281
|
-
if (
|
|
439
|
+
if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
|
|
282
440
|
return {
|
|
283
441
|
ok: true,
|
|
284
442
|
accessToken: postRefresh.idpAccessToken,
|
|
285
443
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
286
444
|
refreshed: true,
|
|
287
|
-
|
|
445
|
+
reason: 'completed_by_concurrent_request',
|
|
446
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
447
|
+
// `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
|
|
288
448
|
persisted: true,
|
|
289
449
|
};
|
|
290
450
|
}
|
|
451
|
+
// Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept, no
|
|
452
|
+
// marker. Same meaning in every branch (lib/refresh-rotation).
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
code,
|
|
456
|
+
message: idpError.message || 'Token refresh failed',
|
|
457
|
+
status: 502,
|
|
458
|
+
discardToken: false,
|
|
459
|
+
retryable: true,
|
|
460
|
+
resolution: idpError.resolution,
|
|
461
|
+
};
|
|
291
462
|
}
|
|
292
463
|
}
|
|
293
464
|
return {
|
|
@@ -302,6 +473,56 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
302
473
|
};
|
|
303
474
|
}
|
|
304
475
|
// Validate canonical envelope.
|
|
476
|
+
// 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations (api-handlers/auth/refresh.ts has the twin of
|
|
477
|
+
// this block). A 200 envelope with success:false used to be a generic 502 here, with discard_token ignored, while the other twin
|
|
478
|
+
// honoured it. Now: success:false + discard_token discards the stored token (same CAS as the HTTP-401 branch above, same
|
|
479
|
+
// IDP_DISCARD_TOKEN marker) and answers terminal 401; success:false without discard_token is a retryable 502 and the token is KEPT.
|
|
480
|
+
if (responseData && typeof responseData === 'object' && responseData.success === false) {
|
|
481
|
+
const idpError = responseData.error || {};
|
|
482
|
+
const code = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
|
|
483
|
+
if (idpError.discard_token === true) {
|
|
484
|
+
const postRefresh = await (0, session_store_1.getSession)(sessionToken);
|
|
485
|
+
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
486
|
+
if (tokenUnchanged) {
|
|
487
|
+
await (0, session_store_1.updateSession)(sessionToken, {
|
|
488
|
+
idpRefreshToken: '',
|
|
489
|
+
idpRefreshTokenExpires: undefined,
|
|
490
|
+
refreshTokenClearedAt: Date.now(),
|
|
491
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
492
|
+
});
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
code,
|
|
496
|
+
message: idpError.message || 'Token refresh failed',
|
|
497
|
+
status: 401,
|
|
498
|
+
discardToken: true,
|
|
499
|
+
retryable: false,
|
|
500
|
+
resolution: idpError.resolution,
|
|
501
|
+
terminal: true,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
|
|
505
|
+
return {
|
|
506
|
+
ok: true,
|
|
507
|
+
accessToken: postRefresh.idpAccessToken,
|
|
508
|
+
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
509
|
+
refreshed: true,
|
|
510
|
+
reason: 'completed_by_concurrent_request',
|
|
511
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
512
|
+
persisted: true,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
code,
|
|
519
|
+
message: idpError.message || 'Token refresh failed',
|
|
520
|
+
status: 502,
|
|
521
|
+
discardToken: false,
|
|
522
|
+
retryable: true,
|
|
523
|
+
resolution: idpError.resolution,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
305
526
|
if (!responseData ||
|
|
306
527
|
typeof responseData !== 'object' ||
|
|
307
528
|
responseData.success !== true ||
|
|
@@ -331,13 +552,13 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
331
552
|
amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
|
|
332
553
|
}
|
|
333
554
|
catch {
|
|
334
|
-
amrClaims =
|
|
555
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
335
556
|
}
|
|
336
557
|
}
|
|
337
558
|
else {
|
|
338
|
-
amrClaims =
|
|
559
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
339
560
|
}
|
|
340
|
-
const acrLevel = String(decoded?.acr ||
|
|
561
|
+
const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
|
|
341
562
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
342
563
|
const newKid = (0, token_utils_1.extractKidFromToken)(newAccess);
|
|
343
564
|
// *** CARD 320072. CAPTURE THIS RETURN. ***
|
|
@@ -347,14 +568,19 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
347
568
|
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
348
569
|
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
349
570
|
// block below; this line only makes the answer available.
|
|
571
|
+
//
|
|
572
|
+
// BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
|
|
573
|
+
// pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
|
|
574
|
+
// pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
|
|
575
|
+
// spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
|
|
576
|
+
// concurrent writer's change to anything else survives.
|
|
350
577
|
const persisted = await (0, session_store_1.updateSession)(sessionToken, {
|
|
351
|
-
...session,
|
|
352
578
|
idpAccessToken: newAccess,
|
|
353
579
|
idpAccessTokenExpires: accessTokenExpires,
|
|
354
|
-
idpRefreshToken: hasNewRefresh ? newRefresh :
|
|
355
|
-
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires :
|
|
580
|
+
idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
|
|
581
|
+
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
|
|
356
582
|
decodedAccessToken: decoded,
|
|
357
|
-
bearerKeyId: newKid ||
|
|
583
|
+
bearerKeyId: newKid || canonicalLatest.bearerKeyId,
|
|
358
584
|
authenticationMethods: amrClaims,
|
|
359
585
|
authenticationLevel: acrLevel,
|
|
360
586
|
// 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
|
|
@@ -362,9 +588,9 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
362
588
|
// rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
|
|
363
589
|
// the same defect duplicated at a second call site.
|
|
364
590
|
mfaVerified: amrClaims.includes('mfa'),
|
|
365
|
-
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 :
|
|
366
|
-
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 :
|
|
367
|
-
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) :
|
|
591
|
+
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
|
|
592
|
+
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
|
|
593
|
+
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
|
|
368
594
|
});
|
|
369
595
|
/**
|
|
370
596
|
* 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
|
|
@@ -429,7 +655,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
429
655
|
// the only way out for a dropped write, so by construction anything reaching
|
|
430
656
|
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
431
657
|
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
432
|
-
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
658
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
|
|
433
659
|
}
|
|
434
660
|
finally {
|
|
435
661
|
if (weHoldLock) {
|
package/dist/lib/idp-fetch.js
CHANGED
|
@@ -42,6 +42,7 @@ const idp_url_guard_1 = require("./idp-url-guard");
|
|
|
42
42
|
Object.defineProperty(exports, "assertAbsoluteIdpUrl", { enumerable: true, get: function () { return idp_url_guard_1.assertAbsoluteIdpUrl; } });
|
|
43
43
|
const retry_after_1 = require("./retry-after");
|
|
44
44
|
Object.defineProperty(exports, "parseRetryAfterSeconds", { enumerable: true, get: function () { return retry_after_1.parseRetryAfterSeconds; } });
|
|
45
|
+
const refresh_window_1 = require("./refresh-window");
|
|
45
46
|
function buildHeaders(req, bearer, extra) {
|
|
46
47
|
const headers = {
|
|
47
48
|
Accept: 'application/json',
|
|
@@ -118,8 +119,8 @@ async function ensureFreshAccessToken(req) {
|
|
|
118
119
|
const hasRefreshToken = !!session.idpRefreshToken;
|
|
119
120
|
const now = Date.now();
|
|
120
121
|
const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
|
|
121
|
-
// Treat tokens as effectively expired if within
|
|
122
|
-
if (timeLeft >
|
|
122
|
+
// Treat tokens as effectively expired if within REFRESH_WINDOW_MS of expiry (lib/refresh-window: the one window)
|
|
123
|
+
if (timeLeft > refresh_window_1.REFRESH_WINDOW_MS) {
|
|
123
124
|
return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
|
|
124
125
|
}
|
|
125
126
|
// Only attempt refresh when we actually have a refresh token to use.
|
package/dist/lib/idp-logout.d.ts
CHANGED
|
@@ -10,8 +10,10 @@ import type { NextRequest } from 'next/server';
|
|
|
10
10
|
* 'revoked' the IdP answered 2xx.
|
|
11
11
|
* 'nothing-to-revoke' the session holds no IdP refresh token (a provisional pre-2FA session has none). No call is made, because
|
|
12
12
|
* without a token Core would pick the newest session.
|
|
13
|
-
* 'grant-already-dead' the revoke was refused
|
|
14
|
-
*
|
|
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.
|
|
15
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
|
|
16
18
|
* turns that into SESSION_NOT_ENDED.
|
|
17
19
|
*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** How long a refresh fetch (connect, response and body) may take. 5 s lock wait + this + the persist stays well inside the 60 s lock TTL. */
|
|
2
|
+
export declare const REFRESH_FETCH_TIMEOUT_MS = 10000;
|
|
3
|
+
/** The typed terminal answer for an attempt that would re-send an in-doubt token. */
|
|
4
|
+
export declare const REFRESH_OUTCOME_UNKNOWN_CODE = "REFRESH_OUTCOME_UNKNOWN";
|
|
5
|
+
/** The typed answer for the timed-out call itself. */
|
|
6
|
+
export declare const UPSTREAM_TIMEOUT_CODE = "UPSTREAM_TIMEOUT";
|
|
7
|
+
/** A one-way identity for a refresh token. Not reversible and not a prefix. */
|
|
8
|
+
export declare function refreshTokenIdentity(token: string): string;
|
|
9
|
+
/** The row fields recorded at a timeout. */
|
|
10
|
+
export declare function inDoubtPatch(sentToken: string): {
|
|
11
|
+
refreshInDoubtTokenHash: string;
|
|
12
|
+
refreshInDoubtAt: number;
|
|
13
|
+
};
|
|
14
|
+
/** True when the token we are about to send is the one a previous attempt left in doubt. */
|
|
15
|
+
export declare function isRefreshInDoubt(session: {
|
|
16
|
+
refreshInDoubtTokenHash?: string | null;
|
|
17
|
+
} | null | undefined, tokenToSend: string | null | undefined): boolean;
|
|
18
|
+
/** True for the error `AbortSignal.timeout` produces (a DOMException named TimeoutError); AbortError is treated the same. */
|
|
19
|
+
export declare function isTimeoutError(err: unknown): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The ONLY transport error codes that PROVE the request never left this process (BAPert 76433 item 4): the connection was refused, or the
|
|
22
|
+
* name did not resolve. Anything else (ECONNRESET, UND_ERR_SOCKET, a timeout, an error with no code) may have happened AFTER the request was
|
|
23
|
+
* written, so the IdP may have consumed the token: outcome unknown. The cost is lopsided on purpose: a false in-doubt costs one re-login, a
|
|
24
|
+
* missed one costs a user-wide revocation. Do NOT add a code here without proof it can only occur before the request is sent.
|
|
25
|
+
*/
|
|
26
|
+
export declare const REQUEST_NEVER_LEFT_CODES: ReadonlySet<string>;
|
|
27
|
+
/**
|
|
28
|
+
* True only when the error carries at least one cause code and EVERY code it carries is in the never-left allowlist. An error with no code
|
|
29
|
+
* is NOT proof, so it is in-doubt.
|
|
30
|
+
*/
|
|
31
|
+
export declare function requestNeverLeft(err: unknown): boolean;
|