@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
|
@@ -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.
|
|
@@ -21,7 +22,32 @@ 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
24
|
const refresh_rotation_1 = require("./refresh-rotation");
|
|
24
|
-
const
|
|
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
|
+
const log_safe_1 = require("./log-safe");
|
|
29
|
+
/**
|
|
30
|
+
* The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
|
|
31
|
+
* `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
|
|
32
|
+
*/
|
|
33
|
+
function checkRefreshEnvelope(body, httpOk) {
|
|
34
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
35
|
+
return { ok: false, why: 'body is not an object' };
|
|
36
|
+
if (typeof body.success !== 'boolean')
|
|
37
|
+
return { ok: false, why: 'success is not a boolean' };
|
|
38
|
+
if (body.success === true) {
|
|
39
|
+
if (!httpOk)
|
|
40
|
+
return { ok: false, why: 'success:true on a non-2xx' };
|
|
41
|
+
if (!body.data || typeof body.data !== 'object' || typeof body.data.access_token !== 'string' || !body.data.access_token) {
|
|
42
|
+
return { ok: false, why: 'success:true without data.access_token' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (!body.error || typeof body.error !== 'object' || typeof body.error.code !== 'string' || !body.error.code) {
|
|
46
|
+
return { ok: false, why: 'success:false without error.code' };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
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)
|
|
25
51
|
const DEFAULT_LOCK_WAIT_MS = 5000;
|
|
26
52
|
function decodeJwtExp(token) {
|
|
27
53
|
const parts = token.split('.');
|
|
@@ -45,37 +71,58 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
45
71
|
// reuse as compromise and revokes descendants across devices) must not be bypassable by an id the caller picks. Date.now() also
|
|
46
72
|
// collided for two calls in the same millisecond. The caller's id is kept as `traceId`, for logs only.
|
|
47
73
|
const requestId = `ensure_fresh_${(0, crypto_1.randomUUID)()}`;
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
// Attacker-chosen text going to the log sink: control characters stripped and length capped (PAY-2376).
|
|
75
|
+
const traceId = (0, safe_trace_id_1.sanitizeTraceId)(options.requestId);
|
|
76
|
+
const canonicalFirst = await (0, session_store_1.getSession)(sessionToken);
|
|
77
|
+
let session = canonicalFirst;
|
|
50
78
|
if (!session || !session.idpRefreshToken) {
|
|
51
79
|
const baSession = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
52
80
|
if (baSession?.idpRefreshToken) {
|
|
53
81
|
session = baSession;
|
|
54
82
|
}
|
|
55
83
|
}
|
|
84
|
+
// `persisted` means the CANONICAL row (BAPert 76449): a token served from the Better Auth fallback record is not stored where the
|
|
85
|
+
// persist writes.
|
|
86
|
+
const sessionIsCanonical = !!canonicalFirst && session === canonicalFirst;
|
|
56
87
|
if (!session) {
|
|
57
88
|
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
58
89
|
}
|
|
90
|
+
if (options.requireRefreshToken && !session.idpRefreshToken) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
code: 'NO_REFRESH_TOKEN',
|
|
94
|
+
message: 'No refresh token available',
|
|
95
|
+
status: 401,
|
|
96
|
+
terminal: true,
|
|
97
|
+
resolution: 'User must re-authenticate',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
59
100
|
const storedToken = session.idpAccessToken;
|
|
60
101
|
if (!storedToken) {
|
|
61
102
|
// Card 187871: same conflation as getIdpToken (server/auth.ts) - a live
|
|
62
103
|
// session with no access token and mfaVerified !== true is a user
|
|
63
104
|
// mid-2FA, not dead. Only terminal once the session itself claims 2FA
|
|
64
105
|
// is already done.
|
|
106
|
+
//
|
|
107
|
+
// 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.
|
|
108
|
+
// 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
|
|
109
|
+
// refresh route always did); with no refresh token it is NO_TOKEN as before.
|
|
65
110
|
if (session.mfaVerified !== true) {
|
|
66
111
|
return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
|
|
67
112
|
}
|
|
68
|
-
|
|
113
|
+
if (!session.idpRefreshToken) {
|
|
114
|
+
return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
|
|
115
|
+
}
|
|
69
116
|
}
|
|
70
117
|
const now = Date.now();
|
|
71
118
|
const redisExpires = session.idpAccessTokenExpires ?? 0;
|
|
72
|
-
const jwtExpires = decodeJwtExp(storedToken);
|
|
119
|
+
const jwtExpires = storedToken ? decodeJwtExp(storedToken) : 0;
|
|
73
120
|
// Use the smaller of the two to be conservative — Redis can be stale, JWT exp is authoritative.
|
|
74
121
|
const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
|
|
75
122
|
const msUntilExpiry = effectiveExpires - now;
|
|
76
|
-
if (msUntilExpiry > safetyWindowMs) {
|
|
77
|
-
//
|
|
78
|
-
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted:
|
|
123
|
+
if (storedToken && msUntilExpiry > safetyWindowMs) {
|
|
124
|
+
// This token came OUT of the store and we wrote nothing; `persisted` is true only when that store row is the canonical one.
|
|
125
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, reason: 'already_fresh', hasRefreshToken: !!session.idpRefreshToken, persisted: sessionIsCanonical };
|
|
79
126
|
}
|
|
80
127
|
if (!session.idpRefreshToken) {
|
|
81
128
|
return {
|
|
@@ -97,7 +144,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
97
144
|
await new Promise(r => setTimeout(r, 200));
|
|
98
145
|
const stillLocked = await (0, session_store_1.checkRefreshLock)(sessionToken);
|
|
99
146
|
if (!stillLocked) {
|
|
100
|
-
|
|
147
|
+
const canonicalAfter = await (0, session_store_1.getSession)(sessionToken);
|
|
148
|
+
let after = canonicalAfter;
|
|
101
149
|
if (!after || !after.idpRefreshToken) {
|
|
102
150
|
const baAfter = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
103
151
|
if (baAfter?.idpRefreshToken)
|
|
@@ -111,8 +159,10 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
111
159
|
accessToken: after.idpAccessToken,
|
|
112
160
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
113
161
|
refreshed: true,
|
|
114
|
-
|
|
115
|
-
|
|
162
|
+
reason: 'completed_by_concurrent_request',
|
|
163
|
+
hasRefreshToken: !!after.idpRefreshToken,
|
|
164
|
+
// Read back from the store after the winner's refresh; `persisted` only when that row is the canonical one.
|
|
165
|
+
persisted: !!canonicalAfter && after === canonicalAfter,
|
|
116
166
|
};
|
|
117
167
|
}
|
|
118
168
|
}
|
|
@@ -134,7 +184,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
134
184
|
}
|
|
135
185
|
try {
|
|
136
186
|
// Re-check after lock — another caller may have already refreshed.
|
|
137
|
-
|
|
187
|
+
const canonicalLatest = await (0, session_store_1.getSession)(sessionToken);
|
|
188
|
+
let latest = canonicalLatest;
|
|
138
189
|
if (!latest || !latest.idpRefreshToken) {
|
|
139
190
|
const baLatest = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
140
191
|
if (baLatest?.idpRefreshToken)
|
|
@@ -150,11 +201,51 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
150
201
|
accessToken: latest.idpAccessToken,
|
|
151
202
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
152
203
|
refreshed: true,
|
|
153
|
-
|
|
154
|
-
|
|
204
|
+
reason: 'completed_by_concurrent_request',
|
|
205
|
+
hasRefreshToken: !!latest.idpRefreshToken,
|
|
206
|
+
// Post-lock re-read: another caller already refreshed and stored this; `persisted` only when the row read is the canonical one.
|
|
207
|
+
persisted: !!canonicalLatest && latest === canonicalLatest,
|
|
155
208
|
};
|
|
156
209
|
}
|
|
157
210
|
}
|
|
211
|
+
// 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
|
|
212
|
+
// one. A row that VANISHED while we waited for the lock is 'no session'; a row whose refresh token was DISCARDED meanwhile (token cleared,
|
|
213
|
+
// marker written by a concurrent request) is the terminal NO_REFRESH_TOKEN. Neither reaches the IdP, and an empty token is never sent.
|
|
214
|
+
if (!latest) {
|
|
215
|
+
return { ok: false, code: 'NO_SESSION', message: 'No session for token', status: 401, terminal: true };
|
|
216
|
+
}
|
|
217
|
+
if (!latest.idpRefreshToken) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
code: 'NO_REFRESH_TOKEN',
|
|
221
|
+
message: 'No refresh token available',
|
|
222
|
+
status: 401,
|
|
223
|
+
terminal: true,
|
|
224
|
+
resolution: 'User must re-authenticate',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// BAPert 76397 Finding 1: the persist below writes ONLY to the canonical row (updateSession merges into it and returns null when it is
|
|
228
|
+
// absent). A session that lives only under the Better Auth key can be READ above through the fallback, but the rotated tokens would have
|
|
229
|
+
// nowhere to go: the IdP would spend the refresh token and we would lose its replacement (SESSION_PERSIST_FAILED, after the fact). So
|
|
230
|
+
// refuse BEFORE the IdP call. Nothing is spent, nothing is discarded, the Better Auth record is left alone; the session has to be
|
|
231
|
+
// re-established.
|
|
232
|
+
if (!canonicalLatest) {
|
|
233
|
+
console.warn('[ENSURE-FRESH] refresh refused before the IDP call - no canonical session row to persist into', {
|
|
234
|
+
requestId,
|
|
235
|
+
traceId,
|
|
236
|
+
betterAuthOnly: !!latest,
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
code: 'NO_CANONICAL_SESSION',
|
|
241
|
+
message: 'The session has no canonical record to store a refreshed token in; no refresh was attempted.',
|
|
242
|
+
status: 401,
|
|
243
|
+
terminal: true,
|
|
244
|
+
retryable: false,
|
|
245
|
+
discardToken: false,
|
|
246
|
+
resolution: 'REAUTHENTICATE',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
158
249
|
// Build refresh request body — wire shape must match what the IDP expects.
|
|
159
250
|
let authMethods = [];
|
|
160
251
|
if (Array.isArray(session.authenticationMethods)) {
|
|
@@ -185,6 +276,25 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
185
276
|
// Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
|
|
186
277
|
// the identity context of THIS request, unchanged by a token rotation.
|
|
187
278
|
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
279
|
+
// 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
|
|
280
|
+
// replay revokes every newer token the user holds. Refuse WITHOUT calling the IdP; one fresh login instead.
|
|
281
|
+
if ((0, refresh_in_doubt_1.isRefreshInDoubt)(canonicalLatest, sentRefreshToken)) {
|
|
282
|
+
console.error('[ENSURE-FRESH] refusing to re-send a refresh token whose earlier attempt timed out (outcome unknown)', {
|
|
283
|
+
requestId,
|
|
284
|
+
traceId,
|
|
285
|
+
inDoubtSince: canonicalLatest.refreshInDoubtAt,
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
code: refresh_in_doubt_1.REFRESH_OUTCOME_UNKNOWN_CODE,
|
|
290
|
+
message: 'An earlier refresh for this session did not complete and its outcome is unknown; the session must be re-established.',
|
|
291
|
+
status: 401,
|
|
292
|
+
terminal: true,
|
|
293
|
+
retryable: false,
|
|
294
|
+
discardToken: false,
|
|
295
|
+
resolution: 'REAUTHENTICATE',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
188
298
|
const body = {
|
|
189
299
|
refresh_token: sentRefreshToken,
|
|
190
300
|
amr: authMethods,
|
|
@@ -196,19 +306,47 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
196
306
|
body.two_factor_method = twoFactorMethod;
|
|
197
307
|
if (session.mfaCompletedAt)
|
|
198
308
|
body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
|
|
309
|
+
// BAPert 76403: a timeout is OUTCOME-UNKNOWN. Record the identity of the token we sent (a hash) so the next attempt refuses instead of
|
|
310
|
+
// replaying it, and answer 504. `retryable` is false on purpose: the retry would be refused with REFRESH_OUTCOME_UNKNOWN.
|
|
311
|
+
const answerTimedOut = async (cause) => {
|
|
312
|
+
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', {
|
|
313
|
+
requestId,
|
|
314
|
+
traceId,
|
|
315
|
+
timeoutMs: refresh_in_doubt_1.REFRESH_FETCH_TIMEOUT_MS,
|
|
316
|
+
cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : undefined,
|
|
317
|
+
});
|
|
318
|
+
try {
|
|
319
|
+
await (0, session_store_1.updateSession)(sessionToken, (0, refresh_in_doubt_1.inDoubtPatch)(sentRefreshToken));
|
|
320
|
+
}
|
|
321
|
+
catch (markError) {
|
|
322
|
+
console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: (0, log_safe_1.errForLog)(markError) });
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
code: refresh_in_doubt_1.UPSTREAM_TIMEOUT_CODE,
|
|
327
|
+
message: 'The identity service did not answer in time; the outcome of this refresh is unknown.',
|
|
328
|
+
status: 504,
|
|
329
|
+
terminal: false,
|
|
330
|
+
retryable: false,
|
|
331
|
+
discardToken: false,
|
|
332
|
+
};
|
|
333
|
+
};
|
|
199
334
|
let idpResponse;
|
|
200
335
|
try {
|
|
201
336
|
idpResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
|
|
202
337
|
method: 'POST',
|
|
203
338
|
headers: { 'Content-Type': 'application/json', 'X-Client-Id': clientId },
|
|
204
339
|
body: JSON.stringify(body),
|
|
340
|
+
signal: AbortSignal.timeout(refresh_in_doubt_1.REFRESH_FETCH_TIMEOUT_MS),
|
|
205
341
|
});
|
|
206
342
|
}
|
|
207
343
|
catch (err) {
|
|
344
|
+
if (!(0, refresh_in_doubt_1.requestNeverLeft)(err))
|
|
345
|
+
return await answerTimedOut(err); // timeout or ANY transport failure that does not prove the request never left (BAPert 76433)
|
|
208
346
|
return {
|
|
209
347
|
ok: false,
|
|
210
348
|
code: 'UPSTREAM_SERVICE_UNAVAILABLE',
|
|
211
|
-
message:
|
|
349
|
+
message: 'IDP service unavailable', // BAPert 76449: R's wording; the raw error text (host names, socket details) is not returned
|
|
212
350
|
status: 503,
|
|
213
351
|
retryable: true,
|
|
214
352
|
};
|
|
@@ -217,12 +355,32 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
217
355
|
try {
|
|
218
356
|
const text = await idpResponse.text();
|
|
219
357
|
if (!text.trim()) {
|
|
358
|
+
if (idpResponse.ok)
|
|
359
|
+
return await answerTimedOut(new Error('empty 2xx body')); // a 2xx with no body: the replacement is lost (BAPert 76441)
|
|
220
360
|
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Empty response from IDP', status: 502, retryable: true };
|
|
221
361
|
}
|
|
222
362
|
responseData = JSON.parse(text);
|
|
223
363
|
}
|
|
224
364
|
catch (err) {
|
|
225
|
-
|
|
365
|
+
if (!(err instanceof SyntaxError) || idpResponse.ok)
|
|
366
|
+
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.
|
|
367
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Invalid response from IDP', status: 502, retryable: true };
|
|
368
|
+
}
|
|
369
|
+
// BAPert 76455 (B): ONE envelope rule for both refresh implementations. `meta` is NOT required: Core's four hint-carrying error returns on
|
|
370
|
+
// POST /api/ExternalAuth/refresh (ExternalAuthController :3708, :3778, :3805, :3822) are hand-built { success:false, error:{ code,
|
|
371
|
+
// message, discard_token, retryable, resolution } } objects with no meta, no data, no request_id, no time_stamp, and they are exactly the
|
|
372
|
+
// answers whose code and flags matter. Requiring meta would turn them into a 502 and lose both.
|
|
373
|
+
const envelope = checkRefreshEnvelope(responseData, idpResponse.ok);
|
|
374
|
+
if (!envelope.ok) {
|
|
375
|
+
console.warn('[ENSURE-FRESH] non-compliant IDP envelope', {
|
|
376
|
+
requestId,
|
|
377
|
+
traceId,
|
|
378
|
+
// the response header if Core sent one, otherwise our own generated id - never a made-up Core request_id
|
|
379
|
+
idpRequestId: idpResponse.headers?.get?.('x-request-id') ?? requestId,
|
|
380
|
+
status: idpResponse.status,
|
|
381
|
+
why: envelope.why,
|
|
382
|
+
});
|
|
383
|
+
return { ok: false, code: 'UPSTREAM_SERVICE_ERROR', message: 'Non-compliant IDP envelope', status: 502, retryable: true };
|
|
226
384
|
}
|
|
227
385
|
if (!idpResponse.ok) {
|
|
228
386
|
const idpError = responseData?.error || {};
|
|
@@ -285,7 +443,9 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
285
443
|
accessToken: postRefresh.idpAccessToken,
|
|
286
444
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
287
445
|
refreshed: true,
|
|
288
|
-
|
|
446
|
+
reason: 'completed_by_concurrent_request',
|
|
447
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
448
|
+
// `postRefresh` is a canonical store read (getSession), so this token is stored where the persist writes.
|
|
289
449
|
persisted: true,
|
|
290
450
|
};
|
|
291
451
|
}
|
|
@@ -348,6 +508,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
348
508
|
accessToken: postRefresh.idpAccessToken,
|
|
349
509
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
350
510
|
refreshed: true,
|
|
511
|
+
reason: 'completed_by_concurrent_request',
|
|
512
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
351
513
|
persisted: true,
|
|
352
514
|
};
|
|
353
515
|
}
|
|
@@ -391,13 +553,13 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
391
553
|
amrClaims = typeof decoded.amr === 'string' ? JSON.parse(decoded.amr) : decoded.amr;
|
|
392
554
|
}
|
|
393
555
|
catch {
|
|
394
|
-
amrClaims =
|
|
556
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
395
557
|
}
|
|
396
558
|
}
|
|
397
559
|
else {
|
|
398
|
-
amrClaims =
|
|
560
|
+
amrClaims = canonicalLatest.authenticationMethods || [];
|
|
399
561
|
}
|
|
400
|
-
const acrLevel = String(decoded?.acr ||
|
|
562
|
+
const acrLevel = String(decoded?.acr || canonicalLatest.authenticationLevel || '1');
|
|
401
563
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
402
564
|
const newKid = (0, token_utils_1.extractKidFromToken)(newAccess);
|
|
403
565
|
// *** CARD 320072. CAPTURE THIS RETURN. ***
|
|
@@ -407,14 +569,19 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
407
569
|
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
408
570
|
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
409
571
|
// block below; this line only makes the answer available.
|
|
572
|
+
//
|
|
573
|
+
// BAPert 76449 item 11 (the twin of 1432 (b2)): every FALLBACK below reads the POST-lock canonical row (`canonicalLatest`), never the
|
|
574
|
+
// pre-lock `session`. With no new refresh token from the IdP the store must keep the token that was SENT (`sentRefreshToken`); the
|
|
575
|
+
// pre-lock value is spent whenever a concurrent request rotated the token while this one waited for the lock. The old `...session`
|
|
576
|
+
// spread (the whole PRE-lock row) is gone too: updateSession merges into the current row, so only the fields below are written and a
|
|
577
|
+
// concurrent writer's change to anything else survives.
|
|
410
578
|
const persisted = await (0, session_store_1.updateSession)(sessionToken, {
|
|
411
|
-
...session,
|
|
412
579
|
idpAccessToken: newAccess,
|
|
413
580
|
idpAccessTokenExpires: accessTokenExpires,
|
|
414
|
-
idpRefreshToken: hasNewRefresh ? newRefresh :
|
|
415
|
-
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires :
|
|
581
|
+
idpRefreshToken: hasNewRefresh ? newRefresh : sentRefreshToken,
|
|
582
|
+
idpRefreshTokenExpires: hasNewRefresh ? refreshTokenExpires : canonicalLatest.idpRefreshTokenExpires,
|
|
416
583
|
decodedAccessToken: decoded,
|
|
417
|
-
bearerKeyId: newKid ||
|
|
584
|
+
bearerKeyId: newKid || canonicalLatest.bearerKeyId,
|
|
418
585
|
authenticationMethods: amrClaims,
|
|
419
586
|
authenticationLevel: acrLevel,
|
|
420
587
|
// 170007 rule 2 (BAPert ruling on 121350): re-derive from the token
|
|
@@ -422,9 +589,9 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
422
589
|
// rationale in api-handlers/auth/refresh.ts's sessionUpdate — this is
|
|
423
590
|
// the same defect duplicated at a second call site.
|
|
424
591
|
mfaVerified: amrClaims.includes('mfa'),
|
|
425
|
-
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 :
|
|
426
|
-
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 :
|
|
427
|
-
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) :
|
|
592
|
+
mfaCompletedAt: decoded?.mfa_time ? parseInt(decoded.mfa_time) * 1000 : canonicalLatest.mfaCompletedAt,
|
|
593
|
+
mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : canonicalLatest.mfaExpiresAt,
|
|
594
|
+
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : canonicalLatest.mfaValidityHours,
|
|
428
595
|
});
|
|
429
596
|
/**
|
|
430
597
|
* 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
|
|
@@ -489,7 +656,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
489
656
|
// the only way out for a dropped write, so by construction anything reaching
|
|
490
657
|
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
491
658
|
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
492
|
-
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
659
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, reason: 'refreshed', hasRefreshToken: true, persisted: true };
|
|
493
660
|
}
|
|
494
661
|
finally {
|
|
495
662
|
if (weHoldLock) {
|
package/dist/lib/geolocation.js
CHANGED
|
@@ -1,22 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* =============================================================================
|
|
4
|
-
* GEOLOCATION UTILITY
|
|
5
|
-
* =============================================================================
|
|
6
|
-
*
|
|
7
|
-
* Server-side utility for resolving IP addresses to geographic locations.
|
|
8
|
-
* Uses ip-api.com (free tier: 45 requests/minute).
|
|
9
|
-
*
|
|
10
|
-
* USAGE:
|
|
11
|
-
* ------
|
|
12
|
-
* import { getLocationFromIP, extractClientIP } from '@payez/next-mvp/lib/geolocation';
|
|
13
|
-
*
|
|
14
|
-
* const ip = extractClientIP(request.headers);
|
|
15
|
-
* const location = await getLocationFromIP(ip);
|
|
16
|
-
* console.log(location?.city); // 'Mountain View'
|
|
17
|
-
*
|
|
18
|
-
* =============================================================================
|
|
19
|
-
*/
|
|
20
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
3
|
exports.isPrivateIP = isPrivateIP;
|
|
22
4
|
exports.getPublicIP = getPublicIP;
|
|
@@ -25,6 +7,7 @@ exports.getLocationFromIP = getLocationFromIP;
|
|
|
25
7
|
exports.formatLocation = formatLocation;
|
|
26
8
|
exports.getCountryFlag = getCountryFlag;
|
|
27
9
|
exports.clearGeoCache = clearGeoCache;
|
|
10
|
+
const log_safe_1 = require("./log-safe");
|
|
28
11
|
// -----------------------------------------------------------------------------
|
|
29
12
|
// CACHE CONFIGURATION
|
|
30
13
|
// -----------------------------------------------------------------------------
|
|
@@ -90,7 +73,7 @@ async function getPublicIP() {
|
|
|
90
73
|
// Validate it looks like an IP
|
|
91
74
|
if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
|
|
92
75
|
cachedPublicIP = { ip, timestamp: Date.now() };
|
|
93
|
-
console.log(
|
|
76
|
+
console.log("[geolocation] Public IP resolved");
|
|
94
77
|
return ip;
|
|
95
78
|
}
|
|
96
79
|
}
|
|
@@ -147,7 +130,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
|
|
|
147
130
|
if (resolvePublicIP) {
|
|
148
131
|
const publicIP = await getPublicIP();
|
|
149
132
|
if (publicIP && !isPrivateIP(publicIP)) {
|
|
150
|
-
console.log(
|
|
133
|
+
console.log("[geolocation] Private IP -> using the resolved public IP");
|
|
151
134
|
// Recursively call with public IP (but don't resolve again to prevent loops)
|
|
152
135
|
return getLocationFromIP(publicIP, false);
|
|
153
136
|
}
|
|
@@ -176,7 +159,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
|
|
|
176
159
|
}
|
|
177
160
|
const data = await response.json();
|
|
178
161
|
if (data.status !== 'success') {
|
|
179
|
-
console.warn(
|
|
162
|
+
console.warn("[geolocation] Lookup failed (upstream refused)");
|
|
180
163
|
// Cache the failure to avoid repeated lookups
|
|
181
164
|
geoCache.set(ip, { location: null, timestamp: Date.now() });
|
|
182
165
|
return null;
|
|
@@ -196,7 +179,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
|
|
|
196
179
|
return location;
|
|
197
180
|
}
|
|
198
181
|
catch (error) {
|
|
199
|
-
console.error(
|
|
182
|
+
console.error("[geolocation] lookup failed:", (0, log_safe_1.errForLog)(error)); // no IP on a routine line (BAPert 76553 Q1)
|
|
200
183
|
return null;
|
|
201
184
|
}
|
|
202
185
|
}
|
|
@@ -82,6 +82,7 @@ exports.getEnabledProviders = getEnabledProviders;
|
|
|
82
82
|
require("server-only");
|
|
83
83
|
const crypto_1 = require("crypto");
|
|
84
84
|
const never_in_response_1 = require("./never-in-response");
|
|
85
|
+
const log_safe_1 = require("./log-safe");
|
|
85
86
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
86
87
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
87
88
|
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
@@ -276,7 +277,7 @@ async function getConfigFromRedis() {
|
|
|
276
277
|
return parsed.config;
|
|
277
278
|
}
|
|
278
279
|
catch (error) {
|
|
279
|
-
console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
|
|
280
|
+
console.warn('[IDP_CONFIG] Failed to read from Redis cache:', (0, log_safe_1.errForLog)(error));
|
|
280
281
|
return null;
|
|
281
282
|
}
|
|
282
283
|
}
|
|
@@ -293,7 +294,7 @@ async function setConfigInRedis(config) {
|
|
|
293
294
|
await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
|
|
294
295
|
}
|
|
295
296
|
catch (error) {
|
|
296
|
-
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
|
|
297
|
+
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', (0, log_safe_1.errForLog)(error));
|
|
297
298
|
}
|
|
298
299
|
}
|
|
299
300
|
// ============================================================================
|
|
@@ -529,7 +530,7 @@ async function clearConfigRedisCache() {
|
|
|
529
530
|
console.log('[IDP_CONFIG] Redis cache cleared:', key);
|
|
530
531
|
}
|
|
531
532
|
catch (error) {
|
|
532
|
-
console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
|
|
533
|
+
console.warn('[IDP_CONFIG] Failed to clear Redis cache:', (0, log_safe_1.errForLog)(error));
|
|
533
534
|
}
|
|
534
535
|
}
|
|
535
536
|
/**
|
package/dist/lib/idp-fetch.d.ts
CHANGED
|
@@ -35,5 +35,16 @@ export interface IdpFetchOptions {
|
|
|
35
35
|
* `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
|
|
36
36
|
*/
|
|
37
37
|
initFor?: (sessionToken: string) => Promise<RequestInit | null>;
|
|
38
|
+
/**
|
|
39
|
+
* 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
|
|
40
|
+
* calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
|
|
41
|
+
* invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
|
|
42
|
+
* 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).
|
|
43
|
+
* Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
|
|
44
|
+
*/
|
|
45
|
+
refreshOnAuthFailure?: (sessionToken: string) => Promise<{
|
|
46
|
+
ok: boolean;
|
|
47
|
+
status: number;
|
|
48
|
+
}>;
|
|
38
49
|
}
|
|
39
50
|
export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit, opts?: IdpFetchOptions): Promise<IdpFetchResult<T>>;
|
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.
|
|
@@ -179,7 +180,9 @@ async function idpFetchJSON(req, targetUrl, init = {}, opts = {}) {
|
|
|
179
180
|
attemptedRefresh = true;
|
|
180
181
|
try {
|
|
181
182
|
// Use centralized internal API helper for server-to-server calls
|
|
182
|
-
const rf =
|
|
183
|
+
const rf = opts.refreshOnAuthFailure
|
|
184
|
+
? await opts.refreshOnAuthFailure(sessionToken)
|
|
185
|
+
: await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
|
|
183
186
|
refreshStatus = rf.status;
|
|
184
187
|
if (rf.ok) {
|
|
185
188
|
const fresh = await (0, session_store_1.getSession)(sessionToken);
|
package/dist/lib/idp-logout.d.ts
CHANGED
|
@@ -21,4 +21,14 @@ import type { NextRequest } from 'next/server';
|
|
|
21
21
|
* refresh token, and a body built earlier would name the old one (NightHawk 76288).
|
|
22
22
|
*/
|
|
23
23
|
export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
|
|
24
|
+
/**
|
|
25
|
+
* 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
|
|
26
|
+
* 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
|
|
27
|
+
* 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.
|
|
28
|
+
*/
|
|
29
|
+
export declare class IdpLogoutUnconfirmedError extends Error {
|
|
30
|
+
readonly grantLive: boolean;
|
|
31
|
+
readonly code = "IDP_LOGOUT_UNCONFIRMED";
|
|
32
|
+
constructor(status: number, grantLive: boolean);
|
|
33
|
+
}
|
|
24
34
|
export declare function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome>;
|
package/dist/lib/idp-logout.js
CHANGED
|
@@ -1,12 +1,64 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.IdpLogoutUnconfirmedError = void 0;
|
|
3
37
|
exports.revokeSessionAtIdp = revokeSessionAtIdp;
|
|
4
38
|
const idp_fetch_1 = require("./idp-fetch");
|
|
5
39
|
const session_store_1 = require("./session-store");
|
|
6
40
|
const env_1 = require("../config/env");
|
|
41
|
+
/**
|
|
42
|
+
* 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
|
|
43
|
+
* 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
|
|
44
|
+
* 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.
|
|
45
|
+
*/
|
|
46
|
+
class IdpLogoutUnconfirmedError extends Error {
|
|
47
|
+
grantLive;
|
|
48
|
+
code = 'IDP_LOGOUT_UNCONFIRMED';
|
|
49
|
+
constructor(status, grantLive) {
|
|
50
|
+
super(`IdP logout answered ${status}`);
|
|
51
|
+
this.grantLive = grantLive;
|
|
52
|
+
this.name = 'IdpLogoutUnconfirmedError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.IdpLogoutUnconfirmedError = IdpLogoutUnconfirmedError;
|
|
7
56
|
async function revokeSessionAtIdp(req) {
|
|
8
57
|
let resolvedSession;
|
|
9
58
|
const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/logout`, {}, {
|
|
59
|
+
// 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
|
|
60
|
+
// in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
|
|
61
|
+
refreshOnAuthFailure: forceRefreshAtIdp,
|
|
10
62
|
initFor: async (token) => {
|
|
11
63
|
resolvedSession = token;
|
|
12
64
|
let session = await (0, session_store_1.getSession)(token);
|
|
@@ -28,7 +80,27 @@ async function revokeSessionAtIdp(req) {
|
|
|
28
80
|
return 'revoked';
|
|
29
81
|
if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession)))
|
|
30
82
|
return 'grant-already-dead';
|
|
31
|
-
|
|
83
|
+
const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
|
|
84
|
+
throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
|
|
88
|
+
* implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
|
|
89
|
+
* (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
|
|
90
|
+
* 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).
|
|
91
|
+
* Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
|
|
92
|
+
*/
|
|
93
|
+
async function forceRefreshAtIdp(sessionToken) {
|
|
94
|
+
const idpBaseUrl = process.env.IDP_URL;
|
|
95
|
+
const clientId = process.env.CLIENT_ID;
|
|
96
|
+
if (!idpBaseUrl || !clientId)
|
|
97
|
+
throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
|
|
98
|
+
const { ensureFreshAccessToken } = await Promise.resolve().then(() => __importStar(require('./ensure-fresh-access-token')));
|
|
99
|
+
const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
|
|
100
|
+
if (r.ok)
|
|
101
|
+
return { ok: true, status: 200 };
|
|
102
|
+
const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
|
|
103
|
+
return { ok: false, status };
|
|
32
104
|
}
|
|
33
105
|
/**
|
|
34
106
|
* A refresh 401 is not by itself "the grant is gone" (NightHawk 76295, BAPert 76297): refresh.ts also answers 401 for 'no session', for
|