@payez/next-mvp 4.1.11 → 4.1.20

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 (178) hide show
  1. package/dist/api-handlers/account/profile.d.ts +38 -0
  2. package/dist/api-handlers/account/profile.js +73 -45
  3. package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
  4. package/dist/api-handlers/account/recovery/send-code.js +52 -18
  5. package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
  6. package/dist/api-handlers/account/recovery/verify-code.js +52 -18
  7. package/dist/api-handlers/account/verify-email.js +15 -1
  8. package/dist/api-handlers/account/verify-sms.js +15 -1
  9. package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
  10. package/dist/api-handlers/auth/passkey-signin.js +97 -0
  11. package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
  12. package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
  13. package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
  14. package/dist/api-handlers/mfa/backup-codes.js +40 -0
  15. package/dist/api-handlers/mfa/methods.d.ts +3 -0
  16. package/dist/api-handlers/mfa/methods.js +41 -0
  17. package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
  18. package/dist/api-handlers/mfa/totp-confirm.js +41 -0
  19. package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
  20. package/dist/api-handlers/mfa/totp-enroll.js +41 -0
  21. package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
  22. package/dist/api-handlers/mfa/totp-verify.js +134 -0
  23. package/dist/api-handlers/passkeys/list.d.ts +7 -0
  24. package/dist/api-handlers/passkeys/list.js +29 -0
  25. package/dist/api-handlers/passkeys/manage.d.ts +3 -0
  26. package/dist/api-handlers/passkeys/manage.js +54 -0
  27. package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
  28. package/dist/api-handlers/passkeys/register-options.js +41 -0
  29. package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
  30. package/dist/api-handlers/passkeys/register-verify.js +40 -0
  31. package/dist/api-handlers/session/viability.js +19 -0
  32. package/dist/auth/better-auth.d.ts +68 -5
  33. package/dist/auth/better-auth.js +256 -42
  34. package/dist/auth/utils/idp-client.d.ts +13 -22
  35. package/dist/auth/utils/idp-client.js +12 -82
  36. package/dist/client/better-auth-client.d.ts +14 -2
  37. package/dist/lib/session-store.js +28 -21
  38. package/dist/lib/startup-init.js +19 -19
  39. package/dist/lib/token-lifecycle.js +40 -4
  40. package/dist/models/SessionModel.d.ts +17 -0
  41. package/dist/pages/login/page.d.ts +10 -1
  42. package/dist/pages/login/page.js +69 -80
  43. package/dist/pages/verify-code/page.d.ts +10 -1
  44. package/dist/pages/verify-code/page.js +72 -4
  45. package/dist/routes/auth/passkey-signin.d.ts +13 -0
  46. package/dist/routes/auth/passkey-signin.js +18 -0
  47. package/dist/routes/auth/viability.d.ts +2 -0
  48. package/dist/routes/auth/viability.js +88 -0
  49. package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
  50. package/dist/routes/mfa/backup-code-verify.js +5 -0
  51. package/dist/routes/mfa/backup-codes.d.ts +10 -0
  52. package/dist/routes/mfa/backup-codes.js +14 -0
  53. package/dist/routes/mfa/methods.d.ts +21 -0
  54. package/dist/routes/mfa/methods.js +10 -0
  55. package/dist/routes/mfa/totp-confirm.d.ts +10 -0
  56. package/dist/routes/mfa/totp-confirm.js +14 -0
  57. package/dist/routes/mfa/totp-enroll.d.ts +10 -0
  58. package/dist/routes/mfa/totp-enroll.js +14 -0
  59. package/dist/routes/mfa/totp-verify.d.ts +14 -0
  60. package/dist/routes/mfa/totp-verify.js +18 -0
  61. package/dist/routes/passkeys/list.d.ts +10 -0
  62. package/dist/routes/passkeys/list.js +14 -0
  63. package/dist/routes/passkeys/manage.d.ts +18 -0
  64. package/dist/routes/passkeys/manage.js +13 -0
  65. package/dist/routes/passkeys/register-options.d.ts +10 -0
  66. package/dist/routes/passkeys/register-options.js +14 -0
  67. package/dist/routes/passkeys/register-verify.d.ts +10 -0
  68. package/dist/routes/passkeys/register-verify.js +14 -0
  69. package/dist/server/auth.d.ts +2 -2
  70. package/dist/server/decode-session.js +37 -3
  71. package/package.json +958 -893
  72. package/scripts/check-internal-url-usage.sh +73 -73
  73. package/src/api/index.ts +17 -17
  74. package/src/api-handlers/account/masked-info.ts +45 -45
  75. package/src/api-handlers/account/profile.ts +99 -80
  76. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  77. package/src/api-handlers/account/recovery/send-code.ts +73 -25
  78. package/src/api-handlers/account/recovery/verify-code.ts +73 -25
  79. package/src/api-handlers/account/reset-password.ts +23 -23
  80. package/src/api-handlers/account/send-code.ts +76 -76
  81. package/src/api-handlers/account/validate-password.ts +118 -118
  82. package/src/api-handlers/account/verify-email.ts +15 -1
  83. package/src/api-handlers/account/verify-sms.ts +15 -1
  84. package/src/api-handlers/admin/stats.ts +249 -249
  85. package/src/api-handlers/anon/preferences.ts +123 -123
  86. package/src/api-handlers/auth/jwks.ts +20 -20
  87. package/src/api-handlers/auth/login.ts +240 -240
  88. package/src/api-handlers/auth/passkey-signin.ts +98 -0
  89. package/src/api-handlers/auth/validate.ts +43 -43
  90. package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
  91. package/src/api-handlers/mfa/backup-codes.ts +41 -0
  92. package/src/api-handlers/mfa/methods.ts +46 -0
  93. package/src/api-handlers/mfa/totp-confirm.ts +42 -0
  94. package/src/api-handlers/mfa/totp-enroll.ts +42 -0
  95. package/src/api-handlers/mfa/totp-verify.ts +156 -0
  96. package/src/api-handlers/passkeys/list.ts +31 -0
  97. package/src/api-handlers/passkeys/manage.ts +54 -0
  98. package/src/api-handlers/passkeys/register-options.ts +42 -0
  99. package/src/api-handlers/passkeys/register-verify.ts +41 -0
  100. package/src/api-handlers/session/viability.ts +161 -142
  101. package/src/auth/better-auth.ts +247 -6
  102. package/src/auth/unauthenticated-routes.ts +2 -2
  103. package/src/auth/utils/idp-client.ts +13 -95
  104. package/src/client/better-auth-client.ts +70 -70
  105. package/src/client/fetchWithSession.ts +21 -21
  106. package/src/client/index.ts +13 -13
  107. package/src/client/useAnonSession.ts +131 -131
  108. package/src/components/admin/VibeAdminContext.tsx +87 -87
  109. package/src/config/logging-config.ts +82 -82
  110. package/src/config/unauthenticated-routes.ts +19 -19
  111. package/src/edge/internal-api-url.ts +65 -65
  112. package/src/edge/middleware.ts +42 -42
  113. package/src/hooks/useProfile.ts +75 -75
  114. package/src/lib/anon-session.ts +213 -213
  115. package/src/lib/auth-secret.ts +137 -137
  116. package/src/lib/demo-mode.ts +17 -17
  117. package/src/lib/idp-client-config.ts +559 -559
  118. package/src/lib/idp-fetch.ts +126 -126
  119. package/src/lib/internal-api.ts +171 -171
  120. package/src/lib/jwt-decode-client.ts +45 -45
  121. package/src/lib/rate-limit-service.ts +9 -9
  122. package/src/lib/redis.ts +26 -26
  123. package/src/lib/refresh-token-validator.ts +64 -64
  124. package/src/lib/secret-validation.ts +8 -8
  125. package/src/lib/session-store.ts +702 -695
  126. package/src/lib/session.ts +34 -34
  127. package/src/lib/startup-init.ts +251 -251
  128. package/src/lib/token-expiry.ts +40 -40
  129. package/src/lib/token-lifecycle.ts +533 -497
  130. package/src/lib/types/api-responses.ts +336 -336
  131. package/src/middleware/auth-decision.ts +43 -43
  132. package/src/models/DecodedAccessToken.ts +16 -16
  133. package/src/models/SessionModel.ts +282 -264
  134. package/src/pages/admin-roles/index.ts +9 -9
  135. package/src/pages/login/page.tsx +123 -228
  136. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  137. package/src/pages/verify-code/page.tsx +71 -5
  138. package/src/routes/account/index.ts +31 -31
  139. package/src/routes/account/masked-info.ts +36 -36
  140. package/src/routes/account/send-code.ts +39 -39
  141. package/src/routes/account/verify-email.ts +40 -40
  142. package/src/routes/account/verify-sms.ts +40 -40
  143. package/src/routes/auth/index.ts +22 -22
  144. package/src/routes/auth/passkey-signin.ts +13 -0
  145. package/src/routes/auth/refresh.ts +48 -48
  146. package/src/routes/auth/session.ts +166 -166
  147. package/src/routes/auth/viability.ts +89 -0
  148. package/src/routes/index.ts +17 -17
  149. package/src/routes/mfa/backup-code-verify.ts +1 -0
  150. package/src/routes/mfa/backup-codes.ts +10 -0
  151. package/src/routes/mfa/methods.ts +22 -0
  152. package/src/routes/mfa/totp-confirm.ts +10 -0
  153. package/src/routes/mfa/totp-enroll.ts +10 -0
  154. package/src/routes/mfa/totp-verify.ts +14 -0
  155. package/src/routes/passkeys/list.ts +10 -0
  156. package/src/routes/passkeys/manage.ts +23 -0
  157. package/src/routes/passkeys/register-options.ts +10 -0
  158. package/src/routes/passkeys/register-verify.ts +10 -0
  159. package/src/routes/session/index.ts +7 -7
  160. package/src/routes/session/refresh-viability.ts +17 -17
  161. package/src/server/auth.ts +373 -373
  162. package/src/server/decode-session.ts +235 -202
  163. package/src/services/signalrActivityService.ts +258 -258
  164. package/src/theme/ThemeProvider.tsx +39 -39
  165. package/src/theme/default.ts +33 -33
  166. package/src/theme/index.ts +31 -31
  167. package/src/theme/types.ts +69 -69
  168. package/src/theme/useTheme.ts +57 -57
  169. package/src/theme/utils.ts +40 -40
  170. package/src/types/api.ts +12 -12
  171. package/src/types/auth.d.ts +23 -23
  172. package/src/types/auth.ts +22 -22
  173. package/src/types/logging.ts +11 -11
  174. package/src/types/recovery.ts +54 -54
  175. package/src/utils/api.ts +353 -353
  176. package/src/utils/circuitBreaker.ts +40 -40
  177. package/src/utils/error-message.ts +108 -108
  178. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,143 +1,162 @@
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
+ // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
121
+ //
122
+ // THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
123
+ // routes/auth/viability.ts, which is what the admin graft mounts.
124
+ // Both are publicly exported entry points, so both can be a consumer's
125
+ // only source of this answer. Adding the relay to just one would leave
126
+ // an app that mounts THIS one still blind to enroll_mfa — the exact
127
+ // seam these cards are about, reopened one export over.
128
+ //
129
+ // 'enroll_mfa' = no USABLE method, the user cannot complete a
130
+ // challenge. 'mfa_verify' = usable method exists. null = nothing due.
131
+ // NOT a completion signal: twoFactorComplete still owns that.
132
+ //
133
+ // No `?? null`: undefined (never captured — every session minted
134
+ // before this change) must stay distinguishable from null (the IDP
135
+ // said nothing is outstanding). Defaulting to null would tell the
136
+ // gate that every pre-existing session is fully authenticated,
137
+ // including zero-method users who must be routed to enrol.
138
+ nextStep: sessionData.nextStep,
139
+ // Token status for refresh decisions
140
+ accessTokenExpired,
141
+ hasRefreshToken: !!sessionData.idpRefreshToken
142
+ };
143
+ return NextResponse.json(response);
144
+ }
145
+
146
+ // CRITICAL: Cookie exists but Redis session is missing (stale cookie state)
147
+ // Return sessionToken so middleware can detect this and clear the stale cookie
148
+ console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
149
+ return NextResponse.json({
150
+ authenticated: false,
151
+ sessionToken // Include token to enable stale cookie detection
152
+ });
153
+ }
154
+
155
+ // If there's no token at all, it's not authenticated
156
+ return NextResponse.json({ authenticated: false });
157
+
158
+ } catch (error) {
159
+ console.error('[API Viability] Error checking session viability:', error);
160
+ return NextResponse.json({ authenticated: false }, { status: 500 });
161
+ }
143
162
  }
@@ -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';
@@ -35,6 +37,31 @@ export interface BetterAuthSocialProvider {
35
37
  /**
36
38
  * Build Better Auth social providers from IDP config.
37
39
  */
40
+ /**
41
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
42
+ *
43
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
44
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
45
+ * for the IDP to require a provider-signed id_token and verify it itself. This
46
+ * is the client half: capture the token Better Auth already receives, and
47
+ * forward it. Additive — the IDP ignores it until its gate flips.
48
+ *
49
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
50
+ * an id_token is credential material. Writing it to the session record would put
51
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
52
+ * and it would outlive the request that needed it. ALS scopes it to the single
53
+ * request, and it is gone when the request ends — nothing to expire, nothing to
54
+ * scrub, nothing to leak.
55
+ *
56
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
57
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
58
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
59
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
60
+ * function. Outside that scope the store is undefined and the body simply omits
61
+ * id_token, which is the correct additive behaviour.
62
+ */
63
+ const idTokenScope = new AsyncLocalStorage<{ idToken?: string }>();
64
+
38
65
  export function buildBetterAuthProviders(
39
66
  config: IDPClientConfig
40
67
  ): Record<string, BetterAuthSocialProvider> {
@@ -68,6 +95,66 @@ export function buildBetterAuthProviders(
68
95
  accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
69
96
  hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
70
97
  };
98
+
99
+ /**
100
+ * CAPTURE THE RAW id_token — card 138635, client half.
101
+ *
102
+ * getUserInfo is the ONLY provider hook that receives the token set.
103
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
104
+ * signed JWT cannot be reconstructed from them. We need the raw token
105
+ * because the IDP must verify the signature ITSELF — a client-decoded
106
+ * claim is just another assertable field, which is the defect restated.
107
+ *
108
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
109
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
110
+ * returns our result verbatim, so everything the default did is now ours to
111
+ * do — including the `hd` hosted-domain restriction.
112
+ *
113
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
114
+ * above, so a client can restrict sign-in to a Google Workspace domain.
115
+ * Silently dropping it would let ANY Google account into a client that had
116
+ * restricted itself — a security regression introduced by a security fix.
117
+ * (Measured on 93: zero clients set it today. That is a reason to be
118
+ * careful, not a reason to skip it — it is configurable and unused, which
119
+ * is exactly the state in which a regression goes unnoticed.)
120
+ *
121
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
122
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
123
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
124
+ * default would return true unconditionally, so omitting it is equivalent
125
+ * by the library's own definition rather than by our judgement.
126
+ *
127
+ * A client that DOES set hd keeps the stock provider, keeps its domain
128
+ * restriction, and simply does not contribute an id_token yet. Losing a
129
+ * capability is an acceptable cost; silently losing a security control is
130
+ * not. (better-auth does not re-export the predicate through its public
131
+ * types, and reimplementing a security check is how two copies drift — so
132
+ * the honest move is to not need it.)
133
+ */
134
+ if (name === 'google' && !providers[name].hd) {
135
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
136
+ if (!token?.idToken) return null;
137
+
138
+ // The only new behaviour: hand the raw token to this request's scope.
139
+ const scope = idTokenScope.getStore();
140
+ if (scope) scope.idToken = token.idToken;
141
+
142
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
143
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
144
+ // KEEP, never what we ACCEPT.
145
+ const claims = decodeJwt(token.idToken) as Record<string, any>;
146
+ return {
147
+ user: {
148
+ id: claims.sub,
149
+ name: claims.name,
150
+ email: claims.email,
151
+ image: claims.picture,
152
+ emailVerified: claims.email_verified,
153
+ },
154
+ data: claims,
155
+ };
156
+ };
157
+ }
71
158
  }
72
159
 
73
160
  return providers;
@@ -84,6 +171,27 @@ export function buildBetterAuthProviders(
84
171
  */
85
172
  export interface CreateBetterAuthInstanceOptions {
86
173
  magicLink?: MagicLinkOptions;
174
+ /**
175
+ * Extra Better Auth plugins supplied by the consuming app.
176
+ *
177
+ * WHY THIS EXISTS — session minting is not delegable any other way. BA's
178
+ * `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
179
+ * endpoint machinery constructs, so an app that must mint a session for an
180
+ * identity established OUTSIDE BA's built-in providers (passkey sign-in being
181
+ * the first) cannot do it from a route handler. It needs a real BA endpoint,
182
+ * and endpoints only arrive via plugins.
183
+ *
184
+ * Deliberately GENERIC. The package gains "an app may supply plugins" rather
185
+ * than any knowledge of passkeys — the alternative was teaching this file
186
+ * about a specific auth method, which is how a package accumulates a consumer's
187
+ * feature list.
188
+ *
189
+ * The alternative to this option is an app hand-rolling the session cookie
190
+ * (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
191
+ * Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
192
+ * so this option exists specifically to make that shortcut unnecessary.
193
+ */
194
+ plugins?: Parameters<typeof betterAuth>[0]['plugins'];
87
195
  }
88
196
 
89
197
  /**
@@ -183,6 +291,9 @@ export function createBetterAuthInstance(
183
291
  plugins: [
184
292
  nextCookies(),
185
293
  ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
294
+ // App-supplied plugins last: they may depend on the built-ins above, and
295
+ // ordering is BA's own concern once they are in the array.
296
+ ...(opts.plugins ?? []),
186
297
  ],
187
298
  });
188
299
  }
@@ -322,6 +433,30 @@ export async function exchangeOAuthForIdpTokens(
322
433
  name,
323
434
  image,
324
435
  client_id: config.clientSlug || String(config.clientId),
436
+ /**
437
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
438
+ *
439
+ * Every other field in this body is an ASSERTION. The endpoint mints an
440
+ * IDP bearer from them today with no cryptographic check of any kind,
441
+ * which is why it is a complete authentication bypass and why it has
442
+ * been publicly reachable for 488 days.
443
+ *
444
+ * This field is the only one the IDP can VERIFY: signature against
445
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
446
+ * from the verified claims. Additive — the IDP ignores it until its
447
+ * gate flips, so this cannot break a login on its own.
448
+ *
449
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
450
+ * decoded email_verified. A client-computed verification flag is a body
451
+ * field with a better story: an attacker posts email_verified:true and
452
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
453
+ * the fix is theatre.
454
+ *
455
+ * Absent when this runs outside the OAuth callback scope — the key is
456
+ * simply omitted rather than sent null, so "we never captured one" stays
457
+ * distinguishable from "the provider issued none".
458
+ */
459
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
325
460
  }),
326
461
  });
327
462
 
@@ -371,31 +506,130 @@ export async function exchangeOAuthForIdpTokens(
371
506
  image: image,
372
507
  roles: result.user?.roles || result.roles || [],
373
508
  mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
509
+ // Cards 141688/141741 — RELAYED, NOT DERIVED.
510
+ //
511
+ // The IDP already decides this (ExternalAuthController:943 and :2024,
512
+ // WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
513
+ // confirmed method exists, null = fully authenticated. It was emitted
514
+ // here and dropped on this line — `idpTokenData` is built field-by-field,
515
+ // so anything not named is silently discarded. Both downstream consumers
516
+ // (the middleware gate and the verify-code page) then reasoned in its
517
+ // absence and sent zero-method users to a page they cannot act on.
518
+ //
519
+ // WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
520
+ // BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
521
+ // `result.nextStep` here would be `undefined` forever and fail silently —
522
+ // the same camel/snake break `requires_two_factor` already cost us once.
523
+ //
524
+ // Do NOT recompute this from a method query. The IDP owns the fact; it is
525
+ // already usable-aware (confirmed and not revoked, per DotNetPert), which
526
+ // is the distinction that decides correctness: a revoked-TOTP user has
527
+ // count>0 but usable=0, and deriving it locally would let through exactly
528
+ // the users we must challenge.
529
+ // No `?? null`: if the response has no `next_step` at all (older IDP
530
+ // build, or a path that does not set it), that is UNKNOWN, not "fully
531
+ // authenticated". Defaulting it to null here would manufacture a
532
+ // clean-bill-of-health the IDP never gave and fail open at the gate.
533
+ nextStep: result.next_step,
374
534
  idpClientId: result.client_id ? String(result.client_id) : undefined,
375
535
  merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
376
536
  };
377
537
 
378
- // Store in BA Redis session (for decodeSession)
538
+ // Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
539
+ const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
540
+ if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
541
+ return stored;
542
+ } catch (err) {
543
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
544
+ return false;
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
550
+ * function never acquires tokens and never decides who the caller is.
551
+ *
552
+ * ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
553
+ *
554
+ * `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
555
+ * POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
556
+ * the second half is reusable, and the first half is actively dangerous to
557
+ * reuse:
558
+ *
559
+ * oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
560
+ * cryptographic verification of any kind (card 138635).
561
+ *
562
+ * Passkey sign-in already holds something strictly better — a grant issued by
563
+ * `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
564
+ * signature against a registered credential. Routing that through
565
+ * oauth-callback to obtain a session would DOWNGRADE a cryptographically
566
+ * verified assertion into an unverified one, make passkey sign-in weaker than
567
+ * its own ceremony, and inherit 138635 wholesale.
568
+ *
569
+ * That is not hypothetical: "do what the OAuth path does" is the obvious
570
+ * implementation and it is the wrong one. Splitting acquisition from storage is
571
+ * what makes the wrong one hard to write by accident.
572
+ *
573
+ * ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
574
+ *
575
+ * A Better Auth session must ALREADY EXIST for `sessionToken`. This function
576
+ * enriches; it does not create. Better Auth owns session creation and cookie
577
+ * signing, and nothing in this package should replicate either — the cookie is
578
+ * `{token}.{base64(hmac)}` with standard padding, signed with the runtime
579
+ * `authSecret` from Redis, and hand-rolling that produces a session which
580
+ * decodes but does not authenticate.
581
+ *
582
+ * @param sessionToken the Better Auth session token (from its Set-Cookie)
583
+ * @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
584
+ * verified route. Built field-by-field by the caller so an
585
+ * unnamed field cannot ride along silently.
586
+ * @param authProvider provenance label recorded on the canonical row. Written
587
+ * to the historical `oauthProvider` key, which predates
588
+ * non-OAuth callers; the NAME is legacy, the VALUE should
589
+ * state what actually authenticated ('passkey', 'google').
590
+ */
591
+ export async function storeIdpTokensInSession(
592
+ sessionToken: string,
593
+ idpTokenData: Record<string, unknown>,
594
+ authProvider: string,
595
+ ): Promise<boolean> {
596
+ try {
597
+ const config = await getIDPClientConfig();
598
+ const appSlug = config.clientSlug || getAppSlug();
599
+ const baKey = `ba:${appSlug}:${sessionToken}`;
600
+
601
+ const baRaw = await getRedis().get(baKey).catch(() => null);
602
+ if (!baRaw) {
603
+ // No session to enrich. Do NOT create one as a fallback: a session
604
+ // conjured here would carry no Better Auth cookie, so it would be
605
+ // unreachable by the browser and invisible to every other consumer —
606
+ // failing as "silently signed out" rather than as an error.
607
+ console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', sessionToken.substring(0, 10));
608
+ return false;
609
+ }
610
+
611
+ const baData = JSON.parse(baRaw);
379
612
  baData.idpTokens = idpTokenData;
380
613
  await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
381
614
 
382
- // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
383
- // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
615
+ // Canonical store — the key getSession() reads, used by the refresh handler
616
+ // and token lifecycle. A failure here leaves the BA session correct and the
617
+ // canonical row missing, so it is warned and not fatal: the user is signed
618
+ // in, and refresh degrades rather than the sign-in failing outright.
384
619
  try {
385
620
  const { getSessionPrefix } = await import('../lib/app-slug');
386
621
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
387
622
  await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
388
623
  ...idpTokenData,
389
- oauthProvider: provider,
624
+ oauthProvider: authProvider,
390
625
  }));
391
626
  } catch (canonicalErr) {
392
627
  console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
393
628
  }
394
629
 
395
- console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
396
630
  return true;
397
631
  } catch (err) {
398
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
632
+ console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
399
633
  return false;
400
634
  }
401
635
  }
@@ -419,6 +653,12 @@ export async function exchangeOAuthForIdpTokens(
419
653
  */
420
654
  export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
421
655
  return async function GET(request: Request): Promise<Response> {
656
+ // Card 138635: establish the request-scoped carrier for the provider's raw
657
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
658
+ // hook that sees the token) and the exchange below (which forwards it).
659
+ // Wrapping the whole handler is what makes "never persisted" achievable —
660
+ // the token exists only for the life of this request.
661
+ return idTokenScope.run({}, async () => {
422
662
  const ba = await getBetterAuthHandler();
423
663
  if (!ba) {
424
664
  return new Response('Auth handler not configured', { status: 500 });
@@ -463,5 +703,6 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
463
703
  }
464
704
 
465
705
  return response;
706
+ });
466
707
  };
467
708
  }
@@ -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';