@payez/next-mvp 4.1.21 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +120 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -55,6 +55,27 @@ export async function GET(req: NextRequest) {
55
55
  hasAccessToken: !!session?.idpAccessToken,
56
56
  });
57
57
 
58
+ // A Better Auth cookie is NOT authorization. The Redis session is the
59
+ // authoritative record of what this user is allowed to do; without it we
60
+ // have no roles and no IDP access token, so there is nothing to authorize
61
+ // against. Returning a populated user here produces the worst failure mode
62
+ // available: a client that renders as signed-in and is not.
63
+ //
64
+ // MUST return empty {} for the same reason as line 34 — useSession() treats
65
+ // ANY non-empty response object as "authenticated".
66
+ //
67
+ // NOTE: an MFA-pending session IS present in Redis (it just has
68
+ // mfaVerified=false), so this gate does not break /verify-code. Gating on a
69
+ // missing accessToken instead WOULD break it.
70
+ if (!session) {
71
+ console.error('[SESSION_ROUTE] Redis session MISS — refusing to report authenticated', {
72
+ userId: authSession.user?.id,
73
+ redisSessionId: redisSessionId ? redisSessionId.substring(0, 8) + '...' : 'MISSING',
74
+ reason: redisSessionId ? 'session expired or evicted' : 'no session token on auth session',
75
+ });
76
+ return NextResponse.json({});
77
+ }
78
+
58
79
  // Return session format with Redis data
59
80
  // useSession() expects: { user: {...}, expires: "..." }
60
81
  // We enrich with all session data from Redis
@@ -96,13 +117,62 @@ export async function GET(req: NextRequest) {
96
117
  }
97
118
 
98
119
  /**
99
- * POST /api/auth/session - Update session data
120
+ * The fields this route USED to accept from the request body and must never accept
121
+ * again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
122
+ *
123
+ * `twoFactorComplete` was written straight through to `mfaVerified`, so a caller
124
+ * holding any session supplied its OWN second-factor verdict - no factor presented,
125
+ * no comparison against `amr`, no server-side derivation. The caller can be PRE-2FA,
126
+ * which makes this a 2FA bypass rather than a user rewriting a flag he had earned.
100
127
  *
101
- * Allows updating session metadata (not tokens).
102
- * Token refresh should use the /api/auth/refresh endpoint.
128
+ * The package already states the invariant in four places, in its own voice:
129
+ * api-handlers/mfa/totp-verify.ts - "Enrolment must never set mfaVerified."
130
+ * api-handlers/auth/refresh.ts - "mfaVerified is derived from amrClaims"
131
+ * auth/better-auth.ts - "computed fresh ... re-derived fresh"
132
+ * (consumer) passkey-signin-plugin - "mfaVerified is DERIVED from amr, never asserted."
133
+ * Every other writer in the estate derives it. This one route took it from an HTTP body.
134
+ *
135
+ * PRESENCE, NOT TRUTHINESS, is what is refused - `twoFactorComplete: false` is rejected
136
+ * exactly like `true`. A caller must not be able to drive this field in EITHER direction,
137
+ * and per R486/R908 an absent signal is "cannot determine", never a negative assertion.
138
+ *
139
+ * REFUSED LOUDLY RATHER THAN IGNORED SILENTLY. Dropping the field would leave a caller
140
+ * believing it had recorded a 2FA completion that never happened - the same failure shape
141
+ * as the 4.1.14 -> 4.1.31 history below, where a write nobody read looked like it worked.
142
+ *
143
+ * *** DO NOT "FIX" A REGRESSION HERE BY REVERTING THE KEY NORMALIZATION (312092 s2). ***
144
+ * 4.1.14 had this same unvalidated write but stored bare `accessToken`/`twoFactorComplete`,
145
+ * keys no reader consulted, so the forgery landed where nothing looked. The 4.1.31
146
+ * normalization did not introduce the bug - IT ARMED IT. Reverting re-hides the hole and
147
+ * restores two real client-facing bugs. The write is authorized here instead.
148
+ */
149
+ const CALLER_ASSERTED_FIELDS: ReadonlyArray<{ field: string; why: string }> = [
150
+ { field: 'twoFactorComplete', why: 'the second-factor verdict is derived from the IDP token (amr), never asserted by the caller' },
151
+ { field: 'mfaVerified', why: 'the second-factor verdict is derived from the IDP token (amr), never asserted by the caller' },
152
+ { field: 'twoFactorMethod', why: 'the method is recorded by the server-side verification path that observed the factor' },
153
+ { field: 'access_token', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
154
+ { field: 'refresh_token', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
155
+ { field: 'idpAccessToken', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
156
+ { field: 'idpRefreshToken', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
157
+ ];
158
+
159
+ /**
160
+ * POST /api/auth/session - Update session METADATA
161
+ *
162
+ * Updates session metadata only. This route holds no authority over authentication
163
+ * state: it cannot set the 2FA verdict and it cannot set tokens.
164
+ *
165
+ * Token refresh uses POST /api/auth/refresh. The 2FA verdict is written only by the
166
+ * server-side verification path that actually observed the factor.
103
167
  *
104
168
  * Body:
105
169
  * - metadata: object - Custom metadata to store in session
170
+ *
171
+ * Any of CALLER_ASSERTED_FIELDS present in the body is a 400. The docstring above used
172
+ * to promise "metadata (not tokens)" while the handler wrote idpAccessToken,
173
+ * idpRefreshToken AND mfaVerified - that false parenthetical is the ONLY caution a
174
+ * mounter reads, and it is how this route came to be mounted (318272). The promise is
175
+ * now enforced by the code rather than asserted by the comment.
106
176
  */
107
177
  export async function POST(req: NextRequest) {
108
178
  try {
@@ -124,7 +194,25 @@ export async function POST(req: NextRequest) {
124
194
  }
125
195
 
126
196
  const body = await req.json();
127
- const { metadata, access_token, refresh_token, twoFactorComplete, twoFactorMethod } = body;
197
+
198
+ // Refuse before reading the session: a caller must not learn whether its forged
199
+ // field would have landed, and nothing should be written on a rejected request.
200
+ const asserted = CALLER_ASSERTED_FIELDS.filter(
201
+ ({ field }) => body != null && Object.prototype.hasOwnProperty.call(body, field)
202
+ );
203
+ if (asserted.length > 0) {
204
+ console.warn('[SESSION_ROUTE] REFUSED caller-asserted authentication state', {
205
+ fields: asserted.map(({ field }) => field),
206
+ userId: authSession.user?.id,
207
+ });
208
+ return NextResponse.json({
209
+ error: 'This route updates session metadata only. It cannot set authentication state.',
210
+ code: 'CALLER_ASSERTED_AUTH_STATE',
211
+ fields: asserted.map(({ field, why }) => ({ field, why })),
212
+ }, { status: 400 });
213
+ }
214
+
215
+ const { metadata } = body;
128
216
 
129
217
  // Get current session from Redis
130
218
  const session = await getRedisSession(sessionToken);
@@ -135,13 +223,14 @@ export async function POST(req: NextRequest) {
135
223
  }, { status: 404 });
136
224
  }
137
225
 
138
- // Update session with new data
226
+ // Update session with new data.
227
+ //
228
+ // METADATA ONLY. The token and 2FA-verdict writes that used to live here were
229
+ // removed, not merely guarded: see CALLER_ASSERTED_FIELDS above. Anything that
230
+ // needs to write authentication state must do so from a path that OBSERVED the
231
+ // fact - the IDP exchange for tokens, the verification path for the 2FA verdict.
139
232
  const updatedSession = {
140
233
  ...session,
141
- ...(access_token ? { accessToken: access_token } : {}),
142
- ...(refresh_token ? { refreshToken: refresh_token } : {}),
143
- ...(typeof twoFactorComplete === 'boolean' ? { twoFactorComplete } : {}),
144
- ...(twoFactorMethod ? { twoFactorMethod } : {}),
145
234
  ...(metadata ? {
146
235
  metadata: {
147
236
  ...(session.metadata || {}),
@@ -3,6 +3,28 @@
3
3
  *
4
4
  * Returns client auth settings for pre-login pages (signup, login).
5
5
  * Does NOT require authentication - these are public client settings.
6
+ *
7
+ * ── 257255: THIS ROUTE USED TO FABRICATE AN AUTHORIZATION POLICY ───────────
8
+ * It returned `success: true` with a hardcoded policy on ANY failure, so a
9
+ * caller could not distinguish "the IDP says X" from "I never reached the
10
+ * IDP". On 2026-08-24 it served require2FA=true / ["email","sms"] for hours
11
+ * while the database said require_2fa=false / ["emailcode","totp"] - wrong
12
+ * about WHETHER 2FA applied and WHICH factors existed, simultaneously and
13
+ * confidently. Six agents spent a night measuring policy with this instrument.
14
+ *
15
+ * Worse, the alarm it swallowed was CORRECT: idp-client-config throws
16
+ * specifically to stop an empty secret reaching betterAuth() and being
17
+ * silently replaced by the PUBLIC DEFAULT_SECRET. This route caught that and
18
+ * returned success.
19
+ *
20
+ * BAPert ruling v32: THE ENDPOINT HARD-FAILS, THE PAGE DEGRADES.
21
+ * QAPert's decisive measurement: the SAME client is require_2fa=TRUE on AKS
22
+ * and FALSE on dev-93, so ANY hardcoded fallback is provably wrong in one
23
+ * environment by construction. There is no safe default because the value is
24
+ * per-client per-environment DATA.
25
+ *
26
+ * The positive control already existed in this package: /api/session/viability
27
+ * surfaces this same fault honestly. These two routes now agree.
6
28
  */
7
29
 
8
30
  import { NextResponse } from 'next/server';
@@ -11,65 +33,145 @@ import { getIDPClientConfig } from '../../lib/idp-client-config';
11
33
  export interface PublicAuthSettings {
12
34
  // OAuth providers enabled for this client
13
35
  enabledProviders: string[];
14
-
15
- // Registration settings
16
- allowPublicRegistration: boolean;
17
36
  allowFederatedLogin: boolean;
18
37
 
19
- // Password settings
20
- enablePasswordReset: boolean;
21
-
22
38
  // 2FA settings (public - no secrets)
23
39
  require2FA: boolean;
24
40
  allowed2FAMethods: string[];
41
+
42
+ // allowPublicRegistration / enablePasswordReset: OPTIONAL, never invented.
43
+ //
44
+ // 257255 removed these outright on the measurement that AuthSettings
45
+ // "carries no such fields, so they cannot be read today", and left one
46
+ // instruction: "Re-add them ONLY when the config document actually carries
47
+ // them." That precondition is now MET. The 240447 fix landed on master
48
+ // after 257255 was written and taught the loader to read them - AuthSettings
49
+ // now declares allowPublicRegistration?/enablePasswordReset? and
50
+ // idp-client-config maps them from auth_settings.allow_public_registration /
51
+ // enable_password_reset at BOTH construction sites. Re-adding them here is
52
+ // that instruction being followed, not overridden: deleting them now would
53
+ // discard a real measurement the loader already performs.
54
+ //
55
+ // They are OPTIONAL rather than defaulted, which is the part 240447 got
56
+ // wrong. Its `?? true` was defended as "preserves today's behaviour ...
57
+ // cannot regress an older IDP build", but `true` is a PERMISSION CLAIM about
58
+ // a tenant we did not successfully ask - the same defect class this card
59
+ // exists to kill, and the exact literal named in its title
60
+ // (allowPublicRegistration:TRUE). Absent means the IDP did not say; a
61
+ // consumer must then decide, and cannot mistake a guess for a policy.
62
+ //
63
+ // They are NOT validated into a hard 503 like require2FA below, because an
64
+ // older IDP build may legitimately omit them. Absent-and-honest is correct
65
+ // there; absent-and-fabricated is not.
66
+ allowPublicRegistration?: boolean;
67
+ enablePasswordReset?: boolean;
68
+ }
69
+
70
+ /** Stable, machine-readable failure codes. Consumers branch on these. */
71
+ export type AuthSettingsErrorCode = 'IDP_UNREACHABLE' | 'IDP_CONFIG_INVALID';
72
+
73
+ /**
74
+ * Fail closed and LOUD. 503 + success:false + a code, and CRUCIALLY no policy
75
+ * fields at all - not defaults, not partials. A consumer cannot mistake an
76
+ * absent body for an answer, which is exactly the mistake the old shape invited.
77
+ */
78
+ function policyUnavailable(code: AuthSettingsErrorCode, message: string) {
79
+ return NextResponse.json(
80
+ { success: false, error: { code, message } },
81
+ { status: 503, headers: { 'Cache-Control': 'no-store' } },
82
+ );
25
83
  }
26
84
 
27
85
  /**
28
86
  * GET /api/auth/settings
29
87
  *
30
- * Returns public auth settings for the current client.
31
- * Used by login/signup pages to determine what options to show.
88
+ * Returns public auth settings for the current client, or 503 if the real
89
+ * policy could not be read. It never answers from a default.
32
90
  */
33
91
  export async function GET() {
92
+ let config;
34
93
  try {
35
- const config = await getIDPClientConfig();
94
+ config = await getIDPClientConfig();
95
+ } catch (error) {
96
+ // Keep the loud line - it is what finally unmasked this in production.
97
+ console.error('[AUTH_SETTINGS] Failed to get settings:', error);
98
+ // The `degraded: true` shape that used to live here (240447) is gone
99
+ // deliberately. It still answered `success: true` with a full invented
100
+ // policy and merely LABELLED the guess - which BAPert v32 rules out:
101
+ // THE ENDPOINT HARD-FAILS, THE PAGE DEGRADES. A flag a consumer may
102
+ // ignore is not a failure; 503 with no policy fields is.
103
+ return policyUnavailable(
104
+ 'IDP_UNREACHABLE',
105
+ 'Could not read client configuration from the identity service.',
106
+ );
107
+ }
108
+
109
+ // ── THE CONDITION IS "DID I READ A VALIDATED POLICY", NOT "DID THE FETCH
110
+ // THROW". This distinction is load-bearing and is why the catch above is
111
+ // not sufficient on its own (QAPert, and card 257661).
112
+ //
113
+ // getIDPClientConfig caches BEFORE it validates, and the cache-hit paths
114
+ // do NOT re-validate. So it can RETURN NORMALLY - no throw, no log line -
115
+ // with a config that FAILED validation on the request that cached it.
116
+ // Measured on :4702 on 2026-08-24: the loader was throwing 53 times per
117
+ // 400 log lines while THIS endpoint served a well-formed policy from
118
+ // cache. A fix that only handles the throw is bypassed by the cache on
119
+ // every request after the first.
120
+ //
121
+ // authSecret is the invariant the loader itself enforces on the fetch
122
+ // path (idp-client-config: "LOAD-BEARING - empty must fail here, never
123
+ // downstream"). Re-checking it here is what makes a cache hit as safe as
124
+ // a fresh read.
125
+ if (!config?.authSecret) {
126
+ console.error(
127
+ '[AUTH_SETTINGS] Refusing to serve policy: config has no authSecret ' +
128
+ '(likely served from cache before validation - see card 257661).',
129
+ );
130
+ return policyUnavailable(
131
+ 'IDP_CONFIG_INVALID',
132
+ 'Client configuration is incomplete; refusing to report a policy.',
133
+ );
134
+ }
36
135
 
37
- const settings: PublicAuthSettings = {
38
- // Get enabled OAuth provider names
39
- enabledProviders: config.oauthProviders
40
- ?.filter(p => p.enabled)
41
- .map(p => p.provider) ?? [],
136
+ // No `??` defaults. If the policy section is absent or malformed we did not
137
+ // read a policy, and inventing one is the defect this card exists for.
138
+ const authSettings = config.authSettings;
139
+ if (
140
+ !authSettings ||
141
+ typeof authSettings.require2FA !== 'boolean' ||
142
+ !Array.isArray(authSettings.allowed2FAMethods)
143
+ ) {
144
+ console.error(
145
+ '[AUTH_SETTINGS] Refusing to serve policy: authSettings absent or malformed.',
146
+ );
147
+ return policyUnavailable(
148
+ 'IDP_CONFIG_INVALID',
149
+ 'Client configuration carried no usable auth policy.',
150
+ );
151
+ }
42
152
 
43
- // Registration - default to true if not specified
44
- allowPublicRegistration: true, // Could come from config.authSettings in future
45
- allowFederatedLogin: config.oauthProviders?.some(p => p.enabled) ?? false,
153
+ const settings: PublicAuthSettings = {
154
+ enabledProviders: config.oauthProviders?.filter(p => p.enabled).map(p => p.provider) ?? [],
46
155
 
47
- // Password reset
48
- enablePasswordReset: true, // Could come from config.authSettings in future
156
+ // Prefer the EXPLICIT policy field over inferring from the provider
157
+ // list (240447). Inferring is only a proxy: a tenant can have federated
158
+ // login switched off while enabled providers are still configured, and
159
+ // the inference reports `true` for it. Fall back to the inference only
160
+ // where the IDP does not state the field.
161
+ allowFederatedLogin:
162
+ authSettings.allowSocialLogin ??
163
+ (config.oauthProviders?.some(p => p.enabled) ?? false),
49
164
 
50
- // 2FA
51
- require2FA: config.authSettings?.require2FA ?? true,
52
- allowed2FAMethods: config.authSettings?.allowed2FAMethods ?? ['email', 'sms'],
53
- };
165
+ // Real reads, no `??` literal. Absent stays absent (see interface).
166
+ allowPublicRegistration: authSettings.allowPublicRegistration,
167
+ enablePasswordReset: authSettings.enablePasswordReset,
54
168
 
55
- return NextResponse.json({
56
- success: true,
57
- data: settings,
58
- });
59
- } catch (error) {
60
- console.error('[AUTH_SETTINGS] Failed to get settings:', error);
169
+ require2FA: authSettings.require2FA,
170
+ allowed2FAMethods: authSettings.allowed2FAMethods,
171
+ };
61
172
 
62
- // Return safe defaults on error
63
- return NextResponse.json({
64
- success: true,
65
- data: {
66
- enabledProviders: [],
67
- allowPublicRegistration: true,
68
- allowFederatedLogin: false,
69
- enablePasswordReset: true,
70
- require2FA: true,
71
- allowed2FAMethods: ['email', 'sms'],
72
- } as PublicAuthSettings,
73
- });
74
- }
173
+ return NextResponse.json(
174
+ { success: true, data: settings },
175
+ { headers: { 'Cache-Control': 'no-store' } },
176
+ );
75
177
  }
@@ -74,21 +74,61 @@ async function getTenantRequiresTwoFactor(): Promise<boolean> {
74
74
  * nullable require_2fa fix: "absent" has to be expressible, or callers cannot
75
75
  * tell a real value from a default.
76
76
  *
77
- * No local default: `allowed2FAMethods` is non-optional on AuthSettings and is
78
- * populated on BOTH config construction paths (idp-client-config.ts:333, :514)
79
- * with the tenant's value or the layer's own ['email','sms']. Repeating that
80
- * default here would be a second source for one setting, free to drift.
77
+ * No local default. 257255 UPDATE: allowed2FAMethods is now OPTIONAL on
78
+ * AuthSettings - the config mapper no longer substitutes ['email','sms'] when
79
+ * the document carries no policy, because that made "no policy" and "exactly
80
+ * these methods" indistinguishable. So absent now correctly reaches this
81
+ * function as undefined and is reported as UNKNOWN (null), which is what this
82
+ * function's own contract already said it wanted. Before the change it would
83
+ * have returned the mapper's fabrication AS IF IT WERE THE TENANT'S VALUE.
81
84
  */
82
85
  async function getTenantAllowed2FAMethods(): Promise<string[] | null> {
83
86
  try {
84
87
  const config = await getIDPClientConfig();
85
- return config.authSettings.allowed2FAMethods;
88
+ // `?? null` is the whole point: undefined means the document carried no
89
+ // policy, and this function reports UNKNOWN rather than guessing.
90
+ return config.authSettings.allowed2FAMethods ?? null;
86
91
  } catch {
87
92
  console.warn('[VIABILITY] Could not get client config — reporting allowed 2FA methods as UNKNOWN (null), not guessing');
88
93
  return null;
89
94
  }
90
95
  }
91
96
 
97
+ /**
98
+ * Resolve the client id reported to the caller.
99
+ *
100
+ * Was: `session?.idpClientId || process.env.IDP_CLIENT_ID || ''`. Two defects,
101
+ * both of the "report a confident value where the truth is unknown" family that
102
+ * card 221076 was about:
103
+ *
104
+ * 1. `|| ''` made "we do not know this session's client" indistinguishable from
105
+ * "the client id is blank". Callers cannot tell absence from a known-empty
106
+ * value, so nothing can react to the former. Absence is now `null`, which is
107
+ * representable — and still falsy, so existing `if (!clientId)` guards behave
108
+ * exactly as before.
109
+ *
110
+ * 2. The env fallback SILENTLY substitutes the deployment's default client for a
111
+ * session whose own client is unknown. On a multi-tenant IDP that is a
112
+ * cross-tenant wrong answer, not a default. It is kept for single-tenant
113
+ * deployments that rely on it, but it now says so in the log rather than
114
+ * passing the substitution off as the session's own identity.
115
+ */
116
+ function resolveClientId(sessionClientId: string | undefined): string | null {
117
+ if (sessionClientId) return sessionClientId;
118
+
119
+ const envClientId = process.env.IDP_CLIENT_ID;
120
+ if (envClientId) {
121
+ console.warn(
122
+ '[VIABILITY_ROUTE] session carries no idpClientId - substituting IDP_CLIENT_ID ' +
123
+ 'from the environment. On a multi-tenant deployment this is a DIFFERENT client ' +
124
+ 'than the session belongs to.'
125
+ );
126
+ return envClientId;
127
+ }
128
+
129
+ return null;
130
+ }
131
+
92
132
  /**
93
133
  * GET /api/session/viability - Check if session is viable
94
134
  *
@@ -165,9 +205,87 @@ export async function GET(req: NextRequest) {
165
205
  // require_2fa case: absent has to stay expressible.
166
206
  const nextStep = session?.nextStep;
167
207
 
208
+ /**
209
+ * 266007 — port the sibling's CRITICAL override (api-handlers/session/
210
+ * viability.ts:82-86) into this, the impl consumers actually MOUNT.
211
+ * An OAuth session whose callback set mfaVerified === false because no
212
+ * refresh_token was issued ALWAYS needs 2FA completion, whatever the
213
+ * tenant config says — completing 2FA is how it obtains one.
214
+ *
215
+ * STRICT === false, deliberately: an ABSENT mfaVerified (every session
216
+ * minted before that field existed) is not a signal, and treating it as
217
+ * one would force-rechallenge the entire existing session population.
218
+ *
219
+ * *** R807 (BAPert, card 362550) - WHY THE `=== false` TEST ALONE NO LONGER
220
+ * CARRIES THAT INTENT, AND WHAT RESTORES IT. ***
221
+ *
222
+ * The intent above is right, but by the time a session reaches here the
223
+ * distinction it depends on IS ALREADY GONE: every construction site
224
+ * (session-store, decode-session, token-lifecycle x2, SessionModel) collapses
225
+ * an absent mfaVerified to `false` via `?? false`. So `=== false` had stopped
226
+ * meaning "the callback affirmatively said false" and had come to mean
227
+ * "affirmatively false OR never knew" - i.e. the exact mass re-challenge this
228
+ * comment says it is preventing. The guard read as strict while behaving as
229
+ * permissive, because the collapse happens upstream and out of sight.
230
+ *
231
+ * R807 keeps `mfaVerified` fail-closed at all five sites (removing `?? false`
232
+ * would be an AUTH BYPASS - a session that never proved MFA read as having
233
+ * proved it) and gives THIS question its own signal instead. So the test is
234
+ * now two parts:
235
+ *
236
+ * mfaSignalPresent === true -> the producer really did emit a value, so the
237
+ * `false` below is a MEASUREMENT. Fire.
238
+ * absent / false -> LEGACY session (minted before the signal
239
+ * existed) or a path with no IDP tokens at all.
240
+ * NOT a signal. Do NOT force a re-challenge.
241
+ *
242
+ * That absent-means-do-not-fire read is the migration behaviour, and it is
243
+ * what preserves this comment's original guarantee verbatim through rollout:
244
+ * on the day this ships, EVERY existing session has no signal and none of them
245
+ * is re-challenged.
246
+ *
247
+ * *** AND IT IS SAFE ONLY BECAUSE IT IS CONFINED HERE. *** mfaSignalPresent is
248
+ * permissive by construction - it can only ever SUPPRESS this override. It must
249
+ * never reach a path that grants access; gate those on `mfaVerified`, which is
250
+ * still fail-closed everywhere. R807 is void if this signal escapes into an
251
+ * auth decision.
252
+ *
253
+ * Declared once here, like nextStep above, so the three authenticated
254
+ * responses cannot drift apart. Feeds the legacy `requires2FA` field the
255
+ * middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
256
+ * it names TENANT policy, and this override is not tenant state.
257
+ */
258
+ const oauthSessionNeedsTwoFactor =
259
+ session?.mfaSignalPresent === true &&
260
+ session?.mfaVerified === false &&
261
+ !(session.idpRefreshToken || (session as any)?.refreshToken);
262
+
168
263
  // Check access token expiry
169
264
  const now = Math.floor(Date.now() / 1000);
170
- const accessTokenExpires = (token as any).accessTokenExpires || token.exp;
265
+ // 240654 addendum 2: fall back to the Redis session's own expiry.
266
+ //
267
+ // The first two reads come off the Better Auth session OBJECT. A Path-B
268
+ // synthetic session — the shape server/auth.ts builds for a legacy JWS
269
+ // session — carries NEITHER: it is `{session:{token}, user:{...}}` with no
270
+ // accessTokenExpires and no exp. So every legacy session fell into the
271
+ // "no expiry info" branch below and answered needsRefresh:true on EVERY
272
+ // viability read from the moment of login, while the token was in fact
273
+ // fresh (15-minute TTL measured). Benign for the viable/authenticated
274
+ // verdict, but it recommends a refresh constantly, which is pure perf
275
+ // noise and multiplies exposure to the empty-clientId refresh path (223542).
276
+ //
277
+ // lib/session-store puts the real value on the session as
278
+ // idpAccessTokenExpires, in MILLISECONDS (better-auth.ts:784 sets
279
+ // `Date.now() + expires_in * 1000`). The other two sources are in SECONDS.
280
+ // Mixing units here would be a silent disaster, so note that the
281
+ // normalisation below (`> 1e12 ? /1000 : as-is`) already discriminates
282
+ // them by magnitude: a seconds timestamp is ~1.7e9, a milliseconds one
283
+ // ~1.7e12. This fallback is therefore safe in either unit, and that
284
+ // existing normaliser is load-bearing for it -- do not remove it.
285
+ const accessTokenExpires =
286
+ (token as any).accessTokenExpires ||
287
+ token.exp ||
288
+ (session as any)?.idpAccessTokenExpires;
171
289
 
172
290
  if (!accessTokenExpires) {
173
291
  // No expiry info, assume viable but recommend refresh
@@ -199,7 +317,7 @@ export async function GET(req: NextRequest) {
199
317
  tenantAllowed2FAMethods,
200
318
  nextStep,
201
319
  // Legacy field names for backwards compatibility
202
- requires2FA: tenantRequiresTwoFactor,
320
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
203
321
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
204
322
  accessTokenExpired: false,
205
323
  reason: 'No expiry information'
@@ -269,13 +387,13 @@ export async function GET(req: NextRequest) {
269
387
  tenantAllowed2FAMethods,
270
388
  nextStep,
271
389
  // Legacy names for backwards compatibility
272
- requires2FA: tenantRequiresTwoFactor,
390
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
273
391
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
274
392
  accessTokenExpired: true,
275
393
  reason: 'Token expired',
276
394
  // RBAC fields
277
395
  roles: session?.roles || [],
278
- clientId: session?.idpClientId || process.env.IDP_CLIENT_ID || '',
396
+ clientId: resolveClientId(session?.idpClientId),
279
397
  });
280
398
  }
281
399
 
@@ -293,13 +411,13 @@ export async function GET(req: NextRequest) {
293
411
  tenantAllowed2FAMethods,
294
412
  nextStep,
295
413
  // Legacy names for backwards compatibility
296
- requires2FA: tenantRequiresTwoFactor,
414
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
297
415
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
298
416
  accessTokenExpired: false,
299
417
  expiresAt: new Date(expiryTime * 1000).toISOString(),
300
418
  // RBAC fields
301
419
  roles: session?.roles || [],
302
- clientId: session?.idpClientId || process.env.IDP_CLIENT_ID || '',
420
+ clientId: resolveClientId(session?.idpClientId),
303
421
  });
304
422
  } catch (error) {
305
423
  console.error('[VIABILITY_ROUTE] Error checking session viability:', error);
@@ -1 +1 @@
1
- export { POST } from '../../api-handlers/mfa/backup-code-verify';
1
+ export { POST } from '../../api-handlers/mfa/backup-code-verify';
@@ -1,10 +1,10 @@
1
- /**
2
- * Ready-to-Use MFA Backup Codes Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/mfa/backup-codes/route.ts
7
- * export { POST } from '@payez/next-mvp/routes/mfa/backup-codes';
8
- * ```
9
- */
10
- export { POST } from '../../api-handlers/mfa/backup-codes';
1
+ /**
2
+ * Ready-to-Use MFA Backup Codes Route
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/mfa/backup-codes/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/backup-codes';
8
+ * ```
9
+ */
10
+ export { POST } from '../../api-handlers/mfa/backup-codes';
@@ -1,22 +1,22 @@
1
- /**
2
- * Ready-to-Use MFA Methods Route (list / remove)
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/mfa/methods/route.ts
7
- * export { GET } from '@payez/next-mvp/routes/mfa/methods';
8
- * // app/api/mfa/methods/[id]/route.ts
9
- * export { DELETE } from '@payez/next-mvp/routes/mfa/methods';
10
- * ```
11
- */
12
- import { NextRequest } from 'next/server';
13
- import { GET as listMethods, DELETE as deleteMethod } from '../../api-handlers/mfa/methods';
14
-
15
- export const GET = listMethods;
16
-
17
- type Ctx = { params: Promise<{ id: string }> };
18
-
19
- export async function DELETE(req: NextRequest, ctx: Ctx) {
20
- const { id } = await ctx.params;
21
- return deleteMethod(req, id);
22
- }
1
+ /**
2
+ * Ready-to-Use MFA Methods Route (list / remove)
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/mfa/methods/route.ts
7
+ * export { GET } from '@payez/next-mvp/routes/mfa/methods';
8
+ * // app/api/mfa/methods/[id]/route.ts
9
+ * export { DELETE } from '@payez/next-mvp/routes/mfa/methods';
10
+ * ```
11
+ */
12
+ import { NextRequest } from 'next/server';
13
+ import { GET as listMethods, DELETE as deleteMethod } from '../../api-handlers/mfa/methods';
14
+
15
+ export const GET = listMethods;
16
+
17
+ type Ctx = { params: Promise<{ id: string }> };
18
+
19
+ export async function DELETE(req: NextRequest, ctx: Ctx) {
20
+ const { id } = await ctx.params;
21
+ return deleteMethod(req, id);
22
+ }
@@ -1,10 +1,10 @@
1
- /**
2
- * Ready-to-Use TOTP Confirm Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/mfa/totp/confirm/route.ts
7
- * export { POST } from '@payez/next-mvp/routes/mfa/totp-confirm';
8
- * ```
9
- */
10
- export { POST } from '../../api-handlers/mfa/totp-confirm';
1
+ /**
2
+ * Ready-to-Use TOTP Confirm Route
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/mfa/totp/confirm/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/totp-confirm';
8
+ * ```
9
+ */
10
+ export { POST } from '../../api-handlers/mfa/totp-confirm';