@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,50 +1,67 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* REFRESH TOKEN API HANDLER - a thin HTTP wrapper over `ensureFreshAccessToken` (R-over-E, BAPert 76449; design: docs/refresh-r-over-e-delta.md)
|
|
4
4
|
*
|
|
5
|
-
* ASK BEFORE EDITING -
|
|
5
|
+
* ASK BEFORE EDITING - this is the route every browser, middleware and `internalRefresh` caller reaches.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* `lib/ensure-fresh-access-token` is the ONE implementation that talks to the IdP, holds the refresh lock and writes the tokens (post-lock
|
|
8
|
+
* token, in-doubt marker, fetch timeout, envelope rule, dropped-persist and canonical-row handling all live there). This file keeps what only
|
|
9
|
+
* an HTTP request has:
|
|
10
|
+
* - resolving the session from the request's own Better Auth cookie,
|
|
11
|
+
* - the `x-session-token` ownership rejection (below),
|
|
12
|
+
* - the `x-request-id` trace id (log correlation only; it is never the lock identity),
|
|
13
|
+
* - mapping E's typed result onto this route's existing JSON bodies and status codes.
|
|
14
|
+
* The bodies did not change except where E's rules did (see CHANGELOG): the browser, `middleware/create-middleware`, `lib/token-lifecycle`
|
|
15
|
+
* (which reads `reason === 'already_fresh'`) and `lib/idp-fetch` / `lib/api-handler` (via `internalRefresh`) depend on them.
|
|
13
16
|
*
|
|
14
17
|
* ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* there is no session to establish ownership of the token it names. Accepting
|
|
19
|
-
* it let a caller refresh a session they did not own.
|
|
18
|
+
* A request presenting `x-session-token` with no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because there is no
|
|
19
|
+
* session to establish ownership of the token it names. Accepting it let a caller refresh a session they did not own. This docstring
|
|
20
|
+
* ships (it is compiled into dist and the .d.ts), so it describes what the code does.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
* rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
|
|
23
|
-
* into dist and into the .d.ts, so it reaches every consumer's editor as hover
|
|
24
|
-
* text. QAPert-NightHawk caught that the fixed build was still describing the
|
|
25
|
-
* removed capability as a current feature (34973) — her verifier tests code
|
|
26
|
-
* properties and reads no comments, so a correct build could have published
|
|
27
|
-
* documentation asserting the opposite of what the code does.
|
|
28
|
-
*
|
|
29
|
-
* That matters twice over: an internal caller relying on the fallback gets a
|
|
30
|
-
* silent behavioural break while the docs promise it still works, and a
|
|
31
|
-
* published package that misdescribes its own auth behaviour is the same
|
|
32
|
-
* amplifier that put other defects onto three surfaces — shipped code carrying
|
|
33
|
-
* instructions for using it wrongly.
|
|
34
|
-
*
|
|
35
|
-
* @version 2.1
|
|
22
|
+
* @version 3.0
|
|
36
23
|
*/
|
|
37
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
25
|
exports.POST = void 0;
|
|
39
26
|
exports.createRefreshHandler = createRefreshHandler;
|
|
40
27
|
const server_1 = require("next/server");
|
|
41
|
-
const crypto_1 = require("crypto");
|
|
42
28
|
const auth_1 = require("../../server/auth");
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
29
|
+
const ensure_fresh_access_token_1 = require("../../lib/ensure-fresh-access-token");
|
|
30
|
+
const refresh_in_doubt_1 = require("../../lib/refresh-in-doubt");
|
|
31
|
+
const safe_trace_id_1 = require("../../lib/safe-trace-id");
|
|
32
|
+
const log_safe_1 = require("../../lib/log-safe");
|
|
33
|
+
/** E's failure -> this route's body and status. Bodies for the cases the route always had are exactly as they were. */
|
|
34
|
+
function failureResponse(r) {
|
|
35
|
+
switch (r.code) {
|
|
36
|
+
case 'NO_SESSION':
|
|
37
|
+
return server_1.NextResponse.json({ error: 'No session available for refresh', code: 'UNAUTHORIZED' }, { status: 401 });
|
|
38
|
+
case 'NO_REFRESH_TOKEN':
|
|
39
|
+
return server_1.NextResponse.json({
|
|
40
|
+
error: 'No refresh token available',
|
|
41
|
+
code: 'NO_REFRESH_TOKEN', // Terminal state - session cannot be refreshed
|
|
42
|
+
terminal: true, // Signal to frontend: don't retry, redirect to login
|
|
43
|
+
resolution: r.resolution ?? 'User must re-authenticate',
|
|
44
|
+
}, { status: 401 });
|
|
45
|
+
case 'CONFLICT':
|
|
46
|
+
return server_1.NextResponse.json({ error: 'Refresh already in progress', code: 'CONFLICT', retryable: true }, { status: 409 });
|
|
47
|
+
default: {
|
|
48
|
+
const body = {
|
|
49
|
+
error: r.message,
|
|
50
|
+
code: r.code,
|
|
51
|
+
retryable: r.retryable ?? false,
|
|
52
|
+
discardToken: r.discardToken ?? false,
|
|
53
|
+
};
|
|
54
|
+
if (r.terminal !== undefined)
|
|
55
|
+
body.terminal = r.terminal;
|
|
56
|
+
if (r.resolution !== undefined)
|
|
57
|
+
body.resolution = r.resolution;
|
|
58
|
+
// A timed-out refresh is outcome-UNKNOWN (BAPert 76403): say so, so a client does not treat it as a plain upstream failure.
|
|
59
|
+
if (r.code === refresh_in_doubt_1.UPSTREAM_TIMEOUT_CODE)
|
|
60
|
+
body.outcomeUnknown = true;
|
|
61
|
+
return server_1.NextResponse.json(body, { status: r.status });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
48
65
|
/**
|
|
49
66
|
* Creates a refresh token handler for Next.js API routes
|
|
50
67
|
*
|
|
@@ -67,46 +84,18 @@ function createRefreshHandler(config) {
|
|
|
67
84
|
const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
|
|
68
85
|
return async function POST(req) {
|
|
69
86
|
try {
|
|
70
|
-
// Extract session from Better Auth
|
|
87
|
+
// Extract session from Better Auth (the ONLY way a session is established here)
|
|
71
88
|
const betterAuthSession = await (0, auth_1.getSession)(req);
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
const sessionToken = (betterAuthSession?.session?.token);
|
|
90
|
+
const userId = betterAuthSession?.user?.id;
|
|
74
91
|
if (!sessionToken) {
|
|
75
|
-
// OWNERSHIP CHECK on the x-session-token fallback (246007
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// to be refreshed -- an identity assumption, and the precondition for the
|
|
83
|
-
// forced-logout chain via IDP reuse detection.
|
|
84
|
-
//
|
|
85
|
-
// WHY THE CHECK IS A HARD DENY RATHER THAN A COMPARISON, which is the part
|
|
86
|
-
// worth understanding before anyone "improves" it: this branch is reached
|
|
87
|
-
// ONLY when getBetterAuthSession() failed to resolve a session from the
|
|
88
|
-
// request's own cookie. The natural ownership check -- "the header may only
|
|
89
|
-
// name the session the caller's cookie already resolves to" -- therefore has
|
|
90
|
-
// nothing to compare against by construction. Any correct ownership test at
|
|
91
|
-
// this point evaluates to reject. So the check and outright removal coincide
|
|
92
|
-
// here; a more permissive version is not possible without moving the check
|
|
93
|
-
// somewhere it can actually see an authenticated identity.
|
|
94
|
-
//
|
|
95
|
-
// WHO THIS AFFECTS, and it is a deliberate accepted cost, not an oversight:
|
|
96
|
-
// internal callers (lib/internal-api internalRefresh, from api-handler and
|
|
97
|
-
// idp-fetch) forward the end user's cookie. Where that cookie resolves they
|
|
98
|
-
// never enter this branch and are unaffected. Where it does not -- sessions
|
|
99
|
-
// minted before the graft dual-write (8a5ac21) whose cookie carries only the
|
|
100
|
-
// __Secure- name, which the pre-240654 resolver could not read -- the refresh
|
|
101
|
-
// now fails and the user re-authenticates ONCE. Their next login writes both
|
|
102
|
-
// cookie names and they are permanently fine. That population cannot grow, does
|
|
103
|
-
// not age out (session TTL slides on every write), and does not self-heal on
|
|
104
|
-
// use (this handler sets no cookie) -- so the only ways out were to accept the
|
|
105
|
-
// re-login or leave the defect live. BAPert chose to accept it.
|
|
106
|
-
//
|
|
107
|
-
// If server-to-server refresh must work WITHOUT an end-user cookie, that needs
|
|
108
|
-
// real service authentication -- a shared secret, mTLS, or a network policy --
|
|
109
|
-
// never a header any client can set.
|
|
92
|
+
// OWNERSHIP CHECK on the x-session-token fallback (246007; BAPert ruled 34708: ship it, accept the one-time re-login cost).
|
|
93
|
+
// This branch is reached ONLY when getBetterAuthSession() failed to resolve a session from the request's own cookie, so the natural
|
|
94
|
+
// check ("the header may only name the session the caller's cookie already resolves to") has nothing to compare against by
|
|
95
|
+
// construction: any correct ownership test here evaluates to reject. So the check and outright removal coincide. Internal callers
|
|
96
|
+
// (lib/internal-api internalRefresh) forward the end user's cookie and never enter this branch where that cookie resolves. If
|
|
97
|
+
// server-to-server refresh must work WITHOUT an end-user cookie it needs real service authentication (a shared secret, mTLS, a network
|
|
98
|
+
// policy), never a header any client can set.
|
|
110
99
|
const headerSessionToken = req.headers.get('x-session-token');
|
|
111
100
|
if (headerSessionToken) {
|
|
112
101
|
console.warn('[AUTH_REFRESH] REJECTED x-session-token: no cookie-resolved session to own it. ' +
|
|
@@ -125,711 +114,22 @@ function createRefreshHandler(config) {
|
|
|
125
114
|
code: 'UNAUTHORIZED'
|
|
126
115
|
}, { status: 401 });
|
|
127
116
|
}
|
|
128
|
-
//
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (!
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
error: 'No refresh token available',
|
|
135
|
-
code: 'NO_REFRESH_TOKEN', // Terminal state - session cannot be refreshed
|
|
136
|
-
terminal: true, // Signal to frontend: don't retry, redirect to login
|
|
137
|
-
resolution: 'User must re-authenticate'
|
|
138
|
-
}, { status: 401 });
|
|
139
|
-
}
|
|
140
|
-
// ============================================================================
|
|
141
|
-
// HIGH VISIBILITY: PRE-FLIGHT REFRESH DIAGNOSTICS
|
|
142
|
-
// ============================================================================
|
|
143
|
-
const now = Date.now();
|
|
144
|
-
const refreshTokenAge = currentSession.idpRefreshTokenIssuedAt
|
|
145
|
-
? Math.round((now - currentSession.idpRefreshTokenIssuedAt) / 1000)
|
|
146
|
-
: 'unknown';
|
|
147
|
-
const refreshTokenExpiresIn = currentSession.idpRefreshTokenExpires
|
|
148
|
-
? Math.round((currentSession.idpRefreshTokenExpires - now) / 1000)
|
|
149
|
-
: 'unknown';
|
|
150
|
-
const accessTokenExpiresIn = currentSession.idpAccessTokenExpires
|
|
151
|
-
? Math.round((currentSession.idpAccessTokenExpires - now) / 1000)
|
|
152
|
-
: 'unknown';
|
|
153
|
-
console.log('╔══════════════════════════════════════════════════════════════════════════════╗');
|
|
154
|
-
console.log('║ REFRESH TOKEN ATTEMPT - PRE-FLIGHT ║');
|
|
155
|
-
console.log('╠══════════════════════════════════════════════════════════════════════════════╣');
|
|
156
|
-
console.log(`║ User: ${(userId || 'unknown').substring(0, 50).padEnd(50)} ║`);
|
|
157
|
-
console.log(`║ Session: ${(0, token_log_id_1.tokenLogId)(sessionToken)} ║`);
|
|
158
|
-
console.log('╠──────────────────────────────────────────────────────────────────────────────╣');
|
|
159
|
-
console.log(`║ Access Token Expires In: ${String(accessTokenExpiresIn).padEnd(10)} seconds ║`);
|
|
160
|
-
console.log(`║ Refresh Token Age: ${String(refreshTokenAge).padEnd(10)} seconds ║`);
|
|
161
|
-
console.log(`║ Refresh Token Expires In: ${String(refreshTokenExpiresIn).padEnd(10)} seconds ║`);
|
|
162
|
-
console.log(`║ Refresh Token Length: ${String(currentSession.idpRefreshToken?.length || 0).padEnd(10)} chars ║`);
|
|
163
|
-
console.log(`║ 2FA Complete: ${String(!!currentSession.mfaVerified).padEnd(10)} ║`);
|
|
164
|
-
console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
|
|
165
|
-
console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
|
|
166
|
-
// LOCK IDENTITY IS MINTED SERVER-SIDE. It must never come from a header.
|
|
167
|
-
//
|
|
168
|
-
// This was `req.headers.get('x-request-id') ?? \`refresh_${Date.now()}\``, and the
|
|
169
|
-
// value was used as the lock's `acquiredBy`. Below it, a branch let a caller
|
|
170
|
-
// PROCEED WITHOUT THE LOCK when the existing lock's acquiredBy equalled the id
|
|
171
|
-
// they had supplied. Since the caller chose that id, the branch was a lock
|
|
172
|
-
// bypass requiring no guesswork: send N concurrent requests all carrying the
|
|
173
|
-
// same x-request-id, and every one after the first skips the wait.
|
|
174
|
-
//
|
|
175
|
-
// That completed an attacker-controlled forced logout, because the serialisation
|
|
176
|
-
// is what prevents refresh-token REUSE, and the IDP treats reuse as compromise:
|
|
177
|
-
// IdentityRefreshTokenService:166 -> RevokeDescendantTokens ->
|
|
178
|
-
// IdentityRefreshTokenRepository:142 revokes every ACTIVE token for that USER
|
|
179
|
-
// issued after the reused one -- across all devices and all client apps. So a
|
|
180
|
-
// caller who knew one session token could log a user out everywhere by sending
|
|
181
|
-
// a few requests with a header value of their own choosing.
|
|
182
|
-
//
|
|
183
|
-
// Minting server-side removes the bypass by construction: no caller can produce
|
|
184
|
-
// a value that matches an in-flight lock, so the equality branch can never be
|
|
185
|
-
// true and is deleted rather than left inert -- an unreachable branch that
|
|
186
|
-
// trusts a header is a trap for whoever edits this next.
|
|
187
|
-
//
|
|
188
|
-
// The anti-deadlock intent that branch served is preserved by the wait path
|
|
189
|
-
// below, which is the correct handling anyway: a genuine duplicate waits for the
|
|
190
|
-
// in-flight refresh and returns its result instead of starting a second one.
|
|
191
|
-
// The header is still read, for TRACING ONLY, and never influences control flow.
|
|
192
|
-
const traceId = req.headers.get('x-request-id') ?? undefined;
|
|
193
|
-
const requestId = `refresh_${(0, crypto_1.randomUUID)()}`;
|
|
194
|
-
const lockAcquired = await (0, session_store_1.acquireRefreshLock)(sessionToken, requestId, 5000);
|
|
195
|
-
let weAcquiredLock = false;
|
|
196
|
-
let releaseLockVersion;
|
|
197
|
-
if (!lockAcquired.acquired) {
|
|
198
|
-
{
|
|
199
|
-
// Wait for the lock to release, then check if token is now fresh
|
|
200
|
-
console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId, traceId });
|
|
201
|
-
const maxWaitMs = 5000;
|
|
202
|
-
const checkIntervalMs = 200;
|
|
203
|
-
const startWait = Date.now();
|
|
204
|
-
while (Date.now() - startWait < maxWaitMs) {
|
|
205
|
-
await new Promise(resolve => setTimeout(resolve, checkIntervalMs));
|
|
206
|
-
// Check if lock was released
|
|
207
|
-
const lockCheck = await (0, session_store_1.checkRefreshLock)(sessionToken);
|
|
208
|
-
if (!lockCheck) {
|
|
209
|
-
// Lock released - check if token is now fresh
|
|
210
|
-
const refreshedSession = await (0, session_store_1.getSession)(sessionToken);
|
|
211
|
-
if (refreshedSession?.idpAccessToken && refreshedSession?.idpAccessTokenExpires) {
|
|
212
|
-
const timeUntilExpiry = refreshedSession.idpAccessTokenExpires - Date.now();
|
|
213
|
-
const fiveMinutes = 5 * 60 * 1000;
|
|
214
|
-
if (timeUntilExpiry > fiveMinutes) {
|
|
215
|
-
console.info('[AUTH_REFRESH] Lock released, token is fresh - returning success', {
|
|
216
|
-
requestId,
|
|
217
|
-
accessTokenExpires: new Date(refreshedSession.idpAccessTokenExpires).toISOString(),
|
|
218
|
-
waitedMs: Date.now() - startWait,
|
|
219
|
-
});
|
|
220
|
-
return server_1.NextResponse.json({
|
|
221
|
-
refreshed: true,
|
|
222
|
-
reason: 'completed_by_concurrent_request',
|
|
223
|
-
accessTokenExpires: refreshedSession.idpAccessTokenExpires,
|
|
224
|
-
hasRefreshToken: !!refreshedSession.idpRefreshToken,
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// Lock released but token not fresh - try to acquire lock ourselves
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
// Still locked after waiting - return 409
|
|
233
|
-
console.warn('[AUTH_REFRESH] Refresh still in progress after waiting', { requestId, waitedMs: Date.now() - startWait });
|
|
234
|
-
return server_1.NextResponse.json({
|
|
235
|
-
error: 'Refresh already in progress',
|
|
236
|
-
code: 'CONFLICT'
|
|
237
|
-
}, { status: 409 });
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
weAcquiredLock = true;
|
|
242
|
-
releaseLockVersion = lockAcquired.lockInfo?.lockVersion;
|
|
243
|
-
}
|
|
244
|
-
// Before performing network refresh, re-check if tokens are already fresh
|
|
245
|
-
const latestAfterLock = await (0, session_store_1.getSession)(sessionToken);
|
|
246
|
-
const fiveMinutes = 5 * 60 * 1000;
|
|
247
|
-
const timeUntilExpiry = latestAfterLock?.idpAccessTokenExpires
|
|
248
|
-
? latestAfterLock.idpAccessTokenExpires - Date.now()
|
|
249
|
-
: -1;
|
|
250
|
-
// CRITICAL: Also check the actual JWT's exp claim - Redis might have stale data
|
|
251
|
-
let actualJwtExpMs = -1;
|
|
252
|
-
let tokenMismatch = false;
|
|
253
|
-
if (latestAfterLock?.idpAccessToken) {
|
|
254
|
-
try {
|
|
255
|
-
const tokenParts = latestAfterLock.idpAccessToken.split('.');
|
|
256
|
-
if (tokenParts.length === 3) {
|
|
257
|
-
const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
|
|
258
|
-
actualJwtExpMs = (payload.exp || 0) * 1000;
|
|
259
|
-
const now = Date.now();
|
|
260
|
-
// If the actual JWT is expired, we MUST refresh regardless of what Redis says
|
|
261
|
-
if (actualJwtExpMs < now) {
|
|
262
|
-
tokenMismatch = true;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
catch (e) {
|
|
267
|
-
// If we can't decode, proceed with normal logic
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
console.log('[AUTH_REFRESH] Pre-refresh check:', {
|
|
271
|
-
userId,
|
|
272
|
-
hasAccessToken: !!latestAfterLock?.idpAccessToken,
|
|
273
|
-
accessTokenExpires: latestAfterLock?.idpAccessTokenExpires
|
|
274
|
-
? new Date(latestAfterLock.idpAccessTokenExpires).toISOString()
|
|
275
|
-
: 'undefined',
|
|
276
|
-
actualJwtExp: actualJwtExpMs > 0 ? new Date(actualJwtExpMs).toISOString() : 'unknown',
|
|
277
|
-
now: new Date().toISOString(),
|
|
278
|
-
timeUntilExpiryMs: timeUntilExpiry,
|
|
279
|
-
tokenMismatch,
|
|
280
|
-
willRefresh: timeUntilExpiry <= fiveMinutes || tokenMismatch
|
|
281
|
-
});
|
|
282
|
-
// Only skip refresh if BOTH Redis says fresh AND the actual JWT is not expired
|
|
283
|
-
if (latestAfterLock?.idpAccessToken && latestAfterLock?.idpAccessTokenExpires &&
|
|
284
|
-
timeUntilExpiry > fiveMinutes && !tokenMismatch) {
|
|
285
|
-
console.info('[AUTH_REFRESH] Skipping refresh: tokens already fresh', { userId, timeUntilExpiryMs: timeUntilExpiry });
|
|
286
|
-
if (weAcquiredLock) {
|
|
287
|
-
await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId, releaseLockVersion);
|
|
288
|
-
weAcquiredLock = false;
|
|
289
|
-
}
|
|
290
|
-
return server_1.NextResponse.json({
|
|
291
|
-
refreshed: false,
|
|
292
|
-
reason: 'already_fresh',
|
|
293
|
-
accessTokenExpires: latestAfterLock.idpAccessTokenExpires,
|
|
294
|
-
hasRefreshToken: !!latestAfterLock.idpRefreshToken,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
// If we detected a token mismatch, log it prominently
|
|
298
|
-
if (tokenMismatch) {
|
|
299
|
-
console.warn('[AUTH_REFRESH] Token mismatch detected - forcing refresh despite Redis claiming fresh', {
|
|
300
|
-
userId,
|
|
301
|
-
redisExpires: latestAfterLock?.idpAccessTokenExpires ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString() : 'N/A',
|
|
302
|
-
actualJwtExp: new Date(actualJwtExpMs).toISOString()
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
// Copy refresh token for use in IDP call
|
|
306
|
-
// Note: Token is NOT cleared preemptively - it will be replaced on success.
|
|
307
|
-
// The lock mechanism prevents concurrent refresh attempts within the same session.
|
|
308
|
-
// If IDP call fails, user can retry with the same token.
|
|
309
|
-
// 4.9.0 (BAPert 76298 (b2), 76330): the token we SEND is the one read AFTER the lock was taken (`latestAfterLock`), and it is the ONLY
|
|
310
|
-
// source: there is NO fallback to `currentSession`, which was read BEFORE the lock. Between those reads another request can rotate the
|
|
311
|
-
// refresh token (sending the pre-lock value would send a SPENT token) or DISCARD it (the IdP answered 401 for it; re-sending it is a
|
|
312
|
-
// reuse). The IdP treats reuse as compromise and revokes descendants across devices. So:
|
|
313
|
-
// - the post-lock read has NO ROW: the session vanished while we waited; it is not ours to refresh -> the 'No session' 401, no IdP call
|
|
314
|
-
// - the row has NO refresh token: -> the same terminal NO_REFRESH_TOKEN as the pre-lock guard, no IdP call; the row is left exactly as
|
|
315
|
-
// it is (a discard marker stays on it)
|
|
316
|
-
// (A row whose access token is still fresh was answered `already_fresh` above and never reaches here.)
|
|
317
|
-
if (!latestAfterLock) {
|
|
318
|
-
console.warn('[AUTH_REFRESH] Session vanished while waiting for the refresh lock; not refreshing', { userId });
|
|
319
|
-
if (weAcquiredLock) {
|
|
320
|
-
await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId, releaseLockVersion);
|
|
321
|
-
weAcquiredLock = false;
|
|
322
|
-
}
|
|
323
|
-
return server_1.NextResponse.json({
|
|
324
|
-
error: 'No session available for refresh',
|
|
325
|
-
code: 'UNAUTHORIZED'
|
|
326
|
-
}, { status: 401 });
|
|
327
|
-
}
|
|
328
|
-
if (!latestAfterLock.idpRefreshToken) {
|
|
329
|
-
console.warn('[AUTH_REFRESH] No refresh token after the lock was taken; not calling the IdP', { userId });
|
|
330
|
-
if (weAcquiredLock) {
|
|
331
|
-
await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId, releaseLockVersion);
|
|
332
|
-
weAcquiredLock = false;
|
|
333
|
-
}
|
|
334
|
-
return server_1.NextResponse.json({
|
|
335
|
-
error: 'No refresh token available',
|
|
336
|
-
code: 'NO_REFRESH_TOKEN',
|
|
337
|
-
terminal: true,
|
|
338
|
-
resolution: 'User must re-authenticate'
|
|
339
|
-
}, { status: 401 });
|
|
340
|
-
}
|
|
341
|
-
const originalRefreshToken = latestAfterLock.idpRefreshToken;
|
|
342
|
-
// The base of everything WRITTEN BACK after a successful exchange is the same post-lock read (BAPert 76318 item 4): writing
|
|
343
|
-
// `...currentSession` over a rotated row would put a SPENT token back. With no new refresh token from the IdP the stored token is the
|
|
344
|
-
// one that was SENT (`originalRefreshToken`).
|
|
345
|
-
const baseSession = latestAfterLock;
|
|
346
|
-
try {
|
|
347
|
-
// Extract 2FA claims from current session
|
|
348
|
-
let authMethods = [];
|
|
349
|
-
if (currentSession.authenticationMethods) {
|
|
350
|
-
if (typeof currentSession.authenticationMethods === 'string') {
|
|
351
|
-
try {
|
|
352
|
-
authMethods = JSON.parse(currentSession.authenticationMethods);
|
|
353
|
-
}
|
|
354
|
-
catch (e) {
|
|
355
|
-
console.warn('[AUTH_REFRESH] Failed to parse authenticationMethods', { authenticationMethods: currentSession.authenticationMethods });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
else if (Array.isArray(currentSession.authenticationMethods)) {
|
|
359
|
-
authMethods = currentSession.authenticationMethods;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
// For OAuth sessions with empty AMR claims, provide defaults
|
|
363
|
-
// OAuth IS a form of multi-factor auth (you authenticated with another provider)
|
|
364
|
-
let isOAuthSession = !!currentSession.oauthProvider;
|
|
365
|
-
if (authMethods.length === 0 && isOAuthSession) {
|
|
366
|
-
// OAuth sessions get default AMR claims
|
|
367
|
-
authMethods = ['pwd', 'mfa'];
|
|
368
|
-
console.log('[AUTH_REFRESH] OAuth session with empty AMR - using defaults:', {
|
|
369
|
-
oauthProvider: currentSession.oauthProvider,
|
|
370
|
-
defaultAmr: authMethods
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
// Check authMethods first, then fallback to twoFactorMethod field (set by transitionTo2FASession)
|
|
374
|
-
// For OAuth sessions, use 'oauth' as the 2FA method since OAuth itself is the authentication
|
|
375
|
-
const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m))
|
|
376
|
-
|| currentSession.mfaMethod
|
|
377
|
-
|| (isOAuthSession ? 'oauth' : null);
|
|
378
|
-
// DEBUG: Log what we have for 2FA
|
|
379
|
-
console.log('[AUTH_REFRESH] 2FA Debug:', {
|
|
380
|
-
authMethods,
|
|
381
|
-
authMethodsRaw: currentSession.authenticationMethods,
|
|
382
|
-
twoFactorMethodFromSession: currentSession.mfaMethod,
|
|
383
|
-
twoFactorMethodResolved: twoFactorMethod,
|
|
384
|
-
twoFactorComplete: currentSession.mfaVerified,
|
|
385
|
-
sessionKeys: Object.keys(currentSession)
|
|
386
|
-
});
|
|
387
|
-
// Build refresh request body per PayEz wire standard (snake_case)
|
|
388
|
-
// See: 2FA-TOKEN-REFRESH-CONTEXT.md - "The Ninja Shit"
|
|
389
|
-
// For OAuth sessions with 2FA complete, use ACR level 2
|
|
390
|
-
let acrValue = String(currentSession.authenticationLevel ?? '1');
|
|
391
|
-
if (currentSession.oauthProvider && currentSession.mfaVerified && acrValue === '1') {
|
|
392
|
-
acrValue = '2'; // OAuth with 2FA complete = full authentication
|
|
393
|
-
}
|
|
394
|
-
const refreshRequestBody = {
|
|
395
|
-
refresh_token: originalRefreshToken,
|
|
396
|
-
amr: authMethods,
|
|
397
|
-
acr: acrValue
|
|
398
|
-
};
|
|
399
|
-
// DEBUG: Log exact request body
|
|
400
|
-
console.log('[AUTH_REFRESH] Request body:', JSON.stringify({
|
|
401
|
-
refresh_token: '***REDACTED***',
|
|
402
|
-
amr: authMethods,
|
|
403
|
-
acr: acrValue,
|
|
404
|
-
acrType: typeof acrValue
|
|
405
|
-
}));
|
|
406
|
-
const twoFactorVerified = !!currentSession.mfaVerified;
|
|
407
|
-
if (twoFactorVerified) {
|
|
408
|
-
refreshRequestBody.two_factor_verified = true;
|
|
409
|
-
}
|
|
410
|
-
if (twoFactorMethod) {
|
|
411
|
-
refreshRequestBody.two_factor_method = twoFactorMethod;
|
|
412
|
-
}
|
|
413
|
-
if (currentSession.mfaCompletedAt) {
|
|
414
|
-
refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
|
|
415
|
-
}
|
|
416
|
-
// Log the full request details for debugging
|
|
417
|
-
console.log('[AUTH_REFRESH] Sending refresh request:', {
|
|
418
|
-
url: `${idpBaseUrl}${refreshEndpoint}`,
|
|
419
|
-
clientId,
|
|
420
|
-
hasRefreshToken: !!refreshRequestBody.refresh_token,
|
|
421
|
-
refreshTokenLength: refreshRequestBody.refresh_token?.length,
|
|
422
|
-
amr: refreshRequestBody.amr,
|
|
423
|
-
acr: refreshRequestBody.acr
|
|
424
|
-
});
|
|
425
|
-
let refreshResponse;
|
|
426
|
-
try {
|
|
427
|
-
refreshResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
|
|
428
|
-
method: 'POST',
|
|
429
|
-
headers: {
|
|
430
|
-
'Content-Type': 'application/json',
|
|
431
|
-
'X-Client-Id': clientId,
|
|
432
|
-
},
|
|
433
|
-
body: JSON.stringify(refreshRequestBody),
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
catch (fetchError) {
|
|
437
|
-
// Network error - IDP unreachable
|
|
438
|
-
// Note: Lock will be released by finally block
|
|
439
|
-
console.error('[AUTH_REFRESH] IDP unreachable:', {
|
|
440
|
-
error: fetchError instanceof Error ? fetchError.message : String(fetchError),
|
|
441
|
-
idpUrl: idpBaseUrl,
|
|
442
|
-
userId
|
|
443
|
-
});
|
|
444
|
-
return server_1.NextResponse.json({
|
|
445
|
-
error: 'IDP service unavailable',
|
|
446
|
-
code: 'UPSTREAM_SERVICE_UNAVAILABLE',
|
|
447
|
-
retryable: true,
|
|
448
|
-
discardToken: false
|
|
449
|
-
}, { status: 503 });
|
|
450
|
-
}
|
|
451
|
-
// Parse response body - handle empty or invalid JSON
|
|
452
|
-
let responseData;
|
|
453
|
-
try {
|
|
454
|
-
const responseText = await refreshResponse.text();
|
|
455
|
-
if (!responseText || responseText.trim() === '') {
|
|
456
|
-
// Note: Lock will be released by finally block
|
|
457
|
-
console.error('[AUTH_REFRESH] Empty response from IDP:', {
|
|
458
|
-
status: refreshResponse.status,
|
|
459
|
-
statusText: refreshResponse.statusText,
|
|
460
|
-
userId
|
|
461
|
-
});
|
|
462
|
-
return server_1.NextResponse.json({
|
|
463
|
-
error: 'Empty response from IDP',
|
|
464
|
-
code: 'UPSTREAM_SERVICE_ERROR',
|
|
465
|
-
retryable: true,
|
|
466
|
-
discardToken: false
|
|
467
|
-
}, { status: 502 });
|
|
468
|
-
}
|
|
469
|
-
responseData = JSON.parse(responseText);
|
|
470
|
-
}
|
|
471
|
-
catch (parseError) {
|
|
472
|
-
// Note: Lock will be released by finally block
|
|
473
|
-
console.error('[AUTH_REFRESH] Failed to parse IDP response:', {
|
|
474
|
-
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
475
|
-
status: refreshResponse.status,
|
|
476
|
-
userId
|
|
477
|
-
});
|
|
478
|
-
return server_1.NextResponse.json({
|
|
479
|
-
error: 'Invalid response from IDP',
|
|
480
|
-
code: 'UPSTREAM_SERVICE_ERROR',
|
|
481
|
-
retryable: true,
|
|
482
|
-
discardToken: false
|
|
483
|
-
}, { status: 502 });
|
|
484
|
-
}
|
|
485
|
-
// Handle non-OK responses with structured error format
|
|
486
|
-
if (!refreshResponse.ok) {
|
|
487
|
-
// Parse structured error from IDP
|
|
488
|
-
const idpError = responseData?.error || {};
|
|
489
|
-
const errorCode = idpError.code || 'UNKNOWN_ERROR';
|
|
490
|
-
const errorMessage = idpError.message || 'Token refresh failed';
|
|
491
|
-
// CRITICAL: 401 means token is definitively invalid - always discard
|
|
492
|
-
// This prevents redirect loops where viability check keeps returning canRefresh:true
|
|
493
|
-
let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
|
|
494
|
-
const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
|
|
495
|
-
const resolution = idpError.resolution || null;
|
|
496
|
-
// ============================================================================
|
|
497
|
-
// HIGH VISIBILITY: REFRESH TOKEN FAILURE DIAGNOSTICS
|
|
498
|
-
// ============================================================================
|
|
499
|
-
// Translate error codes to human-readable explanations
|
|
500
|
-
const failureReasons = {
|
|
501
|
-
'UNAUTHORIZED': 'Token was already used (rotation) OR token is expired OR token was revoked',
|
|
502
|
-
'INVALID_REFRESH_TOKEN': 'Token format invalid OR token not found in IDP database',
|
|
503
|
-
'TOKEN_EXPIRED': 'Refresh token exceeded its max lifetime',
|
|
504
|
-
'TOKEN_REVOKED': 'Token was explicitly revoked (logout, password change, admin action)',
|
|
505
|
-
'TOKEN_REUSE_DETECTED': 'Same refresh token used twice - possible token theft, all tokens revoked',
|
|
506
|
-
'UPSTREAM_SERVICE_ERROR': 'IDP internal error - token may have been rotated before failure',
|
|
507
|
-
'INTERNAL_ERROR': 'IDP internal error - check IDP logs for details',
|
|
508
|
-
'RATE_LIMITED': 'Too many refresh attempts - try again later',
|
|
509
|
-
};
|
|
510
|
-
const whyItFailed = failureReasons[errorCode] || 'Unknown error - check IDP logs';
|
|
511
|
-
console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
|
|
512
|
-
console.error('║ ❌ REFRESH TOKEN FAILURE - DIAGNOSTIC REPORT ║');
|
|
513
|
-
console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
|
|
514
|
-
console.error(`║ HTTP Status: ${String(refreshResponse.status).padEnd(60)}║`);
|
|
515
|
-
console.error(`║ Error Code: ${errorCode.padEnd(60)}║`);
|
|
516
|
-
console.error(`║ Discard Token: ${String(discardToken).padEnd(60)}║`);
|
|
517
|
-
console.error(`║ Retryable: ${String(retryable).padEnd(60)}║`);
|
|
518
|
-
console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
|
|
519
|
-
console.error(`║ WHY IT FAILED: ║`);
|
|
520
|
-
console.error(`║ ${whyItFailed.substring(0, 76).padEnd(76)} ║`);
|
|
521
|
-
console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
|
|
522
|
-
console.error(`║ User: ${(userId || 'unknown').substring(0, 60).padEnd(60)}║`);
|
|
523
|
-
console.error(`║ Session: ${(0, token_log_id_1.tokenLogId)(sessionToken)} ║`);
|
|
524
|
-
console.error(`║ Resolution: ${(resolution || 'Check IDP logs').substring(0, 60).padEnd(60)}║`);
|
|
525
|
-
console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
|
|
526
|
-
// NEVER the raw envelope (DotNetPert-Scout, PR 139 review / mail 58691): this used to print
|
|
527
|
-
// a stringified dump of responseData (first 1000 chars) - the same whole IDP body the :618
|
|
528
|
-
// "unsuccessful" branch stopped logging. The box above already carries status, code and
|
|
529
|
-
// flags; add envelope SHAPE only (key names), never values.
|
|
530
|
-
console.error('[AUTH_REFRESH] IDP error response shape', {
|
|
531
|
-
bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
|
|
532
|
-
errorKeys: idpError && typeof idpError === 'object' ? Object.keys(idpError) : undefined,
|
|
533
|
-
errorHasMessage: !!idpError.message,
|
|
534
|
-
});
|
|
535
|
-
// If IDP signals to discard token, clear it from Redis
|
|
536
|
-
if (discardToken) {
|
|
537
|
-
// Race-safe discard (CAS): only clear if the refresh token we sent is STILL
|
|
538
|
-
// the current one. A concurrent refresh may have already rotated it — our 401
|
|
539
|
-
// is then stale, and wiping here would silently log out a healthy session
|
|
540
|
-
// (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
|
|
541
|
-
const postRefresh = await (0, session_store_1.getSession)(sessionToken);
|
|
542
|
-
const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
|
|
543
|
-
if (tokenUnchanged) {
|
|
544
|
-
console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
|
|
545
|
-
reason: 'IDP_DISCARD_TOKEN',
|
|
546
|
-
errorCode,
|
|
547
|
-
userId,
|
|
548
|
-
sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
|
|
549
|
-
hadRefreshToken: !!currentSession.idpRefreshToken,
|
|
550
|
-
stack: new Error().stack
|
|
551
|
-
});
|
|
552
|
-
await (0, session_store_1.updateSession)(sessionToken, {
|
|
553
|
-
idpRefreshToken: '',
|
|
554
|
-
idpRefreshTokenExpires: undefined,
|
|
555
|
-
refreshTokenClearedAt: Date.now(),
|
|
556
|
-
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
else {
|
|
560
|
-
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
561
|
-
// freshly-rotated token directly so the loser succeeds without a retry
|
|
562
|
-
// (race-fix follow-up #1).
|
|
563
|
-
discardToken = false;
|
|
564
|
-
console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
|
|
565
|
-
if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
|
|
566
|
-
return server_1.NextResponse.json({
|
|
567
|
-
refreshed: true,
|
|
568
|
-
reason: 'completed_by_concurrent_request',
|
|
569
|
-
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
570
|
-
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
// Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept,
|
|
574
|
-
// no marker. Same meaning in every branch (lib/refresh-rotation).
|
|
575
|
-
return server_1.NextResponse.json({
|
|
576
|
-
error: errorMessage,
|
|
577
|
-
code: errorCode,
|
|
578
|
-
discardToken: false,
|
|
579
|
-
retryable: true,
|
|
580
|
-
resolution,
|
|
581
|
-
status: 502
|
|
582
|
-
}, { status: 502 });
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
return server_1.NextResponse.json({
|
|
586
|
-
error: errorMessage,
|
|
587
|
-
code: errorCode,
|
|
588
|
-
discardToken,
|
|
589
|
-
retryable,
|
|
590
|
-
resolution,
|
|
591
|
-
status: refreshResponse.status
|
|
592
|
-
}, { status: refreshResponse.status });
|
|
593
|
-
}
|
|
594
|
-
// Validate PayEz canonical envelope for success responses
|
|
595
|
-
const isCompliant = responseData && typeof responseData === 'object' &&
|
|
596
|
-
Object.prototype.hasOwnProperty.call(responseData, 'success') &&
|
|
597
|
-
(responseData.success === true ? Object.prototype.hasOwnProperty.call(responseData, 'data') : Object.prototype.hasOwnProperty.call(responseData, 'error')) &&
|
|
598
|
-
Object.prototype.hasOwnProperty.call(responseData, 'meta');
|
|
599
|
-
if (!isCompliant) {
|
|
600
|
-
// NEVER the raw body (DotNetPert-Scout, mail 58678): a non-compliant response has an
|
|
601
|
-
// UNKNOWN shape by definition, so there is no basis to assume it is free of tokens or
|
|
602
|
-
// other IDP-side secrets. Log shape metadata only - same "kind, never value" reasoning
|
|
603
|
-
// as the session-store.ts / refresh.ts REFRESH_TOKEN_REMOVAL fixes in PR 138.
|
|
604
|
-
console.error('[AUTH_REFRESH] Upstream non-compliant IDP response', {
|
|
605
|
-
bodyType: typeof responseData,
|
|
606
|
-
bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
|
|
607
|
-
hasSuccessKey: !!(responseData && typeof responseData === 'object' && Object.prototype.hasOwnProperty.call(responseData, 'success')),
|
|
608
|
-
hasMetaKey: !!(responseData && typeof responseData === 'object' && Object.prototype.hasOwnProperty.call(responseData, 'meta')),
|
|
609
|
-
});
|
|
610
|
-
return server_1.NextResponse.json({
|
|
611
|
-
error: 'Upstream non-compliance',
|
|
612
|
-
code: 'UPSTREAM_SERVICE_ERROR',
|
|
613
|
-
retryable: true,
|
|
614
|
-
discardToken: false
|
|
615
|
-
}, { status: 502 });
|
|
616
|
-
}
|
|
617
|
-
if (responseData.success !== true || !responseData.data) {
|
|
618
|
-
// Handle success:false with structured error
|
|
619
|
-
const idpError = responseData?.error || {};
|
|
620
|
-
const errorCode = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
|
|
621
|
-
const discardToken = idpError.discard_token === true;
|
|
622
|
-
const retryable = idpError.retryable === true;
|
|
623
|
-
// NEVER the raw envelope (DotNetPert-Scout, mail 58678): `responseData` here is the
|
|
624
|
-
// WHOLE IDP response body, and its `error` sub-object's contents are not something this
|
|
625
|
-
// handler controls or has audited - printing it wholesale would ship whatever the IDP
|
|
626
|
-
// decided to put there straight to the log sink. Log the fields THIS handler already
|
|
627
|
-
// extracts and acts on (retryable, plus envelope-shape flags for diagnosis) instead.
|
|
628
|
-
console.warn('[AUTH_REFRESH] IDP refresh unsuccessful', {
|
|
629
|
-
code: errorCode,
|
|
630
|
-
discardToken,
|
|
631
|
-
retryable,
|
|
632
|
-
errorHasMessage: !!idpError.message,
|
|
633
|
-
errorKeys: Object.keys(idpError),
|
|
634
|
-
});
|
|
635
|
-
// 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations. This branch used to answer the upstream
|
|
636
|
-
// status, i.e. HTTP 200, with an error body, so `internalRefresh` reported `ok` and idpFetchJSON's 401-retry trusted a refresh that
|
|
637
|
-
// had failed; and it wrote a DIFFERENT marker (IDP_SUCCESS_FALSE_DISCARD) than the IdP-401 branch, which the logout dead-grant check
|
|
638
|
-
// does not recognise. Now:
|
|
639
|
-
// success:false + discard_token -> the stored token is discarded (same CAS as the 401 branch) with the IDP_DISCARD_TOKEN marker, and the
|
|
640
|
-
// answer is 401 (never 200 with an error body)
|
|
641
|
-
// success:false, no discard_token -> 502 retryable, the token is KEPT
|
|
642
|
-
if (discardToken) {
|
|
643
|
-
const postRefresh = await (0, session_store_1.getSession)(sessionToken);
|
|
644
|
-
const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
|
|
645
|
-
if (tokenUnchanged) {
|
|
646
|
-
console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
|
|
647
|
-
reason: 'IDP_DISCARD_TOKEN',
|
|
648
|
-
errorCode,
|
|
649
|
-
userId,
|
|
650
|
-
sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
|
|
651
|
-
hadRefreshToken: !!currentSession.idpRefreshToken,
|
|
652
|
-
stack: new Error().stack
|
|
653
|
-
});
|
|
654
|
-
await (0, session_store_1.updateSession)(sessionToken, {
|
|
655
|
-
idpRefreshToken: '',
|
|
656
|
-
idpRefreshTokenExpires: undefined,
|
|
657
|
-
refreshTokenClearedAt: Date.now(),
|
|
658
|
-
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
|
|
659
|
-
});
|
|
660
|
-
return server_1.NextResponse.json({
|
|
661
|
-
error: idpError.message || 'Token refresh failed',
|
|
662
|
-
code: errorCode,
|
|
663
|
-
discardToken: true,
|
|
664
|
-
retryable: false,
|
|
665
|
-
resolution: idpError.resolution || null,
|
|
666
|
-
status: 401
|
|
667
|
-
}, { status: 401 });
|
|
668
|
-
}
|
|
669
|
-
// A concurrent refresh already rotated the token: ours is stale. Return the fresh tokens if there are any (as the 401 branch does).
|
|
670
|
-
if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
|
|
671
|
-
return server_1.NextResponse.json({
|
|
672
|
-
refreshed: true,
|
|
673
|
-
reason: 'completed_by_concurrent_request',
|
|
674
|
-
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
675
|
-
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
return server_1.NextResponse.json({
|
|
680
|
-
error: idpError.message || 'Token refresh failed',
|
|
681
|
-
code: errorCode,
|
|
682
|
-
discardToken: false,
|
|
683
|
-
retryable: true,
|
|
684
|
-
resolution: idpError.resolution || null,
|
|
685
|
-
status: 502
|
|
686
|
-
}, { status: 502 });
|
|
687
|
-
}
|
|
688
|
-
// Extract tokens from response
|
|
689
|
-
const payload = responseData.data;
|
|
690
|
-
const accessToken = payload?.access_token;
|
|
691
|
-
const refreshToken = payload?.refresh_token;
|
|
692
|
-
if (!accessToken) {
|
|
693
|
-
console.error('[AUTH_REFRESH] Missing access token in IDP response');
|
|
694
|
-
return server_1.NextResponse.json({
|
|
695
|
-
error: 'Invalid token response from IDP',
|
|
696
|
-
code: 'INTERNAL_SERVER_ERROR'
|
|
697
|
-
}, { status: 500 });
|
|
698
|
-
}
|
|
699
|
-
// Decode and compute token expiries
|
|
700
|
-
let decodedAccessToken;
|
|
701
|
-
let accessTokenExpires;
|
|
702
|
-
let computedRefreshTokenExpires;
|
|
703
|
-
try {
|
|
704
|
-
const result = (0, token_expiry_1.computeTokenExpiries)({ accessToken, refreshToken, preferJwt: true });
|
|
705
|
-
decodedAccessToken = result.decodedAccessToken;
|
|
706
|
-
accessTokenExpires = result.accessTokenExpires;
|
|
707
|
-
computedRefreshTokenExpires = result.refreshTokenExpires;
|
|
708
|
-
console.info('[AUTH_REFRESH] Successfully decoded new token pair', {
|
|
709
|
-
accessTokenExpires: new Date(accessTokenExpires).toISOString(),
|
|
710
|
-
refreshTokenExpires: computedRefreshTokenExpires ? new Date(computedRefreshTokenExpires).toISOString() : null
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
catch (decodeError) {
|
|
714
|
-
console.error('[AUTH_REFRESH] Failed to compute token expiries', { error: decodeError });
|
|
715
|
-
return server_1.NextResponse.json({
|
|
716
|
-
error: 'Failed to decode JWT tokens',
|
|
717
|
-
code: 'INTERNAL_SERVER_ERROR'
|
|
718
|
-
}, { status: 500 });
|
|
719
|
-
}
|
|
720
|
-
// Extract 2FA claims from the new access token
|
|
721
|
-
let amrClaims = [];
|
|
722
|
-
if (decodedAccessToken.amr) {
|
|
723
|
-
try {
|
|
724
|
-
amrClaims = typeof decodedAccessToken.amr === 'string'
|
|
725
|
-
? JSON.parse(decodedAccessToken.amr)
|
|
726
|
-
: decodedAccessToken.amr;
|
|
727
|
-
}
|
|
728
|
-
catch (e) {
|
|
729
|
-
console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
|
|
730
|
-
amrClaims = baseSession.authenticationMethods || [];
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
else {
|
|
734
|
-
amrClaims = baseSession.authenticationMethods || [];
|
|
735
|
-
}
|
|
736
|
-
const acrLevel = String(decodedAccessToken.acr || baseSession.authenticationLevel || '1');
|
|
737
|
-
// Extract MFA timing claims
|
|
738
|
-
const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : baseSession.mfaCompletedAt;
|
|
739
|
-
const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : baseSession.mfaExpiresAt;
|
|
740
|
-
const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : baseSession.mfaValidityHours;
|
|
741
|
-
// Update session with new tokens
|
|
742
|
-
const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
|
|
743
|
-
const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
|
|
744
|
-
// CRITICAL: Log if we're about to clear the refresh token due to missing new token
|
|
745
|
-
if (!hasNewRefresh && baseSession.idpRefreshToken) {
|
|
746
|
-
console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
|
|
747
|
-
reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
|
|
748
|
-
userId,
|
|
749
|
-
sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
|
|
750
|
-
hadRefreshToken: true,
|
|
751
|
-
// Kind only, never the value (credential). This branch runs when refreshToken is not a
|
|
752
|
-
// non-empty string, so the old raw value was almost always empty - but a token-shaped
|
|
753
|
-
// non-string would have been printed. refreshTokenType below already carries the kind.
|
|
754
|
-
refreshTokenFromResponsePresent: refreshToken !== undefined && refreshToken !== null && refreshToken !== '',
|
|
755
|
-
refreshTokenType: typeof refreshToken,
|
|
756
|
-
payloadKeys: Object.keys(payload || {}),
|
|
757
|
-
stack: new Error().stack
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
// Keep existing refresh token if IDP doesn't provide a new one
|
|
761
|
-
// Only clear if IDP explicitly signals discard_token=true (handled in error paths)
|
|
762
|
-
// NOTE: Use normalized field names (idp* prefix) per SessionData interface
|
|
763
|
-
// CRITICAL: Extract kid from JWT header - IDP may rotate keys during refresh
|
|
764
|
-
const newBearerKeyId = (0, token_utils_1.extractKidFromToken)(accessToken);
|
|
765
|
-
if (newBearerKeyId) {
|
|
766
|
-
console.log('[AUTH_REFRESH] Extracted bearerKeyId (kid) from new JWT header:', newBearerKeyId);
|
|
767
|
-
}
|
|
768
|
-
else {
|
|
769
|
-
console.warn('[AUTH_REFRESH] No kid found in new JWT header');
|
|
770
|
-
}
|
|
771
|
-
const sessionUpdate = {
|
|
772
|
-
...baseSession,
|
|
773
|
-
idpAccessToken: accessToken,
|
|
774
|
-
idpAccessTokenExpires: accessTokenExpires,
|
|
775
|
-
idpRefreshToken: hasNewRefresh ? refreshToken : originalRefreshToken,
|
|
776
|
-
idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : baseSession.idpRefreshTokenExpires,
|
|
777
|
-
decodedAccessToken: decodedAccessToken,
|
|
778
|
-
// Bearer key ID from JWT header (may change on key rotation)
|
|
779
|
-
bearerKeyId: newBearerKeyId || baseSession.bearerKeyId,
|
|
780
|
-
authenticationMethods: amrClaims,
|
|
781
|
-
authenticationLevel: acrLevel,
|
|
782
|
-
// 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
|
|
783
|
-
// for a fact with an expiry. mfaVerified is derived from amrClaims,
|
|
784
|
-
// which is FRESH every exchange (decoded from the token just
|
|
785
|
-
// returned, falling back to the prior session's amr only on a
|
|
786
|
-
// genuine decode failure a few lines up — not on "this token
|
|
787
|
-
// doesn't currently carry mfa"). ORing in currentSession.mfaVerified
|
|
788
|
-
// made the flag MONOTONIC against a claim that DECAYS (mfa_time,
|
|
789
|
-
// 10 minutes): once true, it stayed true forever regardless of
|
|
790
|
-
// what the current token actually says, silently re-opening the
|
|
791
|
-
// 2FA bypass with no bug in the bridge. Re-derive only.
|
|
792
|
-
mfaVerified: amrClaims.includes('mfa'),
|
|
793
|
-
mfaCompletedAt: mfaTime,
|
|
794
|
-
mfaExpiresAt: mfaExpires,
|
|
795
|
-
mfaValidityHours: mfaValidityHours
|
|
796
|
-
};
|
|
797
|
-
await (0, session_store_1.updateSession)(sessionToken, sessionUpdate);
|
|
798
|
-
console.info('[AUTH_REFRESH] Token refreshed successfully', {
|
|
799
|
-
expiresAt: new Date(accessTokenExpires).toISOString(),
|
|
800
|
-
userId,
|
|
801
|
-
hasNewRefreshToken: hasNewRefresh,
|
|
802
|
-
hasAccessToken: !!accessToken // PAY-1427: was tokenPreview (first 20 chars of the new access token)
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
catch (error) {
|
|
806
|
-
console.error('[AUTH_REFRESH] Error during token refresh', { error });
|
|
807
|
-
return server_1.NextResponse.json({
|
|
808
|
-
error: 'Token refresh error',
|
|
809
|
-
code: 'INTERNAL_SERVER_ERROR'
|
|
810
|
-
}, { status: 500 });
|
|
811
|
-
}
|
|
812
|
-
finally {
|
|
813
|
-
if (weAcquiredLock) {
|
|
814
|
-
await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId, releaseLockVersion);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
// Load the latest session to return basic status
|
|
818
|
-
const latest = await (0, session_store_1.getSession)(sessionToken);
|
|
819
|
-
if (!latest?.idpAccessToken) {
|
|
820
|
-
return server_1.NextResponse.json({
|
|
821
|
-
error: 'No access token after refresh',
|
|
822
|
-
code: 'INTERNAL_SERVER_ERROR'
|
|
823
|
-
}, { status: 500 });
|
|
824
|
-
}
|
|
117
|
+
// The caller's x-request-id is TRACE ONLY (E keeps it as `traceId` for logs); the lock identity is minted inside E.
|
|
118
|
+
const traceId = (0, safe_trace_id_1.sanitizeTraceId)(req.headers.get('x-request-id'));
|
|
119
|
+
const result = await (0, ensure_fresh_access_token_1.ensureFreshAccessToken)(sessionToken, { idpBaseUrl, clientId, refreshEndpoint }, { requestId: traceId, requireRefreshToken: true });
|
|
120
|
+
if (!result.ok)
|
|
121
|
+
return failureResponse(result);
|
|
122
|
+
// The access token is deliberately NOT in this body (E's result carries it; this route never returns it).
|
|
825
123
|
return server_1.NextResponse.json({
|
|
826
|
-
refreshed:
|
|
827
|
-
|
|
828
|
-
|
|
124
|
+
refreshed: result.refreshed,
|
|
125
|
+
// The route's bodies carried a `reason` for two cases only; a plain refresh had none.
|
|
126
|
+
...(result.reason === 'refreshed' ? {} : { reason: result.reason }),
|
|
127
|
+
accessTokenExpires: result.accessTokenExpires,
|
|
128
|
+
hasRefreshToken: result.hasRefreshToken,
|
|
829
129
|
});
|
|
830
130
|
}
|
|
831
131
|
catch (err) {
|
|
832
|
-
console.error('[AUTH_REFRESH] Unexpected error', { error:
|
|
132
|
+
console.error('[AUTH_REFRESH] Unexpected error', { error: (0, log_safe_1.errForLog)(err) });
|
|
833
133
|
return server_1.NextResponse.json({
|
|
834
134
|
error: 'Unexpected error during refresh',
|
|
835
135
|
code: 'INTERNAL_SERVER_ERROR'
|