@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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { jwtDecode, decodeJwtHeader, extractKidFromToken, JwtHeader } from '../../lib/jwt-decode';
|
|
11
11
|
import type { DecodedIdpAccessToken } from '../types/auth-types';
|
|
12
|
+
import { REFRESH_WINDOW_MS } from '../../lib/refresh-window';
|
|
12
13
|
|
|
13
14
|
// Re-export header utilities for consumers
|
|
14
15
|
export { decodeJwtHeader, extractKidFromToken, type JwtHeader } from '../../lib/jwt-decode';
|
|
@@ -137,12 +138,12 @@ export function extractAmrFromToken(decoded: DecodedIdpAccessToken): string[] {
|
|
|
137
138
|
* Check if a token expiry timestamp indicates the token needs refresh.
|
|
138
139
|
*
|
|
139
140
|
* @param expiresAt - Token expiry timestamp (Unix milliseconds)
|
|
140
|
-
* @param bufferMs - How early to trigger refresh (default 5 minutes)
|
|
141
|
+
* @param bufferMs - How early to trigger refresh (default: the one refresh window, `REFRESH_WINDOW_MS`, 5 minutes)
|
|
141
142
|
* @returns true if token is expired or will expire within buffer period
|
|
142
143
|
*/
|
|
143
144
|
export function tokenNeedsRefresh(
|
|
144
145
|
expiresAt: number | undefined,
|
|
145
|
-
bufferMs: number =
|
|
146
|
+
bufferMs: number = REFRESH_WINDOW_MS
|
|
146
147
|
): boolean {
|
|
147
148
|
if (!expiresAt) {
|
|
148
149
|
return true; // No expiry info = assume needs refresh
|
package/src/lib/api-handler.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { getJwtCookieName } from './app-slug';
|
|
27
27
|
import { getTokenTestAware } from './test-aware-get-token';
|
|
28
28
|
import { internalRefresh } from './internal-api';
|
|
29
|
+
import { REFRESH_WINDOW_MS } from './refresh-window';
|
|
29
30
|
|
|
30
31
|
// ============================================================================
|
|
31
32
|
// Types
|
|
@@ -284,7 +285,7 @@ export class ApiHandler {
|
|
|
284
285
|
// Skip the optimization (hasRefreshToken check) when access token is missing —
|
|
285
286
|
// some session shapes store the refresh token under different field names,
|
|
286
287
|
// and we still want a refresh attempt to populate the access token.
|
|
287
|
-
const thresholdMs =
|
|
288
|
+
const thresholdMs = REFRESH_WINDOW_MS;
|
|
288
289
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
289
290
|
const expires = sessionData.idpAccessTokenExpires || (sessionData as any).accessTokenExpires || 0;
|
|
290
291
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -426,7 +427,7 @@ export class ApiHandler {
|
|
|
426
427
|
try {
|
|
427
428
|
// Double-check if still needs refresh
|
|
428
429
|
const latest = await getSession(sessionToken);
|
|
429
|
-
const thresholdMs =
|
|
430
|
+
const thresholdMs = REFRESH_WINDOW_MS;
|
|
430
431
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
431
432
|
const latestAny = latest as any;
|
|
432
433
|
// Sessions may store the access token under either `accessToken` or `idpAccessToken`,
|
|
@@ -481,7 +482,7 @@ export class ApiHandler {
|
|
|
481
482
|
const session = await getSession(sessionToken);
|
|
482
483
|
if (session?.accessToken) {
|
|
483
484
|
const expires = session.accessTokenExpires || 0;
|
|
484
|
-
if ((expires - Date.now()) >
|
|
485
|
+
if ((expires - Date.now()) > REFRESH_WINDOW_MS) {
|
|
485
486
|
return { success: true };
|
|
486
487
|
}
|
|
487
488
|
}
|
|
@@ -24,6 +24,17 @@ import {
|
|
|
24
24
|
} from './session-store';
|
|
25
25
|
import { computeTokenExpiries } from './token-expiry';
|
|
26
26
|
import { extractKidFromToken } from '../auth/utils/token-utils';
|
|
27
|
+
import { rotatedRowIsUsable } from './refresh-rotation';
|
|
28
|
+
import { REFRESH_WINDOW_MS } from './refresh-window';
|
|
29
|
+
import { sanitizeTraceId } from './safe-trace-id';
|
|
30
|
+
import {
|
|
31
|
+
REFRESH_FETCH_TIMEOUT_MS,
|
|
32
|
+
REFRESH_OUTCOME_UNKNOWN_CODE,
|
|
33
|
+
UPSTREAM_TIMEOUT_CODE,
|
|
34
|
+
inDoubtPatch,
|
|
35
|
+
isRefreshInDoubt,
|
|
36
|
+
requestNeverLeft,
|
|
37
|
+
} from './refresh-in-doubt';
|
|
27
38
|
|
|
28
39
|
export interface EnsureFreshConfig {
|
|
29
40
|
idpBaseUrl: string;
|
|
@@ -32,10 +43,15 @@ export interface EnsureFreshConfig {
|
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
export interface EnsureFreshOptions {
|
|
35
|
-
/** Refresh if the access token is within this many ms of expiry. Default
|
|
46
|
+
/** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
|
|
36
47
|
safetyWindowMs?: number;
|
|
37
48
|
/** Max wait while another caller holds the refresh lock. Default 5000. */
|
|
38
49
|
lockWaitMs?: number;
|
|
50
|
+
/**
|
|
51
|
+
* 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
|
|
52
|
+
* 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.
|
|
53
|
+
*/
|
|
54
|
+
requireRefreshToken?: boolean;
|
|
39
55
|
/**
|
|
40
56
|
* Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
|
|
41
57
|
* crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
|
|
@@ -50,6 +66,14 @@ export type EnsureFreshResult =
|
|
|
50
66
|
accessTokenExpires: number;
|
|
51
67
|
/** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
|
|
52
68
|
refreshed: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* WHY this result is ok (BAPert 76449, additive; `api-handlers/auth/refresh` reproduces its bodies from it and `lib/token-lifecycle` reads
|
|
71
|
+
* `already_fresh`): 'already_fresh' = nothing was needed; 'completed_by_concurrent_request' = another request refreshed and we returned
|
|
72
|
+
* its result; 'refreshed' = THIS call refreshed.
|
|
73
|
+
*/
|
|
74
|
+
reason: 'refreshed' | 'already_fresh' | 'completed_by_concurrent_request';
|
|
75
|
+
/** Whether the stored session holds a refresh token (the refresh route reports it in its body). */
|
|
76
|
+
hasRefreshToken: boolean;
|
|
53
77
|
/**
|
|
54
78
|
* CARD 320072. Is the token in THIS result actually in the session store?
|
|
55
79
|
*
|
|
@@ -88,7 +112,25 @@ export type EnsureFreshResult =
|
|
|
88
112
|
resolution?: string;
|
|
89
113
|
};
|
|
90
114
|
|
|
91
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
|
|
117
|
+
* `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
|
|
118
|
+
*/
|
|
119
|
+
export function checkRefreshEnvelope(body: any, httpOk: boolean): { ok: true } | { ok: false; why: string } {
|
|
120
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, why: 'body is not an object' };
|
|
121
|
+
if (typeof body.success !== 'boolean') return { ok: false, why: 'success is not a boolean' };
|
|
122
|
+
if (body.success === true) {
|
|
123
|
+
if (!httpOk) return { ok: false, why: 'success:true on a non-2xx' };
|
|
124
|
+
if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
|
|
125
|
+
return { ok: false, why: 'success:true without data.access_token' };
|
|
126
|
+
}
|
|
127
|
+
} else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
|
|
128
|
+
return { ok: false, why: 'success:false without error.code' };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DEFAULT_SAFETY_WINDOW_MS = REFRESH_WINDOW_MS; // was 60_000; one window for R, E and idp-fetch (BAPert 76397 decision 1)
|
|
92
134
|
const DEFAULT_LOCK_WAIT_MS = 5000;
|
|
93
135
|
|
|
94
136
|
function decodeJwtExp(token: string): number {
|
|
@@ -116,18 +158,33 @@ export async function ensureFreshAccessToken(
|
|
|
116
158
|
// reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
|
|
117
159
|
// collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
|
|
118
160
|
const requestId = `ensure_fresh_${randomUUID()}`;
|
|
119
|
-
|
|
161
|
+
// Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
|
|
162
|
+
const traceId = sanitizeTraceId(options.requestId);
|
|
120
163
|
|
|
121
|
-
|
|
164
|
+
const canonicalFirst = await getSession(sessionToken);
|
|
165
|
+
let session = canonicalFirst;
|
|
122
166
|
if (!session || !session.idpRefreshToken) {
|
|
123
167
|
const baSession = await getBetterAuthSession(sessionToken);
|
|
124
168
|
if (baSession?.idpRefreshToken) {
|
|
125
169
|
session = baSession;
|
|
126
170
|
}
|
|
127
171
|
}
|
|
172
|
+
// `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
|
|
173
|
+
// persist writes.
|
|
174
|
+
const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
|
|
128
175
|
if (!session) {
|
|
129
176
|
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
130
177
|
}
|
|
178
|
+
if (options.requireRefreshToken && !session.idpRefreshToken) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
code: 'NO_REFRESH_TOKEN',
|
|
182
|
+
message: 'No refresh token available',
|
|
183
|
+
status: 401,
|
|
184
|
+
terminal: true,
|
|
185
|
+
resolution: 'User must re-authenticate',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
131
188
|
|
|
132
189
|
const storedToken = session.idpAccessToken;
|
|
133
190
|
if (!storedToken) {
|
|
@@ -135,22 +192,28 @@ export async function ensureFreshAccessToken(
|
|
|
135
192
|
// session with no access token and mfaVerified !== true is a user
|
|
136
193
|
// mid-2FA, not dead. Only terminal once the session itself claims 2FA
|
|
137
194
|
// is already done.
|
|
195
|
+
//
|
|
196
|
+
// 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.
|
|
197
|
+
// 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
|
|
198
|
+
// refresh route always did); with no refresh token it is NO_TOKEN as before.
|
|
138
199
|
if (session.mfaVerified !== true) {
|
|
139
200
|
return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
|
|
140
201
|
}
|
|
141
|
-
|
|
202
|
+
if (!session.idpRefreshToken) {
|
|
203
|
+
return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
|
|
204
|
+
}
|
|
142
205
|
}
|
|
143
206
|
|
|
144
207
|
const now = Date.now();
|
|
145
208
|
const redisExpires = session.idpAccessTokenExpires ?? 0;
|
|
146
|
-
const jwtExpires = decodeJwtExp(storedToken);
|
|
209
|
+
const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
|
|
147
210
|
// Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
|
|
148
211
|
const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
|
|
149
212
|
const msUntilExpiry = effectiveExpires - now;
|
|
150
213
|
|
|
151
|
-
if (msUntilExpiry > safetyWindowMs) {
|
|
152
|
-
//
|
|
153
|
-
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted:
|
|
214
|
+
if (storedToken && msUntilExpiry > safetyWindowMs) {
|
|
215
|
+
// This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
|
|
216
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
|
|
154
217
|
}
|
|
155
218
|
|
|
156
219
|
if (!session.idpRefreshToken) {
|
|
@@ -175,7 +238,8 @@ export async function ensureFreshAccessToken(
|
|
|
175
238
|
await new Promise(r => setTimeout(r, 200));
|
|
176
239
|
const stillLocked = await checkRefreshLock(sessionToken);
|
|
177
240
|
if (!stillLocked) {
|
|
178
|
-
|
|
241
|
+
const canonicalAfter = await getSession(sessionToken);
|
|
242
|
+
let after = canonicalAfter;
|
|
179
243
|
if (!after || !after.idpRefreshToken) {
|
|
180
244
|
const baAfter = await getBetterAuthSession(sessionToken);
|
|
181
245
|
if (baAfter?.idpRefreshToken) after = baAfter;
|
|
@@ -188,8 +252,10 @@ export async function ensureFreshAccessToken(
|
|
|
188
252
|
accessToken: after.idpAccessToken,
|
|
189
253
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
190
254
|
refreshed: true,
|
|
191
|
-
|
|
192
|
-
|
|
255
|
+
reason: 'completed_by_concurrent_request',
|
|
256
|
+
hasRefreshToken: !!after.idpRefreshToken,
|
|
257
|
+
// Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
|
|
258
|
+
persisted: !!canonicalAfter && after === canonicalAfter,
|
|
193
259
|
};
|
|
194
260
|
}
|
|
195
261
|
}
|
|
@@ -211,7 +277,8 @@ export async function ensureFreshAccessToken(
|
|
|
211
277
|
|
|
212
278
|
try {
|
|
213
279
|
// Re-check after lock — another caller may have already refreshed.
|
|
214
|
-
|
|
280
|
+
const canonicalLatest = await getSession(sessionToken);
|
|
281
|
+
let latest = canonicalLatest;
|
|
215
282
|
if (!latest || !latest.idpRefreshToken) {
|
|
216
283
|
const baLatest = await getBetterAuthSession(sessionToken);
|
|
217
284
|
if (baLatest?.idpRefreshToken) latest = baLatest;
|
|
@@ -226,12 +293,54 @@ export async function ensureFreshAccessToken(
|
|
|
226
293
|
accessToken: latest.idpAccessToken,
|
|
227
294
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
228
295
|
refreshed: true,
|
|
229
|
-
|
|
230
|
-
|
|
296
|
+
reason: 'completed_by_concurrent_request',
|
|
297
|
+
hasRefreshToken: !!latest.idpRefreshToken,
|
|
298
|
+
// Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
|
|
299
|
+
persisted: !!canonicalLatest && latest === canonicalLatest,
|
|
231
300
|
};
|
|
232
301
|
}
|
|
233
302
|
}
|
|
234
303
|
|
|
304
|
+
// 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
|
|
305
|
+
// one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
|
|
306
|
+
// marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
|
|
307
|
+
if (!latest) {
|
|
308
|
+
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
309
|
+
}
|
|
310
|
+
if (!latest.idpRefreshToken) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
code: 'NO_REFRESH_TOKEN',
|
|
314
|
+
message: 'No refresh token available',
|
|
315
|
+
status: 401,
|
|
316
|
+
terminal: true,
|
|
317
|
+
resolution: 'User must re-authenticate',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
|
|
322
|
+
// absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
|
|
323
|
+
// nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
|
|
324
|
+
// refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
|
|
325
|
+
// re-established.
|
|
326
|
+
if (!canonicalLatest) {
|
|
327
|
+
console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
|
|
328
|
+
requestId,
|
|
329
|
+
traceId,
|
|
330
|
+
betterAuthOnly: !!latest,
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
code: 'NO_CANONICAL_SESSION',
|
|
335
|
+
message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
|
|
336
|
+
status: 401,
|
|
337
|
+
terminal: true,
|
|
338
|
+
retryable: false,
|
|
339
|
+
discardToken: false,
|
|
340
|
+
resolution: 'REAUTHENTICATE',
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
235
344
|
// Build refresh request body — wire shape must match what the IDP expects.
|
|
236
345
|
let authMethods: string[] = [];
|
|
237
346
|
if (Array.isArray(session.authenticationMethods)) {
|
|
@@ -261,6 +370,27 @@ export async function ensureFreshAccessToken(
|
|
|
261
370
|
// Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
|
|
262
371
|
// the identity context of THIS request, unchanged by a token rotation.
|
|
263
372
|
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
373
|
+
|
|
374
|
+
// 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
|
|
375
|
+
// replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
|
|
376
|
+
if (isRefreshInDoubt(canonicalLatest, sentRefreshToken)) {
|
|
377
|
+
console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
|
|
378
|
+
requestId,
|
|
379
|
+
traceId,
|
|
380
|
+
inDoubtSince: canonicalLatest.refreshInDoubtAt,
|
|
381
|
+
});
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
code: REFRESH_OUTCOME_UNKNOWN_CODE,
|
|
385
|
+
message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
|
|
386
|
+
status: 401,
|
|
387
|
+
terminal: true,
|
|
388
|
+
retryable: false,
|
|
389
|
+
discardToken: false,
|
|
390
|
+
resolution: 'REAUTHENTICATE',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
264
394
|
const body: Record<string, unknown> = {
|
|
265
395
|
refresh_token: sentRefreshToken,
|
|
266
396
|
amr: authMethods,
|
|
@@ -270,18 +400,45 @@ export async function ensureFreshAccessToken(
|
|
|
270
400
|
if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
|
|
271
401
|
if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
|
|
272
402
|
|
|
403
|
+
// BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
|
|
404
|
+
// replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
|
|
405
|
+
const answerTimedOut = async (cause?: unknown): Promise<EnsureFreshResult> => {
|
|
406
|
+
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', {
|
|
407
|
+
requestId,
|
|
408
|
+
traceId,
|
|
409
|
+
timeoutMs: REFRESH_FETCH_TIMEOUT_MS,
|
|
410
|
+
cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
await updateSession(sessionToken, inDoubtPatch(sentRefreshToken as string));
|
|
414
|
+
} catch (markError) {
|
|
415
|
+
console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: markError instanceof Error ? markError.message : String(markError) });
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
code: UPSTREAM_TIMEOUT_CODE,
|
|
420
|
+
message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
|
|
421
|
+
status: 504,
|
|
422
|
+
terminal: false,
|
|
423
|
+
retryable: false,
|
|
424
|
+
discardToken: false,
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
|
|
273
428
|
let idpResponse: Response;
|
|
274
429
|
try {
|
|
275
430
|
idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
|
|
276
431
|
method: 'POST',
|
|
277
432
|
headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
|
|
278
433
|
body: JSON.stringify(body),
|
|
434
|
+
signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
|
|
279
435
|
});
|
|
280
436
|
} catch (err) {
|
|
437
|
+
if (!requestNeverLeft(err)) return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
|
|
281
438
|
return {
|
|
282
439
|
ok: false,
|
|
283
440
|
code: 'UPSTREAM_SERVICE_UNAVAILABLE',
|
|
284
|
-
message:
|
|
441
|
+
message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
|
|
285
442
|
status: 503,
|
|
286
443
|
retryable: true,
|
|
287
444
|
};
|
|
@@ -291,11 +448,30 @@ export async function ensureFreshAccessToken(
|
|
|
291
448
|
try {
|
|
292
449
|
const text = await idpResponse.text();
|
|
293
450
|
if (!text.trim()) {
|
|
451
|
+
if (idpResponse.ok) return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
|
|
294
452
|
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
|
|
295
453
|
}
|
|
296
454
|
responseData = JSON.parse(text);
|
|
297
455
|
} catch (err) {
|
|
298
|
-
return
|
|
456
|
+
if (!(err instanceof SyntaxError) || idpResponse.ok) 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.
|
|
457
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
|
|
461
|
+
// POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
|
|
462
|
+
// message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
|
|
463
|
+
// answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
|
|
464
|
+
const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
|
|
465
|
+
if (!envelope.ok) {
|
|
466
|
+
console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
|
|
467
|
+
requestId,
|
|
468
|
+
traceId,
|
|
469
|
+
// the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
|
|
470
|
+
idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
|
|
471
|
+
status: idpResponse.status,
|
|
472
|
+
why: envelope.why,
|
|
473
|
+
});
|
|
474
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
|
|
299
475
|
}
|
|
300
476
|
|
|
301
477
|
if (!idpResponse.ok) {
|
|
@@ -353,16 +529,29 @@ export async function ensureFreshAccessToken(
|
|
|
353
529
|
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
354
530
|
// (race-fix follow-up #1).
|
|
355
531
|
discardToken = false;
|
|
356
|
-
if (postRefresh
|
|
532
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
357
533
|
return {
|
|
358
534
|
ok: true,
|
|
359
535
|
accessToken: postRefresh.idpAccessToken,
|
|
360
536
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
361
537
|
refreshed: true,
|
|
362
|
-
|
|
538
|
+
reason: 'completed_by_concurrent_request',
|
|
539
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
540
|
+
// `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
|
|
363
541
|
persisted: true,
|
|
364
542
|
};
|
|
365
543
|
}
|
|
544
|
+
// Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept, no
|
|
545
|
+
// marker. Same meaning in every branch (lib/refresh-rotation).
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
code,
|
|
549
|
+
message: idpError.message || 'Token refresh failed',
|
|
550
|
+
status: 502,
|
|
551
|
+
discardToken: false,
|
|
552
|
+
retryable: true,
|
|
553
|
+
resolution: idpError.resolution,
|
|
554
|
+
};
|
|
366
555
|
}
|
|
367
556
|
}
|
|
368
557
|
return {
|
|
@@ -378,6 +567,57 @@ export async function ensureFreshAccessToken(
|
|
|
378
567
|
}
|
|
379
568
|
|
|
380
569
|
// Validate canonical envelope.
|
|
570
|
+
// 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations (api-handlers/auth/refresh.ts has the twin of
|
|
571
|
+
// this block). A 200 envelope with success:false used to be a generic 502 here, with discard_token ignored, while the other twin
|
|
572
|
+
// honoured it. Now: success:false + discard_token discards the stored token (same CAS as the HTTP-401 branch above, same
|
|
573
|
+
// IDP_DISCARD_TOKEN marker) and answers terminal 401; success:false without discard_token is a retryable 502 and the token is KEPT.
|
|
574
|
+
if (responseData && typeof responseData === 'object' && responseData.success === false) {
|
|
575
|
+
const idpError = responseData.error || {};
|
|
576
|
+
const code = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
|
|
577
|
+
if (idpError.discard_token === true) {
|
|
578
|
+
const postRefresh = await getSession(sessionToken);
|
|
579
|
+
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
580
|
+
if (tokenUnchanged) {
|
|
581
|
+
await updateSession(sessionToken, {
|
|
582
|
+
idpRefreshToken: '',
|
|
583
|
+
idpRefreshTokenExpires: undefined,
|
|
584
|
+
refreshTokenClearedAt: Date.now(),
|
|
585
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
586
|
+
});
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
code,
|
|
590
|
+
message: idpError.message || 'Token refresh failed',
|
|
591
|
+
status: 401,
|
|
592
|
+
discardToken: true,
|
|
593
|
+
retryable: false,
|
|
594
|
+
resolution: idpError.resolution,
|
|
595
|
+
terminal: true,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
599
|
+
return {
|
|
600
|
+
ok: true,
|
|
601
|
+
accessToken: postRefresh.idpAccessToken,
|
|
602
|
+
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
603
|
+
refreshed: true,
|
|
604
|
+
reason: 'completed_by_concurrent_request',
|
|
605
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
606
|
+
persisted: true,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
ok: false,
|
|
612
|
+
code,
|
|
613
|
+
message: idpError.message || 'Token refresh failed',
|
|
614
|
+
status: 502,
|
|
615
|
+
discardToken: false,
|
|
616
|
+
retryable: true,
|
|
617
|
+
resolution: idpError.resolution,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
381
621
|
if (
|
|
382
622
|
!responseData ||
|
|
383
623
|
typeof responseData !== 'object' ||
|
|
@@ -410,12 +650,12 @@ export async function ensureFreshAccessToken(
|
|
|
410
650
|
try {
|
|
411
651
|
amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
|
|
412
652
|
} catch {
|
|
413
|
-
amrClaims =
|
|
653
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
414
654
|
}
|
|
415
655
|
} else {
|
|
416
|
-
amrClaims =
|
|
656
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
417
657
|
}
|
|
418
|
-
const acrLevel = String(decoded?.acr ||
|
|
658
|
+
const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
|
|
419
659
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
420
660
|
const newKid = extractKidFromToken(newAccess);
|
|
421
661
|
|
|
@@ -426,14 +666,19 @@ export async function ensureFreshAccessToken(
|
|
|
426
666
|
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
427
667
|
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
428
668
|
// block below; this line only makes the answer available.
|
|
669
|
+
//
|
|
670
|
+
// BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
|
|
671
|
+
// pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
|
|
672
|
+
// pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
|
|
673
|
+
// spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
|
|
674
|
+
// concurrent writer's change to anything else survives.
|
|
429
675
|
const persisted = await updateSession(sessionToken, {
|
|
430
|
-
...session,
|
|
431
676
|
idpAccessToken: newAccess,
|
|
432
677
|
idpAccessTokenExpires: accessTokenExpires,
|
|
433
|
-
idpRefreshToken: hasNewRefresh ? newRefresh :
|
|
434
|
-
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires :
|
|
678
|
+
idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
|
|
679
|
+
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
|
|
435
680
|
decodedAccessToken: decoded,
|
|
436
|
-
bearerKeyId: newKid ||
|
|
681
|
+
bearerKeyId: newKid || canonicalLatest.bearerKeyId,
|
|
437
682
|
authenticationMethods: amrClaims,
|
|
438
683
|
authenticationLevel: acrLevel,
|
|
439
684
|
// 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
|
|
@@ -441,9 +686,9 @@ export async function ensureFreshAccessToken(
|
|
|
441
686
|
// rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
|
|
442
687
|
// the same defect duplicated at a second call site.
|
|
443
688
|
mfaVerified: amrClaims.includes('mfa'),
|
|
444
|
-
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 :
|
|
445
|
-
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 :
|
|
446
|
-
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) :
|
|
689
|
+
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
|
|
690
|
+
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
|
|
691
|
+
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
|
|
447
692
|
});
|
|
448
693
|
|
|
449
694
|
/**
|
|
@@ -511,7 +756,7 @@ export async function ensureFreshAccessToken(
|
|
|
511
756
|
// the only way out for a dropped write, so by construction anything reaching
|
|
512
757
|
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
513
758
|
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
514
|
-
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
759
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
|
|
515
760
|
} finally {
|
|
516
761
|
if (weHoldLock) {
|
|
517
762
|
await releaseRefreshLock(sessionToken, requestId, releaseVersion);
|
package/src/lib/idp-fetch.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getSession } from './session-store';
|
|
|
4
4
|
import { internalRefresh } from './internal-api';
|
|
5
5
|
import { assertAbsoluteIdpUrl } from './idp-url-guard';
|
|
6
6
|
import { parseRetryAfterSeconds } from './retry-after';
|
|
7
|
+
import { REFRESH_WINDOW_MS } from './refresh-window';
|
|
7
8
|
|
|
8
9
|
// Re-exported so `idp-fetch` remains the single obvious import for IDP calling
|
|
9
10
|
// concerns; the implementation lives in a dependency-free leaf so the three
|
|
@@ -119,8 +120,8 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
|
|
|
119
120
|
const hasRefreshToken = !!session.idpRefreshToken;
|
|
120
121
|
const now = Date.now();
|
|
121
122
|
const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
|
|
122
|
-
// Treat tokens as effectively expired if within
|
|
123
|
-
if (timeLeft >
|
|
123
|
+
// Treat tokens as effectively expired if within REFRESH_WINDOW_MS of expiry (lib/refresh-window: the one window)
|
|
124
|
+
if (timeLeft > REFRESH_WINDOW_MS) {
|
|
124
125
|
return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
|
|
125
126
|
}
|
|
126
127
|
// Only attempt refresh when we actually have a refresh token to use.
|
package/src/lib/idp-logout.ts
CHANGED
|
@@ -14,8 +14,10 @@ import { ENV_CONFIG } from '../config/env';
|
|
|
14
14
|
* 'revoked' the IdP answered 2xx.
|
|
15
15
|
* 'nothing-to-revoke' the session holds no IdP refresh token (a provisional pre-2FA session has none). No call is made, because
|
|
16
16
|
* without a token Core would pick the newest session.
|
|
17
|
-
* 'grant-already-dead' the revoke was refused
|
|
18
|
-
*
|
|
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.
|
|
19
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
|
|
20
22
|
* turns that into SESSION_NOT_ENDED.
|
|
21
23
|
*
|