@payez/next-mvp 4.1.11 → 4.1.13
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/dist/api-handlers/auth/refresh.js +15 -38
- package/dist/auth/better-auth.d.ts +0 -3
- package/dist/auth/better-auth.js +164 -69
- package/dist/client/better-auth-client.d.ts +14 -2
- package/dist/lib/ensure-fresh-access-token.js +8 -42
- package/dist/lib/session-store.d.ts +0 -1
- package/dist/lib/session-store.js +29 -33
- package/dist/lib/startup-init.js +19 -19
- package/dist/lib/token-lifecycle.d.ts +0 -2
- package/dist/lib/token-lifecycle.js +3 -21
- package/package.json +5 -4
- package/scripts/check-internal-url-usage.sh +73 -73
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/masked-info.ts +45 -45
- package/src/api-handlers/account/profile.ts +80 -80
- package/src/api-handlers/account/recovery/initiate.ts +23 -23
- package/src/api-handlers/account/recovery/send-code.ts +25 -25
- package/src/api-handlers/account/recovery/verify-code.ts +25 -25
- package/src/api-handlers/account/reset-password.ts +23 -23
- package/src/api-handlers/account/send-code.ts +76 -76
- package/src/api-handlers/account/validate-password.ts +118 -118
- package/src/api-handlers/admin/stats.ts +249 -249
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/jwks.ts +20 -20
- package/src/api-handlers/auth/login.ts +240 -240
- package/src/api-handlers/auth/refresh.ts +15 -38
- package/src/api-handlers/auth/validate.ts +43 -43
- package/src/api-handlers/session/viability.ts +142 -142
- package/src/auth/better-auth.ts +130 -29
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useProfile.ts +75 -75
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/auth-secret.ts +137 -137
- package/src/lib/demo-mode.ts +17 -17
- package/src/lib/ensure-fresh-access-token.ts +8 -42
- package/src/lib/idp-client-config.ts +559 -559
- package/src/lib/idp-fetch.ts +126 -126
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +26 -26
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-store.ts +692 -695
- package/src/lib/session.ts +34 -34
- package/src/lib/startup-init.ts +251 -251
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +470 -497
- package/src/lib/types/api-responses.ts +336 -336
- package/src/middleware/auth-decision.ts +43 -43
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +264 -264
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/refresh.ts +48 -48
- package/src/routes/auth/session.ts +166 -166
- package/src/routes/index.ts +17 -17
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth.ts +373 -373
- package/src/server/decode-session.ts +202 -202
- package/src/services/signalrActivityService.ts +258 -258
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +22 -22
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +353 -353
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
|
@@ -371,7 +371,7 @@ function createRefreshHandler(config) {
|
|
|
371
371
|
const errorMessage = idpError.message || 'Token refresh failed';
|
|
372
372
|
// CRITICAL: 401 means token is definitively invalid - always discard
|
|
373
373
|
// This prevents redirect loops where viability check keeps returning canRefresh:true
|
|
374
|
-
|
|
374
|
+
const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
|
|
375
375
|
const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
|
|
376
376
|
const resolution = idpError.resolution || null;
|
|
377
377
|
// ============================================================================
|
|
@@ -408,43 +408,20 @@ function createRefreshHandler(config) {
|
|
|
408
408
|
console.error('[AUTH_REFRESH] Full IDP error response:', JSON.stringify(responseData, null, 2).substring(0, 1000));
|
|
409
409
|
// If IDP signals to discard token, clear it from Redis
|
|
410
410
|
if (discardToken) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
});
|
|
426
|
-
await (0, session_store_1.updateSession)(sessionToken, {
|
|
427
|
-
idpRefreshToken: '',
|
|
428
|
-
idpRefreshTokenExpires: undefined,
|
|
429
|
-
refreshTokenClearedAt: Date.now(),
|
|
430
|
-
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
else {
|
|
434
|
-
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
435
|
-
// freshly-rotated token directly so the loser succeeds without a retry
|
|
436
|
-
// (race-fix follow-up #1).
|
|
437
|
-
discardToken = false;
|
|
438
|
-
console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
|
|
439
|
-
if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
|
|
440
|
-
return server_1.NextResponse.json({
|
|
441
|
-
refreshed: true,
|
|
442
|
-
reason: 'completed_by_concurrent_request',
|
|
443
|
-
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
444
|
-
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
411
|
+
console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
|
|
412
|
+
reason: 'IDP_DISCARD_TOKEN',
|
|
413
|
+
errorCode,
|
|
414
|
+
userId,
|
|
415
|
+
sessionToken: sessionToken.substring(0, 8) + '...',
|
|
416
|
+
hadRefreshToken: !!currentSession.idpRefreshToken,
|
|
417
|
+
stack: new Error().stack
|
|
418
|
+
});
|
|
419
|
+
await (0, session_store_1.updateSession)(sessionToken, {
|
|
420
|
+
idpRefreshToken: '',
|
|
421
|
+
idpRefreshTokenExpires: undefined,
|
|
422
|
+
refreshTokenClearedAt: Date.now(),
|
|
423
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
|
|
424
|
+
});
|
|
448
425
|
}
|
|
449
426
|
return server_1.NextResponse.json({
|
|
450
427
|
error: errorMessage,
|
|
@@ -22,9 +22,6 @@ export interface BetterAuthSocialProvider {
|
|
|
22
22
|
accessType?: 'offline' | 'online';
|
|
23
23
|
hd?: string;
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Build Better Auth social providers from IDP config.
|
|
27
|
-
*/
|
|
28
25
|
export declare function buildBetterAuthProviders(config: IDPClientConfig): Record<string, BetterAuthSocialProvider>;
|
|
29
26
|
/**
|
|
30
27
|
* Optional configuration for `createBetterAuthInstance`.
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -53,6 +53,8 @@ exports.getBetterAuthHandler = getBetterAuthHandler;
|
|
|
53
53
|
exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
|
|
54
54
|
exports.createAuthGetHandler = createAuthGetHandler;
|
|
55
55
|
require("server-only");
|
|
56
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
57
|
+
const jose_1 = require("jose"); // better-auth's own JWT dependency
|
|
56
58
|
const better_auth_1 = require("better-auth");
|
|
57
59
|
const next_js_1 = require("better-auth/next-js");
|
|
58
60
|
const next_js_2 = require("better-auth/next-js");
|
|
@@ -60,10 +62,33 @@ const magic_link_1 = require("better-auth/plugins/magic-link");
|
|
|
60
62
|
const idp_client_config_1 = require("../lib/idp-client-config");
|
|
61
63
|
const app_slug_1 = require("../lib/app-slug");
|
|
62
64
|
const redis_1 = require("../lib/redis");
|
|
63
|
-
const session_store_1 = require("../lib/session-store");
|
|
64
65
|
/**
|
|
65
66
|
* Build Better Auth social providers from IDP config.
|
|
66
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
|
|
70
|
+
*
|
|
71
|
+
* WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
|
|
72
|
+
* ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
|
|
73
|
+
* for the IDP to require a provider-signed id_token and verify it itself. This
|
|
74
|
+
* is the client half: capture the token Better Auth already receives, and
|
|
75
|
+
* forward it. Additive — the IDP ignores it until its gate flips.
|
|
76
|
+
*
|
|
77
|
+
* WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
|
|
78
|
+
* an id_token is credential material. Writing it to the session record would put
|
|
79
|
+
* it one allow-list edit away from leaking out of /api/internal/session-data,
|
|
80
|
+
* and it would outlive the request that needed it. ALS scopes it to the single
|
|
81
|
+
* request, and it is gone when the request ends — nothing to expire, nothing to
|
|
82
|
+
* scrub, nothing to leak.
|
|
83
|
+
*
|
|
84
|
+
* SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
|
|
85
|
+
* exchange (which reads it) both run inside createAuthGetHandler's own request.
|
|
86
|
+
* VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
|
|
87
|
+
* that invokes getUserInfo — and the exchange follows at :465 in the SAME
|
|
88
|
+
* function. Outside that scope the store is undefined and the body simply omits
|
|
89
|
+
* id_token, which is the correct additive behaviour.
|
|
90
|
+
*/
|
|
91
|
+
const idTokenScope = new node_async_hooks_1.AsyncLocalStorage();
|
|
67
92
|
function buildBetterAuthProviders(config) {
|
|
68
93
|
const providers = {};
|
|
69
94
|
for (const oauth of config.oauthProviders || []) {
|
|
@@ -93,6 +118,65 @@ function buildBetterAuthProviders(config) {
|
|
|
93
118
|
accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
|
|
94
119
|
hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
|
|
95
120
|
};
|
|
121
|
+
/**
|
|
122
|
+
* CAPTURE THE RAW id_token — card 138635, client half.
|
|
123
|
+
*
|
|
124
|
+
* getUserInfo is the ONLY provider hook that receives the token set.
|
|
125
|
+
* mapProfileToUser cannot do this: it is handed the DECODED claims, and a
|
|
126
|
+
* signed JWT cannot be reconstructed from them. We need the raw token
|
|
127
|
+
* because the IDP must verify the signature ITSELF — a client-decoded
|
|
128
|
+
* claim is just another assertable field, which is the defect restated.
|
|
129
|
+
*
|
|
130
|
+
* ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
|
|
131
|
+
* `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
|
|
132
|
+
* returns our result verbatim, so everything the default did is now ours to
|
|
133
|
+
* do — including the `hd` hosted-domain restriction.
|
|
134
|
+
*
|
|
135
|
+
* `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
|
|
136
|
+
* above, so a client can restrict sign-in to a Google Workspace domain.
|
|
137
|
+
* Silently dropping it would let ANY Google account into a client that had
|
|
138
|
+
* restricted itself — a security regression introduced by a security fix.
|
|
139
|
+
* (Measured on 93: zero clients set it today. That is a reason to be
|
|
140
|
+
* careful, not a reason to skip it — it is configurable and unused, which
|
|
141
|
+
* is exactly the state in which a regression goes unnoticed.)
|
|
142
|
+
*
|
|
143
|
+
* SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
|
|
144
|
+
* ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
|
|
145
|
+
* `if (!configuredHostedDomain) return true`. With no hd configured the
|
|
146
|
+
* default would return true unconditionally, so omitting it is equivalent
|
|
147
|
+
* by the library's own definition rather than by our judgement.
|
|
148
|
+
*
|
|
149
|
+
* A client that DOES set hd keeps the stock provider, keeps its domain
|
|
150
|
+
* restriction, and simply does not contribute an id_token yet. Losing a
|
|
151
|
+
* capability is an acceptable cost; silently losing a security control is
|
|
152
|
+
* not. (better-auth does not re-export the predicate through its public
|
|
153
|
+
* types, and reimplementing a security check is how two copies drift — so
|
|
154
|
+
* the honest move is to not need it.)
|
|
155
|
+
*/
|
|
156
|
+
if (name === 'google' && !providers[name].hd) {
|
|
157
|
+
providers[name].getUserInfo = async (token) => {
|
|
158
|
+
if (!token?.idToken)
|
|
159
|
+
return null;
|
|
160
|
+
// The only new behaviour: hand the raw token to this request's scope.
|
|
161
|
+
const scope = idTokenScope.getStore();
|
|
162
|
+
if (scope)
|
|
163
|
+
scope.idToken = token.idToken;
|
|
164
|
+
// Mirrors the upstream default (google.mjs) verbatim, minus the hd
|
|
165
|
+
// branch that cannot fire here — so overriding the hook changes WHAT WE
|
|
166
|
+
// KEEP, never what we ACCEPT.
|
|
167
|
+
const claims = (0, jose_1.decodeJwt)(token.idToken);
|
|
168
|
+
return {
|
|
169
|
+
user: {
|
|
170
|
+
id: claims.sub,
|
|
171
|
+
name: claims.name,
|
|
172
|
+
email: claims.email,
|
|
173
|
+
image: claims.picture,
|
|
174
|
+
emailVerified: claims.email_verified,
|
|
175
|
+
},
|
|
176
|
+
data: claims,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
}
|
|
96
180
|
}
|
|
97
181
|
return providers;
|
|
98
182
|
}
|
|
@@ -126,24 +210,16 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
|
|
|
126
210
|
// Redis-backed session storage via secondaryStorage
|
|
127
211
|
secondaryStorage: {
|
|
128
212
|
get: async (key) => {
|
|
129
|
-
|
|
130
|
-
return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
213
|
+
return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
|
|
135
214
|
},
|
|
136
215
|
set: async (key, value, ttl) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
|
|
144
|
-
}
|
|
216
|
+
const redis = (0, redis_1.getRedis)();
|
|
217
|
+
if (ttl) {
|
|
218
|
+
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
|
|
145
222
|
}
|
|
146
|
-
catch { /* Redis unavailable — cookie cache still works */ }
|
|
147
223
|
},
|
|
148
224
|
delete: async (key) => {
|
|
149
225
|
try {
|
|
@@ -306,6 +382,30 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
306
382
|
name,
|
|
307
383
|
image,
|
|
308
384
|
client_id: config.clientSlug || String(config.clientId),
|
|
385
|
+
/**
|
|
386
|
+
* Card 138635 — the RAW provider id_token, forwarded verbatim.
|
|
387
|
+
*
|
|
388
|
+
* Every other field in this body is an ASSERTION. The endpoint mints an
|
|
389
|
+
* IDP bearer from them today with no cryptographic check of any kind,
|
|
390
|
+
* which is why it is a complete authentication bypass and why it has
|
|
391
|
+
* been publicly reachable for 488 days.
|
|
392
|
+
*
|
|
393
|
+
* This field is the only one the IDP can VERIFY: signature against
|
|
394
|
+
* Google's JWKS, plus iss / exp / aud, and then read email_verified
|
|
395
|
+
* from the verified claims. Additive — the IDP ignores it until its
|
|
396
|
+
* gate flips, so this cannot break a login on its own.
|
|
397
|
+
*
|
|
398
|
+
* RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
|
|
399
|
+
* decoded email_verified. A client-computed verification flag is a body
|
|
400
|
+
* field with a better story: an attacker posts email_verified:true and
|
|
401
|
+
* the defect is rebuilt one layer up. The IDP verifies for itself or
|
|
402
|
+
* the fix is theatre.
|
|
403
|
+
*
|
|
404
|
+
* Absent when this runs outside the OAuth callback scope — the key is
|
|
405
|
+
* simply omitted rather than sent null, so "we never captured one" stays
|
|
406
|
+
* distinguishable from "the provider issued none".
|
|
407
|
+
*/
|
|
408
|
+
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
|
|
309
409
|
}),
|
|
310
410
|
});
|
|
311
411
|
const oauthResText = await oauthRes.text();
|
|
@@ -327,20 +427,8 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
327
427
|
return false;
|
|
328
428
|
}
|
|
329
429
|
// Build IDP token data
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
|
|
334
|
-
// oauth-callback even for require_2fa tenants that haven't done 2FA yet.
|
|
335
|
-
// This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
|
|
336
|
-
// bypassing the 2FA gate entirely.
|
|
337
|
-
//
|
|
338
|
-
// FIX: Always set mfaVerified=false on the initial OAuth exchange.
|
|
339
|
-
// The tenant's require_2fa setting is enforced by the viability endpoint
|
|
340
|
-
// (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
|
|
341
|
-
// mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
|
|
342
|
-
// Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
|
|
343
|
-
// IDP correctly withholds refresh_token until post-2FA.
|
|
430
|
+
// CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
|
|
431
|
+
// treat missing refresh token as "2FA required" so middleware redirects to verify-code.
|
|
344
432
|
const hasRefreshToken = !!result.refresh_token;
|
|
345
433
|
const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
|
|
346
434
|
const idpTokenData = {
|
|
@@ -354,19 +442,19 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
354
442
|
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
355
443
|
image: image,
|
|
356
444
|
roles: result.user?.roles || result.roles || [],
|
|
357
|
-
mfaVerified:
|
|
445
|
+
mfaVerified: !requiresTwoFactor,
|
|
358
446
|
idpClientId: result.client_id ? String(result.client_id) : undefined,
|
|
359
447
|
merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
|
|
360
448
|
};
|
|
361
449
|
// Store in BA Redis session (for decodeSession)
|
|
362
450
|
baData.idpTokens = idpTokenData;
|
|
363
|
-
await (0, redis_1.getRedis)().setex(baKey,
|
|
451
|
+
await (0, redis_1.getRedis)().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
|
|
364
452
|
// Write to canonical session store so refresh handler and token lifecycle can find the tokens.
|
|
365
453
|
// Key format: {sessionPrefix}{token} — same key that getSession() reads from.
|
|
366
454
|
try {
|
|
367
455
|
const { getSessionPrefix } = await Promise.resolve().then(() => __importStar(require('../lib/app-slug')));
|
|
368
456
|
const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
|
|
369
|
-
await (0, redis_1.getRedis)().setex(canonicalKey,
|
|
457
|
+
await (0, redis_1.getRedis)().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
|
|
370
458
|
...idpTokenData,
|
|
371
459
|
oauthProvider: provider,
|
|
372
460
|
}));
|
|
@@ -401,45 +489,52 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
401
489
|
*/
|
|
402
490
|
function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
403
491
|
return async function GET(request) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
414
|
-
return Response.redirect(new URL(loginPath, request.url), 302);
|
|
492
|
+
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
493
|
+
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
494
|
+
// hook that sees the token) and the exchange below (which forwards it).
|
|
495
|
+
// Wrapping the whole handler is what makes "never persisted" achievable —
|
|
496
|
+
// the token exists only for the life of this request.
|
|
497
|
+
return idTokenScope.run({}, async () => {
|
|
498
|
+
const ba = await getBetterAuthHandler();
|
|
499
|
+
if (!ba) {
|
|
500
|
+
return new Response('Auth handler not configured', { status: 500 });
|
|
415
501
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const setCookies = response.headers.getSetCookie?.() || [];
|
|
424
|
-
const cookieHeader = setCookies
|
|
425
|
-
.map((c) => c.split(';')[0])
|
|
426
|
-
.join('; ');
|
|
427
|
-
const headers = new Headers();
|
|
428
|
-
headers.set('cookie', cookieHeader);
|
|
429
|
-
const session = await auth.api.getSession({ headers });
|
|
430
|
-
if (session?.session?.token) {
|
|
431
|
-
console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
|
|
432
|
-
await exchangeOAuthForIdpTokens(session.session.token);
|
|
433
|
-
}
|
|
434
|
-
else {
|
|
435
|
-
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
436
|
-
}
|
|
502
|
+
const response = await ba.GET(request);
|
|
503
|
+
// Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
|
|
504
|
+
if (response.status === 302) {
|
|
505
|
+
const location = response.headers.get('location') || '';
|
|
506
|
+
if (location.includes('/api/auth/error') || location.includes('please_restart')) {
|
|
507
|
+
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
508
|
+
return Response.redirect(new URL(loginPath, request.url), 302);
|
|
437
509
|
}
|
|
438
510
|
}
|
|
439
|
-
|
|
440
|
-
|
|
511
|
+
// After successful OAuth callback: exchange Google identity for IDP tokens
|
|
512
|
+
const url = new URL(request.url);
|
|
513
|
+
if (url.pathname.includes('/callback/') && response.status === 302) {
|
|
514
|
+
try {
|
|
515
|
+
const auth = await getBetterAuthInstance();
|
|
516
|
+
if (auth?.api?.getSession) {
|
|
517
|
+
const setCookies = response.headers.getSetCookie?.() || [];
|
|
518
|
+
const cookieHeader = setCookies
|
|
519
|
+
.map((c) => c.split(';')[0])
|
|
520
|
+
.join('; ');
|
|
521
|
+
const headers = new Headers();
|
|
522
|
+
headers.set('cookie', cookieHeader);
|
|
523
|
+
const session = await auth.api.getSession({ headers });
|
|
524
|
+
if (session?.session?.token) {
|
|
525
|
+
console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
|
|
526
|
+
await exchangeOAuthForIdpTokens(session.session.token);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
535
|
+
}
|
|
441
536
|
}
|
|
442
|
-
|
|
443
|
-
|
|
537
|
+
return response;
|
|
538
|
+
});
|
|
444
539
|
};
|
|
445
540
|
}
|
|
@@ -814,7 +814,13 @@ export declare const authClient: {
|
|
|
814
814
|
isPending: boolean;
|
|
815
815
|
isRefetching: boolean;
|
|
816
816
|
error: import("@better-fetch/fetch").BetterFetchError | null;
|
|
817
|
-
refetch: (queryParams
|
|
817
|
+
refetch: (queryParams
|
|
818
|
+
/**
|
|
819
|
+
* NextAuth-compatible signOut wrapper.
|
|
820
|
+
*
|
|
821
|
+
* Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
|
|
822
|
+
*/
|
|
823
|
+
?: {
|
|
818
824
|
query?: import("better-auth/types").SessionQueryParams;
|
|
819
825
|
} | undefined) => Promise<void>;
|
|
820
826
|
};
|
|
@@ -988,7 +994,13 @@ export declare const useSession: () => {
|
|
|
988
994
|
isPending: boolean;
|
|
989
995
|
isRefetching: boolean;
|
|
990
996
|
error: import("@better-fetch/fetch").BetterFetchError | null;
|
|
991
|
-
refetch: (queryParams
|
|
997
|
+
refetch: (queryParams
|
|
998
|
+
/**
|
|
999
|
+
* NextAuth-compatible signOut wrapper.
|
|
1000
|
+
*
|
|
1001
|
+
* Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
|
|
1002
|
+
*/
|
|
1003
|
+
?: {
|
|
992
1004
|
query?: import("better-auth/types").SessionQueryParams;
|
|
993
1005
|
} | undefined) => Promise<void>;
|
|
994
1006
|
}, signIn: {
|
|
@@ -158,16 +158,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
158
158
|
if (isOAuthSession && session.mfaVerified && acrValue === '1') {
|
|
159
159
|
acrValue = '2';
|
|
160
160
|
}
|
|
161
|
-
// WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
|
|
162
|
-
// `session` was read before we acquired the lock; a concurrent refresh that
|
|
163
|
-
// completed while we waited rotates the token, so the pre-lock value is stale
|
|
164
|
-
// and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
|
|
165
|
-
// the round-trip is wasted). `latest` is the authoritative post-lock session.
|
|
166
|
-
// Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
|
|
167
|
-
// the identity context of THIS request, unchanged by a token rotation.
|
|
168
|
-
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
169
161
|
const body = {
|
|
170
|
-
refresh_token:
|
|
162
|
+
refresh_token: session.idpRefreshToken,
|
|
171
163
|
amr: authMethods,
|
|
172
164
|
acr: acrValue,
|
|
173
165
|
};
|
|
@@ -208,41 +200,15 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
208
200
|
if (!idpResponse.ok) {
|
|
209
201
|
const idpError = responseData?.error || {};
|
|
210
202
|
const code = idpError.code || 'UNKNOWN_ERROR';
|
|
211
|
-
|
|
203
|
+
const discardToken = idpResponse.status === 401 || idpError.discard_token === true;
|
|
212
204
|
const retryable = idpResponse.status !== 401 && idpError.retryable === true;
|
|
213
205
|
if (discardToken) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
|
|
221
|
-
// the post-lock value), not the pre-lock snapshot — otherwise a token rotated
|
|
222
|
-
// between pre-lock read and our send would be misjudged.
|
|
223
|
-
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
224
|
-
if (tokenUnchanged) {
|
|
225
|
-
await (0, session_store_1.updateSession)(sessionToken, {
|
|
226
|
-
idpRefreshToken: '',
|
|
227
|
-
idpRefreshTokenExpires: undefined,
|
|
228
|
-
refreshTokenClearedAt: Date.now(),
|
|
229
|
-
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
234
|
-
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
235
|
-
// (race-fix follow-up #1).
|
|
236
|
-
discardToken = false;
|
|
237
|
-
if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
|
|
238
|
-
return {
|
|
239
|
-
ok: true,
|
|
240
|
-
accessToken: postRefresh.idpAccessToken,
|
|
241
|
-
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
242
|
-
refreshed: true,
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
}
|
|
206
|
+
await (0, session_store_1.updateSession)(sessionToken, {
|
|
207
|
+
idpRefreshToken: '',
|
|
208
|
+
idpRefreshTokenExpires: undefined,
|
|
209
|
+
refreshTokenClearedAt: Date.now(),
|
|
210
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
211
|
+
});
|
|
246
212
|
}
|
|
247
213
|
return {
|
|
248
214
|
ok: false,
|
|
@@ -12,7 +12,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
13
|
};
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.SESSION_TTL = void 0;
|
|
16
15
|
exports.generateSessionToken = generateSessionToken;
|
|
17
16
|
exports.createSession = createSession;
|
|
18
17
|
exports.getSession = getSession;
|
|
@@ -47,10 +46,7 @@ const getSessionVersionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}ve
|
|
|
47
46
|
// Better Auth uses a different key format: ba:{appSlug}:{token}
|
|
48
47
|
const getBetterAuthSessionKey = (token, appSlug) => `ba:${appSlug || 'app'}:${token}`;
|
|
49
48
|
const REFRESH_LOCK_TTL = 60; // 60 seconds
|
|
50
|
-
// 3 days in seconds (matches refresh token lifetime)
|
|
51
|
-
// of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
|
|
52
|
-
// two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
|
|
53
|
-
exports.SESSION_TTL = 3 * 24 * 60 * 60;
|
|
49
|
+
const SESSION_TTL = 3 * 24 * 60 * 60; // 3 days in seconds (matches refresh token lifetime)
|
|
54
50
|
/**
|
|
55
51
|
* Generates a new session token.
|
|
56
52
|
* @returns A new session token string.
|
|
@@ -70,8 +66,8 @@ async function createSession(data) {
|
|
|
70
66
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
71
67
|
try {
|
|
72
68
|
await redis_1.default.multi()
|
|
73
|
-
.setex(key,
|
|
74
|
-
.setex(versionKey,
|
|
69
|
+
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
70
|
+
.setex(versionKey, SESSION_TTL, '1')
|
|
75
71
|
.exec();
|
|
76
72
|
}
|
|
77
73
|
catch (error) {
|
|
@@ -152,7 +148,7 @@ async function getBetterAuthSession(sessionToken, appSlug) {
|
|
|
152
148
|
*/
|
|
153
149
|
async function touchSession(token) {
|
|
154
150
|
const key = getSessionKey(token);
|
|
155
|
-
await redis_1.default.expire(key,
|
|
151
|
+
await redis_1.default.expire(key, SESSION_TTL);
|
|
156
152
|
}
|
|
157
153
|
/**
|
|
158
154
|
* Retrieves a session along with a version identifier for optimistic locking.
|
|
@@ -218,9 +214,9 @@ async function setSession(sessionToken, data) {
|
|
|
218
214
|
const key = getSessionKey(sessionToken);
|
|
219
215
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
220
216
|
await redis_1.default.multi()
|
|
221
|
-
.setex(key,
|
|
217
|
+
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
222
218
|
.incr(versionKey)
|
|
223
|
-
.expire(versionKey,
|
|
219
|
+
.expire(versionKey, SESSION_TTL)
|
|
224
220
|
.exec();
|
|
225
221
|
}
|
|
226
222
|
/**
|
|
@@ -258,9 +254,9 @@ async function updateSession(sessionToken, updates) {
|
|
|
258
254
|
const updatedSession = { ...currentSession, ...updates };
|
|
259
255
|
// Write the entire updated session back to Redis with version increment
|
|
260
256
|
await redis_1.default.multi()
|
|
261
|
-
.setex(key,
|
|
257
|
+
.setex(key, SESSION_TTL, JSON.stringify(updatedSession))
|
|
262
258
|
.incr(versionKey)
|
|
263
|
-
.expire(versionKey,
|
|
259
|
+
.expire(versionKey, SESSION_TTL)
|
|
264
260
|
.exec();
|
|
265
261
|
return updatedSession;
|
|
266
262
|
}
|
|
@@ -489,27 +485,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
|
|
|
489
485
|
const lockKey = getRefreshLockKey(sessionToken);
|
|
490
486
|
try {
|
|
491
487
|
// Lua script for atomic lock validation and release
|
|
492
|
-
const luaScript = `
|
|
493
|
-
local lockKey = KEYS[1]
|
|
494
|
-
local expectedRequestId = ARGV[1]
|
|
495
|
-
local expectedVersion = ARGV[2]
|
|
496
|
-
|
|
497
|
-
local lockData = redis.call('GET', lockKey)
|
|
498
|
-
if not lockData then
|
|
499
|
-
return 0 -- Lock doesn't exist
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
local lockInfo = cjson.decode(lockData)
|
|
503
|
-
if lockInfo.acquiredBy == expectedRequestId then
|
|
504
|
-
if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
|
|
505
|
-
redis.call('DEL', lockKey)
|
|
506
|
-
return 1 -- Successfully released
|
|
507
|
-
else
|
|
508
|
-
return -2 -- Version mismatch
|
|
509
|
-
end
|
|
510
|
-
else
|
|
511
|
-
return -1 -- Wrong owner
|
|
512
|
-
end
|
|
488
|
+
const luaScript = `
|
|
489
|
+
local lockKey = KEYS[1]
|
|
490
|
+
local expectedRequestId = ARGV[1]
|
|
491
|
+
local expectedVersion = ARGV[2]
|
|
492
|
+
|
|
493
|
+
local lockData = redis.call('GET', lockKey)
|
|
494
|
+
if not lockData then
|
|
495
|
+
return 0 -- Lock doesn't exist
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
local lockInfo = cjson.decode(lockData)
|
|
499
|
+
if lockInfo.acquiredBy == expectedRequestId then
|
|
500
|
+
if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
|
|
501
|
+
redis.call('DEL', lockKey)
|
|
502
|
+
return 1 -- Successfully released
|
|
503
|
+
else
|
|
504
|
+
return -2 -- Version mismatch
|
|
505
|
+
end
|
|
506
|
+
else
|
|
507
|
+
return -1 -- Wrong owner
|
|
508
|
+
end
|
|
513
509
|
`;
|
|
514
510
|
const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
|
|
515
511
|
if (result === 1) {
|