@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.
Files changed (92) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.d.ts +0 -3
  3. package/dist/auth/better-auth.js +164 -69
  4. package/dist/client/better-auth-client.d.ts +14 -2
  5. package/dist/lib/ensure-fresh-access-token.js +8 -42
  6. package/dist/lib/session-store.d.ts +0 -1
  7. package/dist/lib/session-store.js +29 -33
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/lib/token-lifecycle.d.ts +0 -2
  10. package/dist/lib/token-lifecycle.js +3 -21
  11. package/package.json +5 -4
  12. package/scripts/check-internal-url-usage.sh +73 -73
  13. package/src/api/index.ts +17 -17
  14. package/src/api-handlers/account/masked-info.ts +45 -45
  15. package/src/api-handlers/account/profile.ts +80 -80
  16. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  17. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  18. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  19. package/src/api-handlers/account/reset-password.ts +23 -23
  20. package/src/api-handlers/account/send-code.ts +76 -76
  21. package/src/api-handlers/account/validate-password.ts +118 -118
  22. package/src/api-handlers/admin/stats.ts +249 -249
  23. package/src/api-handlers/anon/preferences.ts +123 -123
  24. package/src/api-handlers/auth/jwks.ts +20 -20
  25. package/src/api-handlers/auth/login.ts +240 -240
  26. package/src/api-handlers/auth/refresh.ts +15 -38
  27. package/src/api-handlers/auth/validate.ts +43 -43
  28. package/src/api-handlers/session/viability.ts +142 -142
  29. package/src/auth/better-auth.ts +130 -29
  30. package/src/auth/unauthenticated-routes.ts +2 -2
  31. package/src/client/better-auth-client.ts +70 -70
  32. package/src/client/fetchWithSession.ts +21 -21
  33. package/src/client/index.ts +13 -13
  34. package/src/client/useAnonSession.ts +131 -131
  35. package/src/components/admin/VibeAdminContext.tsx +87 -87
  36. package/src/config/logging-config.ts +82 -82
  37. package/src/config/unauthenticated-routes.ts +19 -19
  38. package/src/edge/internal-api-url.ts +65 -65
  39. package/src/edge/middleware.ts +42 -42
  40. package/src/hooks/useProfile.ts +75 -75
  41. package/src/lib/anon-session.ts +213 -213
  42. package/src/lib/auth-secret.ts +137 -137
  43. package/src/lib/demo-mode.ts +17 -17
  44. package/src/lib/ensure-fresh-access-token.ts +8 -42
  45. package/src/lib/idp-client-config.ts +559 -559
  46. package/src/lib/idp-fetch.ts +126 -126
  47. package/src/lib/internal-api.ts +171 -171
  48. package/src/lib/jwt-decode-client.ts +45 -45
  49. package/src/lib/rate-limit-service.ts +9 -9
  50. package/src/lib/redis.ts +26 -26
  51. package/src/lib/refresh-token-validator.ts +64 -64
  52. package/src/lib/secret-validation.ts +8 -8
  53. package/src/lib/session-store.ts +692 -695
  54. package/src/lib/session.ts +34 -34
  55. package/src/lib/startup-init.ts +251 -251
  56. package/src/lib/token-expiry.ts +40 -40
  57. package/src/lib/token-lifecycle.ts +470 -497
  58. package/src/lib/types/api-responses.ts +336 -336
  59. package/src/middleware/auth-decision.ts +43 -43
  60. package/src/models/DecodedAccessToken.ts +16 -16
  61. package/src/models/SessionModel.ts +264 -264
  62. package/src/pages/admin-roles/index.ts +9 -9
  63. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  64. package/src/routes/account/index.ts +31 -31
  65. package/src/routes/account/masked-info.ts +36 -36
  66. package/src/routes/account/send-code.ts +39 -39
  67. package/src/routes/account/verify-email.ts +40 -40
  68. package/src/routes/account/verify-sms.ts +40 -40
  69. package/src/routes/auth/index.ts +22 -22
  70. package/src/routes/auth/refresh.ts +48 -48
  71. package/src/routes/auth/session.ts +166 -166
  72. package/src/routes/index.ts +17 -17
  73. package/src/routes/session/index.ts +7 -7
  74. package/src/routes/session/refresh-viability.ts +17 -17
  75. package/src/server/auth.ts +373 -373
  76. package/src/server/decode-session.ts +202 -202
  77. package/src/services/signalrActivityService.ts +258 -258
  78. package/src/theme/ThemeProvider.tsx +39 -39
  79. package/src/theme/default.ts +33 -33
  80. package/src/theme/index.ts +31 -31
  81. package/src/theme/types.ts +69 -69
  82. package/src/theme/useTheme.ts +57 -57
  83. package/src/theme/utils.ts +40 -40
  84. package/src/types/api.ts +12 -12
  85. package/src/types/auth.d.ts +23 -23
  86. package/src/types/auth.ts +22 -22
  87. package/src/types/logging.ts +11 -11
  88. package/src/types/recovery.ts +54 -54
  89. package/src/utils/api.ts +353 -353
  90. package/src/utils/circuitBreaker.ts +40 -40
  91. package/src/utils/error-message.ts +108 -108
  92. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,143 +1,143 @@
1
- /**
2
- * Session Viability Check API Handler for `@payez/next-mvp`
3
- *
4
- * This API route is called by the middleware to securely check if a session is valid.
5
- */
6
-
7
- import { NextRequest, NextResponse } from 'next/server';
8
- import { getSession as getRedisSession, getBetterAuthSession } from '../../lib/session-store';
9
- import { getSession } from '../../server/auth';
10
- import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
11
- import { getIDPClientConfig } from '../../lib/idp-client-config';
12
-
13
- export async function GET(req: NextRequest) {
14
- try {
15
- // Ensure initialization is complete (auth signing secret resolved from IDP)
16
- if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
17
- try {
18
- await ensureInitialized();
19
- } catch (error) {
20
- // Initialization failed - return 503
21
- console.error('[API Viability] Initialization failed - returning 503');
22
- return NextResponse.json(
23
- {
24
- error: 'Service Unavailable',
25
- message: 'Authentication service is not properly configured',
26
- code: 'AUTH_NOT_INITIALIZED'
27
- },
28
- { status: 503 }
29
- );
30
- }
31
- }
32
-
33
- // Double-check after initialization attempt
34
- if (isInitializationFailed()) {
35
- console.error('[API Viability] Initialization failed - returning 503');
36
- return NextResponse.json(
37
- {
38
- error: 'Service Unavailable',
39
- message: 'Authentication service is not properly configured',
40
- code: 'AUTH_NOT_INITIALIZED'
41
- },
42
- { status: 503 }
43
- );
44
- }
45
-
46
- // Get session from Better Auth
47
- const betterAuthSession = await getSession(req);
48
-
49
- // Debug logging
50
- if (!betterAuthSession) {
51
- console.warn('[VIABILITY] getSession returned null');
52
- }
53
-
54
- const sessionToken = betterAuthSession?.session?.token as string | undefined;
55
- if (betterAuthSession && sessionToken) {
56
- // Try legacy session store first, then Better Auth format
57
- let sessionData = await getRedisSession(sessionToken);
58
- if (!sessionData) {
59
- // Better Auth stores sessions with ba:{appSlug}:{token} prefix
60
- sessionData = await getBetterAuthSession(sessionToken);
61
- if (sessionData) {
62
- console.log('[VIABILITY] Found session in Better Auth store');
63
- }
64
- }
65
- if (sessionData) {
66
- // The session exists in Redis
67
-
68
- // Check if access token is expired (for middleware decision-making)
69
- const accessTokenExpires = sessionData.idpAccessTokenExpires || 0;
70
- const accessTokenExpired = accessTokenExpires < Date.now();
71
-
72
- // Get requires2FA from cached client config (client-wide setting)
73
- let requires2FA = true; // Default to true for security
74
- try {
75
- const cachedConfig = await getIDPClientConfig();
76
- requires2FA = cachedConfig.authSettings?.require2FA ?? true;
77
- } catch (e) {
78
- console.warn('[API Viability] Could not get client config, defaulting requires2FA to true');
79
- }
80
-
81
- // CRITICAL: OAuth sessions without refresh token always need 2FA completion
82
- // to obtain a refresh token from the IDP. The OAuth callback sets mfaVerified=false
83
- // when no refresh_token is present. This must override the client config.
84
- if (sessionData.mfaVerified === false && !sessionData.idpRefreshToken) {
85
- requires2FA = true;
86
- }
87
-
88
- // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
89
- // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
90
- // has passed, we must treat 2FA as incomplete to force re-verification.
91
- const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
92
- const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
93
- // Check both field names for compatibility (mfaVerified is the normalized name)
94
- const sessionMfaComplete = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
95
- const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
96
-
97
- console.log('[VIABILITY] Session 2FA check:', {
98
- sessionToken: sessionToken.substring(0, 8) + '...',
99
- mfaVerified: sessionData.mfaVerified,
100
- twoFactorComplete: (sessionData as any).twoFactorComplete,
101
- sessionMfaComplete,
102
- mfaExpired,
103
- effectiveTwoFactorComplete,
104
- });
105
-
106
- if (mfaExpired && sessionMfaComplete) {
107
- console.warn('[API Viability] MFA expired - forcing 2FA re-verification', {
108
- mfaExpiresAt: new Date(mfaExpiresAt).toISOString(),
109
- now: new Date().toISOString(),
110
- hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
111
- });
112
- }
113
-
114
- const response = {
115
- authenticated: true,
116
- sessionToken, // Include token for middleware tracking
117
- // 2FA fields - critical for middleware redirect logic
118
- requires2FA, // From cached client config (client-wide setting)
119
- twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
120
- // Token status for refresh decisions
121
- accessTokenExpired,
122
- hasRefreshToken: !!sessionData.idpRefreshToken
123
- };
124
- return NextResponse.json(response);
125
- }
126
-
127
- // CRITICAL: Cookie exists but Redis session is missing (stale cookie state)
128
- // Return sessionToken so middleware can detect this and clear the stale cookie
129
- console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
130
- return NextResponse.json({
131
- authenticated: false,
132
- sessionToken // Include token to enable stale cookie detection
133
- });
134
- }
135
-
136
- // If there's no token at all, it's not authenticated
137
- return NextResponse.json({ authenticated: false });
138
-
139
- } catch (error) {
140
- console.error('[API Viability] Error checking session viability:', error);
141
- return NextResponse.json({ authenticated: false }, { status: 500 });
142
- }
1
+ /**
2
+ * Session Viability Check API Handler for `@payez/next-mvp`
3
+ *
4
+ * This API route is called by the middleware to securely check if a session is valid.
5
+ */
6
+
7
+ import { NextRequest, NextResponse } from 'next/server';
8
+ import { getSession as getRedisSession, getBetterAuthSession } from '../../lib/session-store';
9
+ import { getSession } from '../../server/auth';
10
+ import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
11
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
12
+
13
+ export async function GET(req: NextRequest) {
14
+ try {
15
+ // Ensure initialization is complete (auth signing secret resolved from IDP)
16
+ if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
17
+ try {
18
+ await ensureInitialized();
19
+ } catch (error) {
20
+ // Initialization failed - return 503
21
+ console.error('[API Viability] Initialization failed - returning 503');
22
+ return NextResponse.json(
23
+ {
24
+ error: 'Service Unavailable',
25
+ message: 'Authentication service is not properly configured',
26
+ code: 'AUTH_NOT_INITIALIZED'
27
+ },
28
+ { status: 503 }
29
+ );
30
+ }
31
+ }
32
+
33
+ // Double-check after initialization attempt
34
+ if (isInitializationFailed()) {
35
+ console.error('[API Viability] Initialization failed - returning 503');
36
+ return NextResponse.json(
37
+ {
38
+ error: 'Service Unavailable',
39
+ message: 'Authentication service is not properly configured',
40
+ code: 'AUTH_NOT_INITIALIZED'
41
+ },
42
+ { status: 503 }
43
+ );
44
+ }
45
+
46
+ // Get session from Better Auth
47
+ const betterAuthSession = await getSession(req);
48
+
49
+ // Debug logging
50
+ if (!betterAuthSession) {
51
+ console.warn('[VIABILITY] getSession returned null');
52
+ }
53
+
54
+ const sessionToken = betterAuthSession?.session?.token as string | undefined;
55
+ if (betterAuthSession && sessionToken) {
56
+ // Try legacy session store first, then Better Auth format
57
+ let sessionData = await getRedisSession(sessionToken);
58
+ if (!sessionData) {
59
+ // Better Auth stores sessions with ba:{appSlug}:{token} prefix
60
+ sessionData = await getBetterAuthSession(sessionToken);
61
+ if (sessionData) {
62
+ console.log('[VIABILITY] Found session in Better Auth store');
63
+ }
64
+ }
65
+ if (sessionData) {
66
+ // The session exists in Redis
67
+
68
+ // Check if access token is expired (for middleware decision-making)
69
+ const accessTokenExpires = sessionData.idpAccessTokenExpires || 0;
70
+ const accessTokenExpired = accessTokenExpires < Date.now();
71
+
72
+ // Get requires2FA from cached client config (client-wide setting)
73
+ let requires2FA = true; // Default to true for security
74
+ try {
75
+ const cachedConfig = await getIDPClientConfig();
76
+ requires2FA = cachedConfig.authSettings?.require2FA ?? true;
77
+ } catch (e) {
78
+ console.warn('[API Viability] Could not get client config, defaulting requires2FA to true');
79
+ }
80
+
81
+ // CRITICAL: OAuth sessions without refresh token always need 2FA completion
82
+ // to obtain a refresh token from the IDP. The OAuth callback sets mfaVerified=false
83
+ // when no refresh_token is present. This must override the client config.
84
+ if (sessionData.mfaVerified === false && !sessionData.idpRefreshToken) {
85
+ requires2FA = true;
86
+ }
87
+
88
+ // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
89
+ // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
90
+ // has passed, we must treat 2FA as incomplete to force re-verification.
91
+ const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
92
+ const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
93
+ // Check both field names for compatibility (mfaVerified is the normalized name)
94
+ const sessionMfaComplete = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
95
+ const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
96
+
97
+ console.log('[VIABILITY] Session 2FA check:', {
98
+ sessionToken: sessionToken.substring(0, 8) + '...',
99
+ mfaVerified: sessionData.mfaVerified,
100
+ twoFactorComplete: (sessionData as any).twoFactorComplete,
101
+ sessionMfaComplete,
102
+ mfaExpired,
103
+ effectiveTwoFactorComplete,
104
+ });
105
+
106
+ if (mfaExpired && sessionMfaComplete) {
107
+ console.warn('[API Viability] MFA expired - forcing 2FA re-verification', {
108
+ mfaExpiresAt: new Date(mfaExpiresAt).toISOString(),
109
+ now: new Date().toISOString(),
110
+ hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
111
+ });
112
+ }
113
+
114
+ const response = {
115
+ authenticated: true,
116
+ sessionToken, // Include token for middleware tracking
117
+ // 2FA fields - critical for middleware redirect logic
118
+ requires2FA, // From cached client config (client-wide setting)
119
+ twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
120
+ // Token status for refresh decisions
121
+ accessTokenExpired,
122
+ hasRefreshToken: !!sessionData.idpRefreshToken
123
+ };
124
+ return NextResponse.json(response);
125
+ }
126
+
127
+ // CRITICAL: Cookie exists but Redis session is missing (stale cookie state)
128
+ // Return sessionToken so middleware can detect this and clear the stale cookie
129
+ console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
130
+ return NextResponse.json({
131
+ authenticated: false,
132
+ sessionToken // Include token to enable stale cookie detection
133
+ });
134
+ }
135
+
136
+ // If there's no token at all, it's not authenticated
137
+ return NextResponse.json({ authenticated: false });
138
+
139
+ } catch (error) {
140
+ console.error('[API Viability] Error checking session viability:', error);
141
+ return NextResponse.json({ authenticated: false }, { status: 500 });
142
+ }
143
143
  }
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import 'server-only';
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
13
15
  import { betterAuth } from 'better-auth';
14
16
  import { nextCookies } from 'better-auth/next-js';
15
17
  import { toNextJsHandler } from 'better-auth/next-js';
@@ -18,7 +20,6 @@ import type { IDPClientConfig } from '../lib/idp-client-config';
18
20
  import { getIDPClientConfig } from '../lib/idp-client-config';
19
21
  import { getAppSlug } from '../lib/app-slug';
20
22
  import { getRedis } from '../lib/redis';
21
- import { SESSION_TTL } from '../lib/session-store';
22
23
 
23
24
  /**
24
25
  * Better Auth social provider config shape.
@@ -35,6 +36,31 @@ export interface BetterAuthSocialProvider {
35
36
  /**
36
37
  * Build Better Auth social providers from IDP config.
37
38
  */
39
+ /**
40
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
41
+ *
42
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
43
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
44
+ * for the IDP to require a provider-signed id_token and verify it itself. This
45
+ * is the client half: capture the token Better Auth already receives, and
46
+ * forward it. Additive — the IDP ignores it until its gate flips.
47
+ *
48
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
49
+ * an id_token is credential material. Writing it to the session record would put
50
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
51
+ * and it would outlive the request that needed it. ALS scopes it to the single
52
+ * request, and it is gone when the request ends — nothing to expire, nothing to
53
+ * scrub, nothing to leak.
54
+ *
55
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
56
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
57
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
58
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
59
+ * function. Outside that scope the store is undefined and the body simply omits
60
+ * id_token, which is the correct additive behaviour.
61
+ */
62
+ const idTokenScope = new AsyncLocalStorage<{ idToken?: string }>();
63
+
38
64
  export function buildBetterAuthProviders(
39
65
  config: IDPClientConfig
40
66
  ): Record<string, BetterAuthSocialProvider> {
@@ -68,6 +94,66 @@ export function buildBetterAuthProviders(
68
94
  accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
69
95
  hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
70
96
  };
97
+
98
+ /**
99
+ * CAPTURE THE RAW id_token — card 138635, client half.
100
+ *
101
+ * getUserInfo is the ONLY provider hook that receives the token set.
102
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
103
+ * signed JWT cannot be reconstructed from them. We need the raw token
104
+ * because the IDP must verify the signature ITSELF — a client-decoded
105
+ * claim is just another assertable field, which is the defect restated.
106
+ *
107
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
108
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
109
+ * returns our result verbatim, so everything the default did is now ours to
110
+ * do — including the `hd` hosted-domain restriction.
111
+ *
112
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
113
+ * above, so a client can restrict sign-in to a Google Workspace domain.
114
+ * Silently dropping it would let ANY Google account into a client that had
115
+ * restricted itself — a security regression introduced by a security fix.
116
+ * (Measured on 93: zero clients set it today. That is a reason to be
117
+ * careful, not a reason to skip it — it is configurable and unused, which
118
+ * is exactly the state in which a regression goes unnoticed.)
119
+ *
120
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
121
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
122
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
123
+ * default would return true unconditionally, so omitting it is equivalent
124
+ * by the library's own definition rather than by our judgement.
125
+ *
126
+ * A client that DOES set hd keeps the stock provider, keeps its domain
127
+ * restriction, and simply does not contribute an id_token yet. Losing a
128
+ * capability is an acceptable cost; silently losing a security control is
129
+ * not. (better-auth does not re-export the predicate through its public
130
+ * types, and reimplementing a security check is how two copies drift — so
131
+ * the honest move is to not need it.)
132
+ */
133
+ if (name === 'google' && !providers[name].hd) {
134
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
135
+ if (!token?.idToken) return null;
136
+
137
+ // The only new behaviour: hand the raw token to this request's scope.
138
+ const scope = idTokenScope.getStore();
139
+ if (scope) scope.idToken = token.idToken;
140
+
141
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
142
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
143
+ // KEEP, never what we ACCEPT.
144
+ const claims = decodeJwt(token.idToken) as Record<string, any>;
145
+ return {
146
+ user: {
147
+ id: claims.sub,
148
+ name: claims.name,
149
+ email: claims.email,
150
+ image: claims.picture,
151
+ emailVerified: claims.email_verified,
152
+ },
153
+ data: claims,
154
+ };
155
+ };
156
+ }
71
157
  }
72
158
 
73
159
  return providers;
@@ -124,19 +210,15 @@ export function createBetterAuthInstance(
124
210
  // Redis-backed session storage via secondaryStorage
125
211
  secondaryStorage: {
126
212
  get: async (key: string) => {
127
- try {
128
- return await getRedis().get(`ba:${appSlug}:${key}`);
129
- } catch { return null; }
213
+ return await getRedis().get(`ba:${appSlug}:${key}`);
130
214
  },
131
215
  set: async (key: string, value: string, ttl?: number) => {
132
- try {
133
- const redis = getRedis();
134
- if (ttl) {
135
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
136
- } else {
137
- await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
138
- }
139
- } catch { /* Redis unavailable — cookie cache still works */ }
216
+ const redis = getRedis();
217
+ if (ttl) {
218
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
219
+ } else {
220
+ await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
221
+ }
140
222
  },
141
223
  delete: async (key: string) => {
142
224
  try {
@@ -322,6 +404,30 @@ export async function exchangeOAuthForIdpTokens(
322
404
  name,
323
405
  image,
324
406
  client_id: config.clientSlug || String(config.clientId),
407
+ /**
408
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
409
+ *
410
+ * Every other field in this body is an ASSERTION. The endpoint mints an
411
+ * IDP bearer from them today with no cryptographic check of any kind,
412
+ * which is why it is a complete authentication bypass and why it has
413
+ * been publicly reachable for 488 days.
414
+ *
415
+ * This field is the only one the IDP can VERIFY: signature against
416
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
417
+ * from the verified claims. Additive — the IDP ignores it until its
418
+ * gate flips, so this cannot break a login on its own.
419
+ *
420
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
421
+ * decoded email_verified. A client-computed verification flag is a body
422
+ * field with a better story: an attacker posts email_verified:true and
423
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
424
+ * the fix is theatre.
425
+ *
426
+ * Absent when this runs outside the OAuth callback scope — the key is
427
+ * simply omitted rather than sent null, so "we never captured one" stays
428
+ * distinguishable from "the provider issued none".
429
+ */
430
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
325
431
  }),
326
432
  });
327
433
 
@@ -343,20 +449,8 @@ export async function exchangeOAuthForIdpTokens(
343
449
  }
344
450
 
345
451
  // Build IDP token data
346
- // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
347
- // !hasRefreshToken was a reliable "2FA required" signal.
348
- //
349
- // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
350
- // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
351
- // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
352
- // bypassing the 2FA gate entirely.
353
- //
354
- // FIX: Always set mfaVerified=false on the initial OAuth exchange.
355
- // The tenant's require_2fa setting is enforced by the viability endpoint
356
- // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
357
- // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
358
- // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
359
- // IDP correctly withholds refresh_token until post-2FA.
452
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
453
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
360
454
  const hasRefreshToken = !!result.refresh_token;
361
455
  const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
362
456
  const idpTokenData = {
@@ -370,21 +464,21 @@ export async function exchangeOAuthForIdpTokens(
370
464
  name: result.user?.full_name || result.user?.name || result.name || name,
371
465
  image: image,
372
466
  roles: result.user?.roles || result.roles || [],
373
- mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
467
+ mfaVerified: !requiresTwoFactor,
374
468
  idpClientId: result.client_id ? String(result.client_id) : undefined,
375
469
  merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
376
470
  };
377
471
 
378
472
  // Store in BA Redis session (for decodeSession)
379
473
  baData.idpTokens = idpTokenData;
380
- await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
474
+ await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
381
475
 
382
476
  // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
383
477
  // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
384
478
  try {
385
479
  const { getSessionPrefix } = await import('../lib/app-slug');
386
480
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
387
- await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
481
+ await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
388
482
  ...idpTokenData,
389
483
  oauthProvider: provider,
390
484
  }));
@@ -419,6 +513,12 @@ export async function exchangeOAuthForIdpTokens(
419
513
  */
420
514
  export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
421
515
  return async function GET(request: Request): Promise<Response> {
516
+ // Card 138635: establish the request-scoped carrier for the provider's raw
517
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
518
+ // hook that sees the token) and the exchange below (which forwards it).
519
+ // Wrapping the whole handler is what makes "never persisted" achievable —
520
+ // the token exists only for the life of this request.
521
+ return idTokenScope.run({}, async () => {
422
522
  const ba = await getBetterAuthHandler();
423
523
  if (!ba) {
424
524
  return new Response('Auth handler not configured', { status: 500 });
@@ -463,5 +563,6 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
463
563
  }
464
564
 
465
565
  return response;
566
+ });
466
567
  };
467
568
  }
@@ -1,3 +1,3 @@
1
- // src/auth/unauthenticated-routes.ts
2
- // Re-exporting from route-config for backward compatibility
1
+ // src/auth/unauthenticated-routes.ts
2
+ // Re-exporting from route-config for backward compatibility
3
3
  export * from './route-config';