@payez/next-mvp 4.10.0 → 4.12.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 +44 -0
- package/dist/api-handlers/account/recovery/forward.js +2 -1
- package/dist/api-handlers/account/recovery/initiate.js +2 -1
- package/dist/api-handlers/account/reset-password.js +2 -1
- package/dist/api-handlers/account/send-code.js +2 -1
- package/dist/api-handlers/account/update-phone.js +2 -1
- package/dist/api-handlers/account/validate-password.js +2 -1
- package/dist/api-handlers/account/verify-email.js +2 -1
- package/dist/api-handlers/account/verify-sms.js +2 -1
- package/dist/api-handlers/anon/preferences.js +3 -2
- package/dist/api-handlers/auth/credential-login-email-code.js +3 -2
- package/dist/api-handlers/auth/credential-login.js +6 -5
- package/dist/api-handlers/auth/refresh.d.ts +17 -58
- package/dist/api-handlers/auth/refresh.js +73 -773
- package/dist/api-handlers/auth/signout.js +43 -6
- package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
- package/dist/api-handlers/mfa/totp-verify.js +2 -1
- package/dist/api-handlers/session/refresh-viability.js +3 -2
- package/dist/api-handlers/session/viability.js +2 -1
- package/dist/auth/better-auth.js +7 -6
- package/dist/auth/idp-oauth.js +4 -2
- package/dist/auth/utils/idp-client.js +5 -4
- package/dist/auth/utils/token-utils.d.ts +1 -1
- package/dist/auth/utils/token-utils.js +7 -5
- package/dist/config/logger.js +2 -1
- package/dist/config/logging-config.js +4 -3
- package/dist/config/vibe-log-transport.js +2 -1
- 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 +195 -28
- package/dist/lib/geolocation.js +5 -22
- package/dist/lib/idp-client-config.js +4 -3
- package/dist/lib/idp-fetch.d.ts +11 -0
- package/dist/lib/idp-fetch.js +6 -3
- package/dist/lib/idp-logout.d.ts +10 -0
- package/dist/lib/idp-logout.js +73 -1
- package/dist/lib/jwt-decode-client.js +2 -1
- package/dist/lib/jwt-decode.js +2 -1
- package/dist/lib/log-safe.d.ts +19 -0
- package/dist/lib/log-safe.js +32 -0
- package/dist/lib/redis.js +2 -1
- package/dist/lib/refresh-in-doubt.d.ts +31 -0
- package/dist/lib/refresh-in-doubt.js +81 -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-error-response.d.ts +5 -1
- package/dist/lib/safe-error-response.js +7 -2
- package/dist/lib/safe-trace-id.d.ts +11 -0
- package/dist/lib/safe-trace-id.js +23 -0
- package/dist/lib/session-store.js +16 -5
- package/dist/lib/site-logger.d.ts +2 -8
- package/dist/lib/site-logger.js +11 -5
- package/dist/lib/standardized-client-api.js +3 -2
- package/dist/lib/token-lifecycle.js +6 -4
- package/dist/middleware/create-middleware.js +2 -1
- package/dist/middleware/rbac-check.d.ts +0 -13
- package/dist/middleware/rbac-check.js +6 -5
- package/dist/models/SessionModel.d.ts +7 -0
- package/dist/routes/auth/logout.js +6 -2
- package/dist/routes/auth/refresh.d.ts +1 -15
- package/dist/routes/auth/settings.js +2 -1
- package/dist/server/auth-guard.js +4 -3
- package/dist/server/auth.d.ts +1 -1
- package/dist/server/auth.js +5 -9
- package/dist/server/decode-session.js +3 -2
- package/dist/server/with-auth.js +2 -1
- package/dist/stores/authStore.js +3 -2
- package/package.json +1 -1
- package/scripts/verify-release-provenance.mjs +1 -0
- package/src/api-handlers/account/recovery/forward.ts +2 -1
- package/src/api-handlers/account/recovery/initiate.ts +2 -1
- package/src/api-handlers/account/reset-password.ts +2 -1
- package/src/api-handlers/account/send-code.ts +2 -1
- package/src/api-handlers/account/update-phone.ts +2 -1
- package/src/api-handlers/account/validate-password.ts +2 -1
- package/src/api-handlers/account/verify-email.ts +2 -1
- package/src/api-handlers/account/verify-sms.ts +2 -1
- package/src/api-handlers/anon/preferences.ts +3 -2
- package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
- package/src/api-handlers/auth/credential-login.ts +6 -5
- package/src/api-handlers/auth/refresh.ts +74 -813
- package/src/api-handlers/auth/signout.ts +54 -7
- package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
- package/src/api-handlers/mfa/totp-verify.ts +2 -1
- package/src/api-handlers/session/refresh-viability.ts +3 -2
- package/src/api-handlers/session/viability.ts +2 -1
- package/src/auth/better-auth.ts +7 -6
- package/src/auth/idp-oauth.ts +7 -2
- package/src/auth/utils/idp-client.ts +5 -4
- package/src/auth/utils/token-utils.ts +7 -5
- package/src/config/logger.ts +2 -1
- package/src/config/logging-config.ts +4 -3
- package/src/config/vibe-log-transport.ts +2 -1
- package/src/lib/api-handler.ts +4 -3
- package/src/lib/ensure-fresh-access-token.ts +214 -29
- package/src/lib/geolocation.ts +5 -4
- package/src/lib/idp-client-config.ts +4 -3
- package/src/lib/idp-fetch.ts +14 -3
- package/src/lib/idp-logout.ts +36 -1
- package/src/lib/jwt-decode-client.ts +3 -1
- package/src/lib/jwt-decode.ts +2 -1
- package/src/lib/log-safe.ts +30 -0
- package/src/lib/redis.ts +2 -1
- package/src/lib/refresh-in-doubt.ts +79 -0
- package/src/lib/refresh-token-validator.ts +2 -1
- package/src/lib/refresh-window.ts +16 -0
- package/src/lib/safe-error-response.ts +7 -2
- package/src/lib/safe-trace-id.ts +21 -0
- package/src/lib/session-store.ts +17 -4
- package/src/lib/site-logger.ts +13 -7
- package/src/lib/standardized-client-api.ts +3 -2
- package/src/lib/token-lifecycle.ts +6 -4
- package/src/middleware/create-middleware.ts +2 -1
- package/src/middleware/rbac-check.ts +2 -1
- package/src/models/SessionModel.ts +7 -0
- package/src/routes/auth/logout.ts +9 -2
- package/src/routes/auth/settings.ts +2 -1
- package/src/server/auth-guard.ts +4 -3
- package/src/server/auth.ts +6 -11
- package/src/server/decode-session.ts +3 -2
- package/src/server/with-auth.ts +2 -1
- package/src/stores/authStore.ts +3 -2
|
@@ -25,6 +25,17 @@ import {
|
|
|
25
25
|
import { computeTokenExpiries } from './token-expiry';
|
|
26
26
|
import { extractKidFromToken } from '../auth/utils/token-utils';
|
|
27
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';
|
|
38
|
+
import { errForLog } from './log-safe';
|
|
28
39
|
|
|
29
40
|
export interface EnsureFreshConfig {
|
|
30
41
|
idpBaseUrl: string;
|
|
@@ -33,10 +44,15 @@ export interface EnsureFreshConfig {
|
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
export interface EnsureFreshOptions {
|
|
36
|
-
/** Refresh if the access token is within this many ms of expiry. Default
|
|
47
|
+
/** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
|
|
37
48
|
safetyWindowMs?: number;
|
|
38
49
|
/** Max wait while another caller holds the refresh lock. Default 5000. */
|
|
39
50
|
lockWaitMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* 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
|
|
53
|
+
* 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.
|
|
54
|
+
*/
|
|
55
|
+
requireRefreshToken?: boolean;
|
|
40
56
|
/**
|
|
41
57
|
* Optional caller request id, for LOG CORRELATION ONLY. It is never the lock identity: the lock id is minted here with
|
|
42
58
|
* crypto.randomUUID(). (BAPert 76298 (b): the lock id used to be this value, taken from the request's x-request-id header.)
|
|
@@ -51,6 +67,14 @@ export type EnsureFreshResult =
|
|
|
51
67
|
accessTokenExpires: number;
|
|
52
68
|
/** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
|
|
53
69
|
refreshed: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* WHY this result is ok (BAPert 76449, additive; `api-handlers/auth/refresh` reproduces its bodies from it and `lib/token-lifecycle` reads
|
|
72
|
+
* `already_fresh`): 'already_fresh' = nothing was needed; 'completed_by_concurrent_request' = another request refreshed and we returned
|
|
73
|
+
* its result; 'refreshed' = THIS call refreshed.
|
|
74
|
+
*/
|
|
75
|
+
reason: 'refreshed' | 'already_fresh' | 'completed_by_concurrent_request';
|
|
76
|
+
/** Whether the stored session holds a refresh token (the refresh route reports it in its body). */
|
|
77
|
+
hasRefreshToken: boolean;
|
|
54
78
|
/**
|
|
55
79
|
* CARD 320072. Is the token in THIS result actually in the session store?
|
|
56
80
|
*
|
|
@@ -89,7 +113,25 @@ export type EnsureFreshResult =
|
|
|
89
113
|
resolution?: string;
|
|
90
114
|
};
|
|
91
115
|
|
|
92
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
|
|
118
|
+
* `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
|
|
119
|
+
*/
|
|
120
|
+
export function checkRefreshEnvelope(body: any, httpOk: boolean): { ok: true } | { ok: false; why: string } {
|
|
121
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, why: 'body is not an object' };
|
|
122
|
+
if (typeof body.success !== 'boolean') return { ok: false, why: 'success is not a boolean' };
|
|
123
|
+
if (body.success === true) {
|
|
124
|
+
if (!httpOk) return { ok: false, why: 'success:true on a non-2xx' };
|
|
125
|
+
if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
|
|
126
|
+
return { ok: false, why: 'success:true without data.access_token' };
|
|
127
|
+
}
|
|
128
|
+
} else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
|
|
129
|
+
return { ok: false, why: 'success:false without error.code' };
|
|
130
|
+
}
|
|
131
|
+
return { ok: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_SAFETY_WINDOW_MS = REFRESH_WINDOW_MS; // was 60_000; one window for R, E and idp-fetch (BAPert 76397 decision 1)
|
|
93
135
|
const DEFAULT_LOCK_WAIT_MS = 5000;
|
|
94
136
|
|
|
95
137
|
function decodeJwtExp(token: string): number {
|
|
@@ -117,18 +159,33 @@ export async function ensureFreshAccessToken(
|
|
|
117
159
|
// reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
|
|
118
160
|
// collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
|
|
119
161
|
const requestId = `ensure_fresh_${randomUUID()}`;
|
|
120
|
-
|
|
162
|
+
// Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
|
|
163
|
+
const traceId = sanitizeTraceId(options.requestId);
|
|
121
164
|
|
|
122
|
-
|
|
165
|
+
const canonicalFirst = await getSession(sessionToken);
|
|
166
|
+
let session = canonicalFirst;
|
|
123
167
|
if (!session || !session.idpRefreshToken) {
|
|
124
168
|
const baSession = await getBetterAuthSession(sessionToken);
|
|
125
169
|
if (baSession?.idpRefreshToken) {
|
|
126
170
|
session = baSession;
|
|
127
171
|
}
|
|
128
172
|
}
|
|
173
|
+
// `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
|
|
174
|
+
// persist writes.
|
|
175
|
+
const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
|
|
129
176
|
if (!session) {
|
|
130
177
|
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
131
178
|
}
|
|
179
|
+
if (options.requireRefreshToken && !session.idpRefreshToken) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
code: 'NO_REFRESH_TOKEN',
|
|
183
|
+
message: 'No refresh token available',
|
|
184
|
+
status: 401,
|
|
185
|
+
terminal: true,
|
|
186
|
+
resolution: 'User must re-authenticate',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
132
189
|
|
|
133
190
|
const storedToken = session.idpAccessToken;
|
|
134
191
|
if (!storedToken) {
|
|
@@ -136,22 +193,28 @@ export async function ensureFreshAccessToken(
|
|
|
136
193
|
// session with no access token and mfaVerified !== true is a user
|
|
137
194
|
// mid-2FA, not dead. Only terminal once the session itself claims 2FA
|
|
138
195
|
// is already done.
|
|
196
|
+
//
|
|
197
|
+
// 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.
|
|
198
|
+
// 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
|
|
199
|
+
// refresh route always did); with no refresh token it is NO_TOKEN as before.
|
|
139
200
|
if (session.mfaVerified !== true) {
|
|
140
201
|
return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
|
|
141
202
|
}
|
|
142
|
-
|
|
203
|
+
if (!session.idpRefreshToken) {
|
|
204
|
+
return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
|
|
205
|
+
}
|
|
143
206
|
}
|
|
144
207
|
|
|
145
208
|
const now = Date.now();
|
|
146
209
|
const redisExpires = session.idpAccessTokenExpires ?? 0;
|
|
147
|
-
const jwtExpires = decodeJwtExp(storedToken);
|
|
210
|
+
const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
|
|
148
211
|
// Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
|
|
149
212
|
const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
|
|
150
213
|
const msUntilExpiry = effectiveExpires - now;
|
|
151
214
|
|
|
152
|
-
if (msUntilExpiry > safetyWindowMs) {
|
|
153
|
-
//
|
|
154
|
-
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted:
|
|
215
|
+
if (storedToken && msUntilExpiry > safetyWindowMs) {
|
|
216
|
+
// This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
|
|
217
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
|
|
155
218
|
}
|
|
156
219
|
|
|
157
220
|
if (!session.idpRefreshToken) {
|
|
@@ -176,7 +239,8 @@ export async function ensureFreshAccessToken(
|
|
|
176
239
|
await new Promise(r => setTimeout(r, 200));
|
|
177
240
|
const stillLocked = await checkRefreshLock(sessionToken);
|
|
178
241
|
if (!stillLocked) {
|
|
179
|
-
|
|
242
|
+
const canonicalAfter = await getSession(sessionToken);
|
|
243
|
+
let after = canonicalAfter;
|
|
180
244
|
if (!after || !after.idpRefreshToken) {
|
|
181
245
|
const baAfter = await getBetterAuthSession(sessionToken);
|
|
182
246
|
if (baAfter?.idpRefreshToken) after = baAfter;
|
|
@@ -189,8 +253,10 @@ export async function ensureFreshAccessToken(
|
|
|
189
253
|
accessToken: after.idpAccessToken,
|
|
190
254
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
191
255
|
refreshed: true,
|
|
192
|
-
|
|
193
|
-
|
|
256
|
+
reason: 'completed_by_concurrent_request',
|
|
257
|
+
hasRefreshToken: !!after.idpRefreshToken,
|
|
258
|
+
// Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
|
|
259
|
+
persisted: !!canonicalAfter && after === canonicalAfter,
|
|
194
260
|
};
|
|
195
261
|
}
|
|
196
262
|
}
|
|
@@ -212,7 +278,8 @@ export async function ensureFreshAccessToken(
|
|
|
212
278
|
|
|
213
279
|
try {
|
|
214
280
|
// Re-check after lock — another caller may have already refreshed.
|
|
215
|
-
|
|
281
|
+
const canonicalLatest = await getSession(sessionToken);
|
|
282
|
+
let latest = canonicalLatest;
|
|
216
283
|
if (!latest || !latest.idpRefreshToken) {
|
|
217
284
|
const baLatest = await getBetterAuthSession(sessionToken);
|
|
218
285
|
if (baLatest?.idpRefreshToken) latest = baLatest;
|
|
@@ -227,12 +294,54 @@ export async function ensureFreshAccessToken(
|
|
|
227
294
|
accessToken: latest.idpAccessToken,
|
|
228
295
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
229
296
|
refreshed: true,
|
|
230
|
-
|
|
231
|
-
|
|
297
|
+
reason: 'completed_by_concurrent_request',
|
|
298
|
+
hasRefreshToken: !!latest.idpRefreshToken,
|
|
299
|
+
// Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
|
|
300
|
+
persisted: !!canonicalLatest && latest === canonicalLatest,
|
|
232
301
|
};
|
|
233
302
|
}
|
|
234
303
|
}
|
|
235
304
|
|
|
305
|
+
// 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
|
|
306
|
+
// one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
|
|
307
|
+
// marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
|
|
308
|
+
if (!latest) {
|
|
309
|
+
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
310
|
+
}
|
|
311
|
+
if (!latest.idpRefreshToken) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
code: 'NO_REFRESH_TOKEN',
|
|
315
|
+
message: 'No refresh token available',
|
|
316
|
+
status: 401,
|
|
317
|
+
terminal: true,
|
|
318
|
+
resolution: 'User must re-authenticate',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
|
|
323
|
+
// absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
|
|
324
|
+
// nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
|
|
325
|
+
// refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
|
|
326
|
+
// re-established.
|
|
327
|
+
if (!canonicalLatest) {
|
|
328
|
+
console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
|
|
329
|
+
requestId,
|
|
330
|
+
traceId,
|
|
331
|
+
betterAuthOnly: !!latest,
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
code: 'NO_CANONICAL_SESSION',
|
|
336
|
+
message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
|
|
337
|
+
status: 401,
|
|
338
|
+
terminal: true,
|
|
339
|
+
retryable: false,
|
|
340
|
+
discardToken: false,
|
|
341
|
+
resolution: 'REAUTHENTICATE',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
236
345
|
// Build refresh request body — wire shape must match what the IDP expects.
|
|
237
346
|
let authMethods: string[] = [];
|
|
238
347
|
if (Array.isArray(session.authenticationMethods)) {
|
|
@@ -262,6 +371,27 @@ export async function ensureFreshAccessToken(
|
|
|
262
371
|
// Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
|
|
263
372
|
// the identity context of THIS request, unchanged by a token rotation.
|
|
264
373
|
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
374
|
+
|
|
375
|
+
// 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
|
|
376
|
+
// replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
|
|
377
|
+
if (isRefreshInDoubt(canonicalLatest, sentRefreshToken)) {
|
|
378
|
+
console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
|
|
379
|
+
requestId,
|
|
380
|
+
traceId,
|
|
381
|
+
inDoubtSince: canonicalLatest.refreshInDoubtAt,
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
code: REFRESH_OUTCOME_UNKNOWN_CODE,
|
|
386
|
+
message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
|
|
387
|
+
status: 401,
|
|
388
|
+
terminal: true,
|
|
389
|
+
retryable: false,
|
|
390
|
+
discardToken: false,
|
|
391
|
+
resolution: 'REAUTHENTICATE',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
265
395
|
const body: Record<string, unknown> = {
|
|
266
396
|
refresh_token: sentRefreshToken,
|
|
267
397
|
amr: authMethods,
|
|
@@ -271,18 +401,45 @@ export async function ensureFreshAccessToken(
|
|
|
271
401
|
if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
|
|
272
402
|
if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
|
|
273
403
|
|
|
404
|
+
// BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
|
|
405
|
+
// replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
|
|
406
|
+
const answerTimedOut = async (cause?: unknown): Promise<EnsureFreshResult> => {
|
|
407
|
+
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', {
|
|
408
|
+
requestId,
|
|
409
|
+
traceId,
|
|
410
|
+
timeoutMs: REFRESH_FETCH_TIMEOUT_MS,
|
|
411
|
+
cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
await updateSession(sessionToken, inDoubtPatch(sentRefreshToken as string));
|
|
415
|
+
} catch (markError) {
|
|
416
|
+
console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: errForLog(markError) });
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
code: UPSTREAM_TIMEOUT_CODE,
|
|
421
|
+
message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
|
|
422
|
+
status: 504,
|
|
423
|
+
terminal: false,
|
|
424
|
+
retryable: false,
|
|
425
|
+
discardToken: false,
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
274
429
|
let idpResponse: Response;
|
|
275
430
|
try {
|
|
276
431
|
idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
|
|
277
432
|
method: 'POST',
|
|
278
433
|
headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
|
|
279
434
|
body: JSON.stringify(body),
|
|
435
|
+
signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
|
|
280
436
|
});
|
|
281
437
|
} catch (err) {
|
|
438
|
+
if (!requestNeverLeft(err)) return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
|
|
282
439
|
return {
|
|
283
440
|
ok: false,
|
|
284
441
|
code: 'UPSTREAM_SERVICE_UNAVAILABLE',
|
|
285
|
-
message:
|
|
442
|
+
message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
|
|
286
443
|
status: 503,
|
|
287
444
|
retryable: true,
|
|
288
445
|
};
|
|
@@ -292,11 +449,30 @@ export async function ensureFreshAccessToken(
|
|
|
292
449
|
try {
|
|
293
450
|
const text = await idpResponse.text();
|
|
294
451
|
if (!text.trim()) {
|
|
452
|
+
if (idpResponse.ok) return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
|
|
295
453
|
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
|
|
296
454
|
}
|
|
297
455
|
responseData = JSON.parse(text);
|
|
298
456
|
} catch (err) {
|
|
299
|
-
return
|
|
457
|
+
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.
|
|
458
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
|
|
462
|
+
// POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
|
|
463
|
+
// message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
|
|
464
|
+
// answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
|
|
465
|
+
const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
|
|
466
|
+
if (!envelope.ok) {
|
|
467
|
+
console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
|
|
468
|
+
requestId,
|
|
469
|
+
traceId,
|
|
470
|
+
// the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
|
|
471
|
+
idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
|
|
472
|
+
status: idpResponse.status,
|
|
473
|
+
why: envelope.why,
|
|
474
|
+
});
|
|
475
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
|
|
300
476
|
}
|
|
301
477
|
|
|
302
478
|
if (!idpResponse.ok) {
|
|
@@ -360,7 +536,9 @@ export async function ensureFreshAccessToken(
|
|
|
360
536
|
accessToken: postRefresh.idpAccessToken,
|
|
361
537
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
362
538
|
refreshed: true,
|
|
363
|
-
|
|
539
|
+
reason: 'completed_by_concurrent_request',
|
|
540
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
541
|
+
// `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
|
|
364
542
|
persisted: true,
|
|
365
543
|
};
|
|
366
544
|
}
|
|
@@ -424,6 +602,8 @@ export async function ensureFreshAccessToken(
|
|
|
424
602
|
accessToken: postRefresh.idpAccessToken,
|
|
425
603
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
426
604
|
refreshed: true,
|
|
605
|
+
reason: 'completed_by_concurrent_request',
|
|
606
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
427
607
|
persisted: true,
|
|
428
608
|
};
|
|
429
609
|
}
|
|
@@ -471,12 +651,12 @@ export async function ensureFreshAccessToken(
|
|
|
471
651
|
try {
|
|
472
652
|
amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
|
|
473
653
|
} catch {
|
|
474
|
-
amrClaims =
|
|
654
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
475
655
|
}
|
|
476
656
|
} else {
|
|
477
|
-
amrClaims =
|
|
657
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
478
658
|
}
|
|
479
|
-
const acrLevel = String(decoded?.acr ||
|
|
659
|
+
const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
|
|
480
660
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
481
661
|
const newKid = extractKidFromToken(newAccess);
|
|
482
662
|
|
|
@@ -487,14 +667,19 @@ export async function ensureFreshAccessToken(
|
|
|
487
667
|
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
488
668
|
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
489
669
|
// block below; this line only makes the answer available.
|
|
670
|
+
//
|
|
671
|
+
// BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
|
|
672
|
+
// pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
|
|
673
|
+
// pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
|
|
674
|
+
// spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
|
|
675
|
+
// concurrent writer's change to anything else survives.
|
|
490
676
|
const persisted = await updateSession(sessionToken, {
|
|
491
|
-
...session,
|
|
492
677
|
idpAccessToken: newAccess,
|
|
493
678
|
idpAccessTokenExpires: accessTokenExpires,
|
|
494
|
-
idpRefreshToken: hasNewRefresh ? newRefresh :
|
|
495
|
-
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires :
|
|
679
|
+
idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
|
|
680
|
+
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
|
|
496
681
|
decodedAccessToken: decoded,
|
|
497
|
-
bearerKeyId: newKid ||
|
|
682
|
+
bearerKeyId: newKid || canonicalLatest.bearerKeyId,
|
|
498
683
|
authenticationMethods: amrClaims,
|
|
499
684
|
authenticationLevel: acrLevel,
|
|
500
685
|
// 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
|
|
@@ -502,9 +687,9 @@ export async function ensureFreshAccessToken(
|
|
|
502
687
|
// rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
|
|
503
688
|
// the same defect duplicated at a second call site.
|
|
504
689
|
mfaVerified: amrClaims.includes('mfa'),
|
|
505
|
-
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 :
|
|
506
|
-
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 :
|
|
507
|
-
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) :
|
|
690
|
+
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
|
|
691
|
+
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
|
|
692
|
+
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
|
|
508
693
|
});
|
|
509
694
|
|
|
510
695
|
/**
|
|
@@ -572,7 +757,7 @@ export async function ensureFreshAccessToken(
|
|
|
572
757
|
// the only way out for a dropped write, so by construction anything reaching
|
|
573
758
|
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
574
759
|
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
575
|
-
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
760
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
|
|
576
761
|
} finally {
|
|
577
762
|
if (weHoldLock) {
|
|
578
763
|
await releaseRefreshLock(sessionToken, requestId, releaseVersion);
|
package/src/lib/geolocation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errForLog } from './log-safe';
|
|
1
2
|
/**
|
|
2
3
|
* =============================================================================
|
|
3
4
|
* GEOLOCATION UTILITY
|
|
@@ -103,7 +104,7 @@ export async function getPublicIP(): Promise<string | null> {
|
|
|
103
104
|
// Validate it looks like an IP
|
|
104
105
|
if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
|
|
105
106
|
cachedPublicIP = { ip, timestamp: Date.now() };
|
|
106
|
-
console.log(
|
|
107
|
+
console.log("[geolocation] Public IP resolved");
|
|
107
108
|
return ip;
|
|
108
109
|
}
|
|
109
110
|
}
|
|
@@ -163,7 +164,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
|
|
|
163
164
|
if (resolvePublicIP) {
|
|
164
165
|
const publicIP = await getPublicIP();
|
|
165
166
|
if (publicIP && !isPrivateIP(publicIP)) {
|
|
166
|
-
console.log(
|
|
167
|
+
console.log("[geolocation] Private IP -> using the resolved public IP");
|
|
167
168
|
// Recursively call with public IP (but don't resolve again to prevent loops)
|
|
168
169
|
return getLocationFromIP(publicIP, false);
|
|
169
170
|
}
|
|
@@ -201,7 +202,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
|
|
|
201
202
|
const data = await response.json();
|
|
202
203
|
|
|
203
204
|
if (data.status !== 'success') {
|
|
204
|
-
console.warn(
|
|
205
|
+
console.warn("[geolocation] Lookup failed (upstream refused)");
|
|
205
206
|
// Cache the failure to avoid repeated lookups
|
|
206
207
|
geoCache.set(ip, { location: null, timestamp: Date.now() });
|
|
207
208
|
return null;
|
|
@@ -224,7 +225,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
|
|
|
224
225
|
return location;
|
|
225
226
|
|
|
226
227
|
} catch (error) {
|
|
227
|
-
console.error(
|
|
228
|
+
console.error("[geolocation] lookup failed:", errForLog(error)); // no IP on a routine line (BAPert 76553 Q1)
|
|
228
229
|
return null;
|
|
229
230
|
}
|
|
230
231
|
}
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import 'server-only';
|
|
44
44
|
import { createHash, randomUUID } from 'crypto';
|
|
45
45
|
import { markNeverInResponse } from './never-in-response';
|
|
46
|
+
import { errForLog } from './log-safe';
|
|
46
47
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
47
48
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
48
49
|
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
@@ -360,7 +361,7 @@ async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
|
|
|
360
361
|
|
|
361
362
|
return parsed.config;
|
|
362
363
|
} catch (error) {
|
|
363
|
-
console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
|
|
364
|
+
console.warn('[IDP_CONFIG] Failed to read from Redis cache:', errForLog(error));
|
|
364
365
|
return null;
|
|
365
366
|
}
|
|
366
367
|
}
|
|
@@ -377,7 +378,7 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
|
|
|
377
378
|
// Store with TTL slightly longer than the logical expiry to allow for clock skew
|
|
378
379
|
await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
|
|
379
380
|
} catch (error) {
|
|
380
|
-
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
|
|
381
|
+
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', errForLog(error));
|
|
381
382
|
}
|
|
382
383
|
}
|
|
383
384
|
|
|
@@ -633,7 +634,7 @@ export async function clearConfigRedisCache(): Promise<void> {
|
|
|
633
634
|
await (await getRedis()).del(key);
|
|
634
635
|
console.log('[IDP_CONFIG] Redis cache cleared:', key);
|
|
635
636
|
} catch (error) {
|
|
636
|
-
console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
|
|
637
|
+
console.warn('[IDP_CONFIG] Failed to clear Redis cache:', errForLog(error));
|
|
637
638
|
}
|
|
638
639
|
}
|
|
639
640
|
|
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
|
|
@@ -45,6 +46,14 @@ export interface IdpFetchOptions {
|
|
|
45
46
|
* `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
|
|
46
47
|
*/
|
|
47
48
|
initFor?: (sessionToken: string) => Promise<RequestInit | null>;
|
|
49
|
+
/**
|
|
50
|
+
* 4.12: the refresh that follows a 401/403 is done by THIS function instead of the HTTP self-call to /api/auth/refresh. Sign-out supplies one that
|
|
51
|
+
* calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
|
|
52
|
+
* invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
|
|
53
|
+
* the grant is live) can say which. No header on the public refresh route, so nothing a browser sends can force a refresh (BAPert 77022).
|
|
54
|
+
* Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
|
|
55
|
+
*/
|
|
56
|
+
refreshOnAuthFailure?: (sessionToken: string) => Promise<{ ok: boolean; status: number }>;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
|
|
@@ -119,8 +128,8 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
|
|
|
119
128
|
const hasRefreshToken = !!session.idpRefreshToken;
|
|
120
129
|
const now = Date.now();
|
|
121
130
|
const timeLeft = (session.idpAccessTokenExpires ?? now) - now;
|
|
122
|
-
// Treat tokens as effectively expired if within
|
|
123
|
-
if (timeLeft >
|
|
131
|
+
// Treat tokens as effectively expired if within REFRESH_WINDOW_MS of expiry (lib/refresh-window: the one window)
|
|
132
|
+
if (timeLeft > REFRESH_WINDOW_MS) {
|
|
124
133
|
return { sessionToken, accessToken: session.idpAccessToken, hasRefreshToken };
|
|
125
134
|
}
|
|
126
135
|
// Only attempt refresh when we actually have a refresh token to use.
|
|
@@ -183,7 +192,9 @@ export async function idpFetchJSON<T = any>(
|
|
|
183
192
|
attemptedRefresh = true;
|
|
184
193
|
try {
|
|
185
194
|
// Use centralized internal API helper for server-to-server calls
|
|
186
|
-
const rf =
|
|
195
|
+
const rf = opts.refreshOnAuthFailure
|
|
196
|
+
? await opts.refreshOnAuthFailure(sessionToken)
|
|
197
|
+
: await internalRefresh(req.headers.get('cookie') || '', sessionToken);
|
|
187
198
|
refreshStatus = rf.status;
|
|
188
199
|
if (rf.ok) {
|
|
189
200
|
const fresh = await getSession(sessionToken);
|
package/src/lib/idp-logout.ts
CHANGED
|
@@ -26,6 +26,19 @@ import { ENV_CONFIG } from '../config/env';
|
|
|
26
26
|
*/
|
|
27
27
|
export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The revoke could not be confirmed. `grantLive` is true ONLY when the IdP itself showed the grant is alive: the forced refresh SUCCEEDED (it issued
|
|
31
|
+
* new tokens) and the logout still did not end it. Every other unknown (a transport error, a 5xx, a refresh that threw or answered something
|
|
32
|
+
* else) leaves it false: the grant may be live or dead, and the log must not claim either (BAPert 77013). The closed `code` is what errForLog prints.
|
|
33
|
+
*/
|
|
34
|
+
export class IdpLogoutUnconfirmedError extends Error {
|
|
35
|
+
readonly code = 'IDP_LOGOUT_UNCONFIRMED';
|
|
36
|
+
constructor(status: number, readonly grantLive: boolean) {
|
|
37
|
+
super(`IdP logout answered ${status}`);
|
|
38
|
+
this.name = 'IdpLogoutUnconfirmedError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome> {
|
|
30
43
|
let resolvedSession: string | undefined;
|
|
31
44
|
const result = await idpFetchJSON(
|
|
@@ -33,6 +46,9 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
|
|
|
33
46
|
`${ENV_CONFIG.IDP_URL}/api/Account/logout`,
|
|
34
47
|
{},
|
|
35
48
|
{
|
|
49
|
+
// A logout 401 with a locally-fresh token must reach a REAL refresh, so the IdP's answer can prove the grant dead (BAPert 77013). Done
|
|
50
|
+
// in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
|
|
51
|
+
refreshOnAuthFailure: forceRefreshAtIdp,
|
|
36
52
|
initFor: async (token) => {
|
|
37
53
|
resolvedSession = token;
|
|
38
54
|
let session = await getSession(token);
|
|
@@ -50,7 +66,26 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
|
|
|
50
66
|
if (result.skipped) return 'nothing-to-revoke';
|
|
51
67
|
if (result.ok) return 'revoked';
|
|
52
68
|
if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession))) return 'grant-already-dead';
|
|
53
|
-
|
|
69
|
+
const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
|
|
70
|
+
throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
|
|
75
|
+
* implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
|
|
76
|
+
* (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
|
|
77
|
+
* NO_SESSION / NO_REFRESH_TOKEN, 409 for a lost lock, otherwise E's own status (a discarded IdP 401 is 401 with the marker written).
|
|
78
|
+
* Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
|
|
79
|
+
*/
|
|
80
|
+
async function forceRefreshAtIdp(sessionToken: string): Promise<{ ok: boolean; status: number }> {
|
|
81
|
+
const idpBaseUrl = process.env.IDP_URL;
|
|
82
|
+
const clientId = process.env.CLIENT_ID;
|
|
83
|
+
if (!idpBaseUrl || !clientId) throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
|
|
84
|
+
const { ensureFreshAccessToken } = await import('./ensure-fresh-access-token');
|
|
85
|
+
const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
|
|
86
|
+
if (r.ok) return { ok: true, status: 200 };
|
|
87
|
+
const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
|
|
88
|
+
return { ok: false, status };
|
|
54
89
|
}
|
|
55
90
|
|
|
56
91
|
/**
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* This is a lightweight version for browser usage
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { errForLog } from './log-safe';
|
|
7
|
+
|
|
6
8
|
// Decode base64url
|
|
7
9
|
function base64urlDecode(base64url: string): string {
|
|
8
10
|
try {
|
|
@@ -39,7 +41,7 @@ export function jwtDecode<T = any>(token: string): T | null {
|
|
|
39
41
|
|
|
40
42
|
return parsedPayload;
|
|
41
43
|
} catch (e) {
|
|
42
|
-
console.error('[JWT] Decode failed:', e
|
|
44
|
+
console.error('[JWT] Decode failed:', errForLog(e));
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
45
47
|
}
|
package/src/lib/jwt-decode.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jwtDecode as originalJwtDecode } from 'jwt-decode';
|
|
2
|
+
import { errForLog } from './log-safe';
|
|
2
3
|
|
|
3
4
|
// Define JwtPayload interface since jwt-decode v4 doesn't export it
|
|
4
5
|
export interface JwtPayload {
|
|
@@ -65,7 +66,7 @@ export function decodeJwtHeader(token: string): JwtHeader | null {
|
|
|
65
66
|
|
|
66
67
|
return JSON.parse(headerJson) as JwtHeader;
|
|
67
68
|
} catch (error) {
|
|
68
|
-
console.error('[JWT_DECODE] Failed to decode JWT header:', error);
|
|
69
|
+
console.error('[JWT_DECODE] Failed to decode JWT header:', errForLog(error));
|
|
69
70
|
return null;
|
|
70
71
|
}
|
|
71
72
|
}
|