@payez/next-mvp 4.1.21 → 4.4.1

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 +162 -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
@@ -1,10 +1,56 @@
1
1
  "use strict";
2
2
  /**
3
- * Test Environment Pages
3
+ * ⚠️ TEST-ENVIRONMENT DEBUG PAGES — NONE OF THESE BELONG IN PRODUCTION. ⚠️
4
4
  *
5
- * Export debug pages for MVP consumers.
5
+ * ────────────────────────── READ BEFORE COPYING ANYTHING BELOW (QAPert 35000) ──────────────────────────
6
+ * This barrel is the SPREAD MECHANISM. It ships compiled into dist and into
7
+ * the .d.ts, so it reaches every consuming developer's editor as hover text,
8
+ * and the block below is a four-line copy-paste that mounts every debug page
9
+ * this package has. It previously carried no warning at all.
6
10
  *
7
- * Usage:
11
+ * NONE of these pages has an env gate of its own. Unlike the force-expire
12
+ * handler (ENABLE_TEST_ROUTES) or the idp-token route
13
+ * (ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as your
14
+ * middleware allows. MOUNTING IS THE GATE — so gate the WHOLE /test-env
15
+ * segment, per environment, not page by page.
16
+ *
17
+ * They are not equally dangerous. In descending order:
18
+ * JwtInspectPage WORST. Copy-to-clipboard for the LIVE SESSION TOKEN
19
+ * and ACCESS TOKEN. A session token is exactly the
20
+ * precondition the 246007 finding needs.
21
+ * TestEnvPage The index. Harmless alone, but it ADVERTISES the
22
+ * other three to anyone who reaches it.
23
+ * RefreshTokenPage Drives the destructive /api/test/force-expire.
24
+ * EmergencyLogoutPage Mildest. Self-inflicted, recoverable.
25
+ *
26
+ * ── THESE PAGES ARE NOW ENV-GATED, FAIL-CLOSED (249259, BAPert ruling 35321) ──
27
+ * SUPERSEDES the previous note here, which said a gate was impossible and that
28
+ * mounting was the only control. That was correct about the PAGES and wrong as
29
+ * a conclusion: it CANNOT be done inside these `'use client'` files, but it CAN
30
+ * be done in a server component wrapping them. That wrapper now ships, and it
31
+ * is what the four names below resolve to — see ./guarded.tsx.
32
+ *
33
+ * ENABLE_TEST_ROUTES === 'true' -> the page renders
34
+ * anything else, including unset -> notFound()
35
+ *
36
+ * It is the DEFAULT, not an opt-in sibling: BAPert's ruling was explicit that
37
+ * "an opt-in safety gate is one nobody opts into". The raw ungated components
38
+ * are NOT exported. Same import path, same component names, same signatures —
39
+ * consumers add one env var to their dev environments and change no code.
40
+ *
41
+ * ── MOUNTING IS STILL THE PRIMARY GATE ──────────────────────────────────────
42
+ * Do not read the env var as making mounting safe. The wrapper is a backstop
43
+ * for a mount that should not have happened. With ENABLE_TEST_ROUTES=true in an
44
+ * environment where these are mounted you have exactly the exposure 248991
45
+ * documented: a live session token, one click from the clipboard. If you are
46
+ * not actively debugging, DO NOT MOUNT THESE AT ALL — the safest configuration
47
+ * is still their absence, and the env var does not change that.
48
+ *
49
+ * Each page's own docstring carries the detail. If you are not actively
50
+ * debugging, DO NOT MOUNT THESE AT ALL — the safest configuration is their
51
+ * absence.
52
+ *
53
+ * Usage — TEST/DEV ENVIRONMENTS ONLY:
8
54
  * ```typescript
9
55
  * // app/test-env/page.tsx
10
56
  * export { TestEnvPage as default } from '@payez/next-mvp/pages/test-env';
@@ -21,12 +67,12 @@
21
67
  */
22
68
  Object.defineProperty(exports, "__esModule", { value: true });
23
69
  exports.EmergencyLogoutPage = exports.RefreshTokenPage = exports.JwtInspectPage = exports.default = exports.TestEnvPage = void 0;
24
- var TestEnvPage_1 = require("./TestEnvPage");
25
- Object.defineProperty(exports, "TestEnvPage", { enumerable: true, get: function () { return TestEnvPage_1.TestEnvPage; } });
26
- Object.defineProperty(exports, "default", { enumerable: true, get: function () { return TestEnvPage_1.TestEnvPage; } });
27
- var JwtInspectPage_1 = require("./JwtInspectPage");
28
- Object.defineProperty(exports, "JwtInspectPage", { enumerable: true, get: function () { return JwtInspectPage_1.JwtInspectPage; } });
29
- var RefreshTokenPage_1 = require("./RefreshTokenPage");
30
- Object.defineProperty(exports, "RefreshTokenPage", { enumerable: true, get: function () { return RefreshTokenPage_1.RefreshTokenPage; } });
31
- var EmergencyLogoutPage_1 = require("./EmergencyLogoutPage");
32
- Object.defineProperty(exports, "EmergencyLogoutPage", { enumerable: true, get: function () { return EmergencyLogoutPage_1.EmergencyLogoutPage; } });
70
+ // GUARDED wrappers only. Exporting the raw components from './TestEnvPage'
71
+ // etc. would reintroduce an ungated path to the same pages under a different
72
+ // import, which is the "opt-in gate nobody opts into" the ruling rejected.
73
+ var guarded_1 = require("./guarded");
74
+ Object.defineProperty(exports, "TestEnvPage", { enumerable: true, get: function () { return guarded_1.TestEnvPage; } });
75
+ Object.defineProperty(exports, "default", { enumerable: true, get: function () { return guarded_1.TestEnvPage; } });
76
+ Object.defineProperty(exports, "JwtInspectPage", { enumerable: true, get: function () { return guarded_1.JwtInspectPage; } });
77
+ Object.defineProperty(exports, "RefreshTokenPage", { enumerable: true, get: function () { return guarded_1.RefreshTokenPage; } });
78
+ Object.defineProperty(exports, "EmergencyLogoutPage", { enumerable: true, get: function () { return guarded_1.EmergencyLogoutPage; } });
@@ -40,10 +40,11 @@ async function POST(req) {
40
40
  });
41
41
  }
42
42
  const sessionId = session.session?.token;
43
- // Delete session from store (this also removes the refresh token)
43
+ // Delete session from EVERY store — canonical + BA secondary (181922).
44
+ // Canonical-only deletion let the ba: record resurrect the session.
44
45
  if (sessionId) {
45
46
  try {
46
- await (0, session_store_1.deleteSession)(sessionId);
47
+ await (0, session_store_1.deleteSessionEverywhere)(sessionId);
47
48
  console.info('[LOGOUT_ROUTE] Session deleted from store');
48
49
  }
49
50
  catch (error) {
@@ -17,15 +17,41 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.POST = POST;
19
19
  const refresh_1 = require("../../api-handlers/auth/refresh");
20
+ /**
21
+ * Resolve the client id from environment variables.
22
+ *
23
+ * Was: `process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID || ''`
24
+ * That fallback to empty string made "we do not know the client" indistinguishable from
25
+ * "the client id is blank". Callers cannot tell absence from a known-empty value.
26
+ *
27
+ * Card 221076 / 223542: absence is now `null`, which is representable and still falsy,
28
+ * so existing `if (!clientId)` guards behave exactly as before.
29
+ * (Ported verbatim from qapert-nighthawk/221076, where this fix already existed;
30
+ * the qa/222363 line carried viability's half but never got refresh's.)
31
+ */
32
+ function resolveClientId() {
33
+ const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID;
34
+ if (clientId) {
35
+ return clientId;
36
+ }
37
+ return null;
38
+ }
20
39
  // Configuration is read at runtime from environment.
21
40
  function getConfig() {
22
41
  const idpBaseUrl = process.env.IDP_URL;
23
42
  if (!idpBaseUrl) {
24
43
  throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
25
44
  }
45
+ const clientId = resolveClientId();
46
+ if (!clientId) {
47
+ // Card 221076 / 223542: absence must be detectable, not collapsed to a default.
48
+ console.warn('[REFRESH_ROUTE] No CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID configured. ' +
49
+ 'Token refresh will be attempted, but the server cannot identify which client ' +
50
+ 'initiated the request. This may fail if the IDP requires a client ID.');
51
+ }
26
52
  return {
27
53
  idpBaseUrl,
28
- clientId: process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID || '',
54
+ clientId: clientId || 'payez_default_client', // Handler-safe fallback
29
55
  refreshEndpoint: process.env.REFRESH_ENDPOINT || '/api/ExternalAuth/refresh',
30
56
  };
31
57
  }
@@ -23,13 +23,22 @@ import { NextRequest, NextResponse } from 'next/server';
23
23
  */
24
24
  export declare function GET(req: NextRequest): Promise<NextResponse<{}>>;
25
25
  /**
26
- * POST /api/auth/session - Update session data
26
+ * POST /api/auth/session - Update session METADATA
27
27
  *
28
- * Allows updating session metadata (not tokens).
29
- * Token refresh should use the /api/auth/refresh endpoint.
28
+ * Updates session metadata only. This route holds no authority over authentication
29
+ * state: it cannot set the 2FA verdict and it cannot set tokens.
30
+ *
31
+ * Token refresh uses POST /api/auth/refresh. The 2FA verdict is written only by the
32
+ * server-side verification path that actually observed the factor.
30
33
  *
31
34
  * Body:
32
35
  * - metadata: object - Custom metadata to store in session
36
+ *
37
+ * Any of CALLER_ASSERTED_FIELDS present in the body is a 400. The docstring above used
38
+ * to promise "metadata (not tokens)" while the handler wrote idpAccessToken,
39
+ * idpRefreshToken AND mfaVerified - that false parenthetical is the ONLY caution a
40
+ * mounter reads, and it is how this route came to be mounted (318272). The promise is
41
+ * now enforced by the code rather than asserted by the comment.
33
42
  */
34
43
  export declare function POST(req: NextRequest): Promise<NextResponse<{
35
44
  error: string;
@@ -51,6 +51,26 @@ async function GET(req) {
51
51
  roles: session?.roles,
52
52
  hasAccessToken: !!session?.idpAccessToken,
53
53
  });
54
+ // A Better Auth cookie is NOT authorization. The Redis session is the
55
+ // authoritative record of what this user is allowed to do; without it we
56
+ // have no roles and no IDP access token, so there is nothing to authorize
57
+ // against. Returning a populated user here produces the worst failure mode
58
+ // available: a client that renders as signed-in and is not.
59
+ //
60
+ // MUST return empty {} for the same reason as line 34 — useSession() treats
61
+ // ANY non-empty response object as "authenticated".
62
+ //
63
+ // NOTE: an MFA-pending session IS present in Redis (it just has
64
+ // mfaVerified=false), so this gate does not break /verify-code. Gating on a
65
+ // missing accessToken instead WOULD break it.
66
+ if (!session) {
67
+ console.error('[SESSION_ROUTE] Redis session MISS — refusing to report authenticated', {
68
+ userId: authSession.user?.id,
69
+ redisSessionId: redisSessionId ? redisSessionId.substring(0, 8) + '...' : 'MISSING',
70
+ reason: redisSessionId ? 'session expired or evicted' : 'no session token on auth session',
71
+ });
72
+ return server_1.NextResponse.json({});
73
+ }
54
74
  // Return session format with Redis data
55
75
  // useSession() expects: { user: {...}, expires: "..." }
56
76
  // We enrich with all session data from Redis
@@ -92,13 +112,61 @@ async function GET(req) {
92
112
  }
93
113
  }
94
114
  /**
95
- * POST /api/auth/session - Update session data
115
+ * The fields this route USED to accept from the request body and must never accept
116
+ * again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
117
+ *
118
+ * `twoFactorComplete` was written straight through to `mfaVerified`, so a caller
119
+ * holding any session supplied its OWN second-factor verdict - no factor presented,
120
+ * no comparison against `amr`, no server-side derivation. The caller can be PRE-2FA,
121
+ * which makes this a 2FA bypass rather than a user rewriting a flag he had earned.
96
122
  *
97
- * Allows updating session metadata (not tokens).
98
- * Token refresh should use the /api/auth/refresh endpoint.
123
+ * The package already states the invariant in four places, in its own voice:
124
+ * api-handlers/mfa/totp-verify.ts - "Enrolment must never set mfaVerified."
125
+ * api-handlers/auth/refresh.ts - "mfaVerified is derived from amrClaims"
126
+ * auth/better-auth.ts - "computed fresh ... re-derived fresh"
127
+ * (consumer) passkey-signin-plugin - "mfaVerified is DERIVED from amr, never asserted."
128
+ * Every other writer in the estate derives it. This one route took it from an HTTP body.
129
+ *
130
+ * PRESENCE, NOT TRUTHINESS, is what is refused - `twoFactorComplete: false` is rejected
131
+ * exactly like `true`. A caller must not be able to drive this field in EITHER direction,
132
+ * and per R486/R908 an absent signal is "cannot determine", never a negative assertion.
133
+ *
134
+ * REFUSED LOUDLY RATHER THAN IGNORED SILENTLY. Dropping the field would leave a caller
135
+ * believing it had recorded a 2FA completion that never happened - the same failure shape
136
+ * as the 4.1.14 -> 4.1.31 history below, where a write nobody read looked like it worked.
137
+ *
138
+ * *** DO NOT "FIX" A REGRESSION HERE BY REVERTING THE KEY NORMALIZATION (312092 s2). ***
139
+ * 4.1.14 had this same unvalidated write but stored bare `accessToken`/`twoFactorComplete`,
140
+ * keys no reader consulted, so the forgery landed where nothing looked. The 4.1.31
141
+ * normalization did not introduce the bug - IT ARMED IT. Reverting re-hides the hole and
142
+ * restores two real client-facing bugs. The write is authorized here instead.
143
+ */
144
+ const CALLER_ASSERTED_FIELDS = [
145
+ { field: 'twoFactorComplete', why: 'the second-factor verdict is derived from the IDP token (amr), never asserted by the caller' },
146
+ { field: 'mfaVerified', why: 'the second-factor verdict is derived from the IDP token (amr), never asserted by the caller' },
147
+ { field: 'twoFactorMethod', why: 'the method is recorded by the server-side verification path that observed the factor' },
148
+ { field: 'access_token', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
149
+ { field: 'refresh_token', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
150
+ { field: 'idpAccessToken', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
151
+ { field: 'idpRefreshToken', why: 'tokens are issued by the IDP; use POST /api/auth/refresh' },
152
+ ];
153
+ /**
154
+ * POST /api/auth/session - Update session METADATA
155
+ *
156
+ * Updates session metadata only. This route holds no authority over authentication
157
+ * state: it cannot set the 2FA verdict and it cannot set tokens.
158
+ *
159
+ * Token refresh uses POST /api/auth/refresh. The 2FA verdict is written only by the
160
+ * server-side verification path that actually observed the factor.
99
161
  *
100
162
  * Body:
101
163
  * - metadata: object - Custom metadata to store in session
164
+ *
165
+ * Any of CALLER_ASSERTED_FIELDS present in the body is a 400. The docstring above used
166
+ * to promise "metadata (not tokens)" while the handler wrote idpAccessToken,
167
+ * idpRefreshToken AND mfaVerified - that false parenthetical is the ONLY caution a
168
+ * mounter reads, and it is how this route came to be mounted (318272). The promise is
169
+ * now enforced by the code rather than asserted by the comment.
102
170
  */
103
171
  async function POST(req) {
104
172
  try {
@@ -117,7 +185,21 @@ async function POST(req) {
117
185
  }, { status: 400 });
118
186
  }
119
187
  const body = await req.json();
120
- const { metadata, access_token, refresh_token, twoFactorComplete, twoFactorMethod } = body;
188
+ // Refuse before reading the session: a caller must not learn whether its forged
189
+ // field would have landed, and nothing should be written on a rejected request.
190
+ const asserted = CALLER_ASSERTED_FIELDS.filter(({ field }) => body != null && Object.prototype.hasOwnProperty.call(body, field));
191
+ if (asserted.length > 0) {
192
+ console.warn('[SESSION_ROUTE] REFUSED caller-asserted authentication state', {
193
+ fields: asserted.map(({ field }) => field),
194
+ userId: authSession.user?.id,
195
+ });
196
+ return server_1.NextResponse.json({
197
+ error: 'This route updates session metadata only. It cannot set authentication state.',
198
+ code: 'CALLER_ASSERTED_AUTH_STATE',
199
+ fields: asserted.map(({ field, why }) => ({ field, why })),
200
+ }, { status: 400 });
201
+ }
202
+ const { metadata } = body;
121
203
  // Get current session from Redis
122
204
  const session = await (0, session_store_1.getSession)(sessionToken);
123
205
  if (!session) {
@@ -126,13 +208,14 @@ async function POST(req) {
126
208
  code: 'SESSION_NOT_FOUND'
127
209
  }, { status: 404 });
128
210
  }
129
- // Update session with new data
211
+ // Update session with new data.
212
+ //
213
+ // METADATA ONLY. The token and 2FA-verdict writes that used to live here were
214
+ // removed, not merely guarded: see CALLER_ASSERTED_FIELDS above. Anything that
215
+ // needs to write authentication state must do so from a path that OBSERVED the
216
+ // fact - the IDP exchange for tokens, the verification path for the 2FA verdict.
130
217
  const updatedSession = {
131
218
  ...session,
132
- ...(access_token ? { accessToken: access_token } : {}),
133
- ...(refresh_token ? { refreshToken: refresh_token } : {}),
134
- ...(typeof twoFactorComplete === 'boolean' ? { twoFactorComplete } : {}),
135
- ...(twoFactorMethod ? { twoFactorMethod } : {}),
136
219
  ...(metadata ? {
137
220
  metadata: {
138
221
  ...(session.metadata || {}),
@@ -3,23 +3,53 @@
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
  import { NextResponse } from 'next/server';
8
30
  export interface PublicAuthSettings {
9
31
  enabledProviders: string[];
10
- allowPublicRegistration: boolean;
11
32
  allowFederatedLogin: boolean;
12
- enablePasswordReset: boolean;
13
33
  require2FA: boolean;
14
34
  allowed2FAMethods: string[];
35
+ allowPublicRegistration?: boolean;
36
+ enablePasswordReset?: boolean;
15
37
  }
38
+ /** Stable, machine-readable failure codes. Consumers branch on these. */
39
+ export type AuthSettingsErrorCode = 'IDP_UNREACHABLE' | 'IDP_CONFIG_INVALID';
16
40
  /**
17
41
  * GET /api/auth/settings
18
42
  *
19
- * Returns public auth settings for the current client.
20
- * Used by login/signup pages to determine what options to show.
43
+ * Returns public auth settings for the current client, or 503 if the real
44
+ * policy could not be read. It never answers from a default.
21
45
  */
22
46
  export declare function GET(): Promise<NextResponse<{
47
+ success: boolean;
48
+ error: {
49
+ code: AuthSettingsErrorCode;
50
+ message: string;
51
+ };
52
+ }> | NextResponse<{
23
53
  success: boolean;
24
54
  data: PublicAuthSettings;
25
55
  }>>;
@@ -4,52 +4,106 @@
4
4
  *
5
5
  * Returns client auth settings for pre-login pages (signup, login).
6
6
  * Does NOT require authentication - these are public client settings.
7
+ *
8
+ * ── 257255: THIS ROUTE USED TO FABRICATE AN AUTHORIZATION POLICY ───────────
9
+ * It returned `success: true` with a hardcoded policy on ANY failure, so a
10
+ * caller could not distinguish "the IDP says X" from "I never reached the
11
+ * IDP". On 2026-08-24 it served require2FA=true / ["email","sms"] for hours
12
+ * while the database said require_2fa=false / ["emailcode","totp"] - wrong
13
+ * about WHETHER 2FA applied and WHICH factors existed, simultaneously and
14
+ * confidently. Six agents spent a night measuring policy with this instrument.
15
+ *
16
+ * Worse, the alarm it swallowed was CORRECT: idp-client-config throws
17
+ * specifically to stop an empty secret reaching betterAuth() and being
18
+ * silently replaced by the PUBLIC DEFAULT_SECRET. This route caught that and
19
+ * returned success.
20
+ *
21
+ * BAPert ruling v32: THE ENDPOINT HARD-FAILS, THE PAGE DEGRADES.
22
+ * QAPert's decisive measurement: the SAME client is require_2fa=TRUE on AKS
23
+ * and FALSE on dev-93, so ANY hardcoded fallback is provably wrong in one
24
+ * environment by construction. There is no safe default because the value is
25
+ * per-client per-environment DATA.
26
+ *
27
+ * The positive control already existed in this package: /api/session/viability
28
+ * surfaces this same fault honestly. These two routes now agree.
7
29
  */
8
30
  Object.defineProperty(exports, "__esModule", { value: true });
9
31
  exports.GET = GET;
10
32
  const server_1 = require("next/server");
11
33
  const idp_client_config_1 = require("../../lib/idp-client-config");
34
+ /**
35
+ * Fail closed and LOUD. 503 + success:false + a code, and CRUCIALLY no policy
36
+ * fields at all - not defaults, not partials. A consumer cannot mistake an
37
+ * absent body for an answer, which is exactly the mistake the old shape invited.
38
+ */
39
+ function policyUnavailable(code, message) {
40
+ return server_1.NextResponse.json({ success: false, error: { code, message } }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
41
+ }
12
42
  /**
13
43
  * GET /api/auth/settings
14
44
  *
15
- * Returns public auth settings for the current client.
16
- * Used by login/signup pages to determine what options to show.
45
+ * Returns public auth settings for the current client, or 503 if the real
46
+ * policy could not be read. It never answers from a default.
17
47
  */
18
48
  async function GET() {
49
+ let config;
19
50
  try {
20
- const config = await (0, idp_client_config_1.getIDPClientConfig)();
21
- const settings = {
22
- // Get enabled OAuth provider names
23
- enabledProviders: config.oauthProviders
24
- ?.filter(p => p.enabled)
25
- .map(p => p.provider) ?? [],
26
- // Registration - default to true if not specified
27
- allowPublicRegistration: true, // Could come from config.authSettings in future
28
- allowFederatedLogin: config.oauthProviders?.some(p => p.enabled) ?? false,
29
- // Password reset
30
- enablePasswordReset: true, // Could come from config.authSettings in future
31
- // 2FA
32
- require2FA: config.authSettings?.require2FA ?? true,
33
- allowed2FAMethods: config.authSettings?.allowed2FAMethods ?? ['email', 'sms'],
34
- };
35
- return server_1.NextResponse.json({
36
- success: true,
37
- data: settings,
38
- });
51
+ config = await (0, idp_client_config_1.getIDPClientConfig)();
39
52
  }
40
53
  catch (error) {
54
+ // Keep the loud line - it is what finally unmasked this in production.
41
55
  console.error('[AUTH_SETTINGS] Failed to get settings:', error);
42
- // Return safe defaults on error
43
- return server_1.NextResponse.json({
44
- success: true,
45
- data: {
46
- enabledProviders: [],
47
- allowPublicRegistration: true,
48
- allowFederatedLogin: false,
49
- enablePasswordReset: true,
50
- require2FA: true,
51
- allowed2FAMethods: ['email', 'sms'],
52
- },
53
- });
56
+ // The `degraded: true` shape that used to live here (240447) is gone
57
+ // deliberately. It still answered `success: true` with a full invented
58
+ // policy and merely LABELLED the guess - which BAPert v32 rules out:
59
+ // THE ENDPOINT HARD-FAILS, THE PAGE DEGRADES. A flag a consumer may
60
+ // ignore is not a failure; 503 with no policy fields is.
61
+ return policyUnavailable('IDP_UNREACHABLE', 'Could not read client configuration from the identity service.');
62
+ }
63
+ // ── THE CONDITION IS "DID I READ A VALIDATED POLICY", NOT "DID THE FETCH
64
+ // THROW". This distinction is load-bearing and is why the catch above is
65
+ // not sufficient on its own (QAPert, and card 257661).
66
+ //
67
+ // getIDPClientConfig caches BEFORE it validates, and the cache-hit paths
68
+ // do NOT re-validate. So it can RETURN NORMALLY - no throw, no log line -
69
+ // with a config that FAILED validation on the request that cached it.
70
+ // Measured on :4702 on 2026-08-24: the loader was throwing 53 times per
71
+ // 400 log lines while THIS endpoint served a well-formed policy from
72
+ // cache. A fix that only handles the throw is bypassed by the cache on
73
+ // every request after the first.
74
+ //
75
+ // authSecret is the invariant the loader itself enforces on the fetch
76
+ // path (idp-client-config: "LOAD-BEARING - empty must fail here, never
77
+ // downstream"). Re-checking it here is what makes a cache hit as safe as
78
+ // a fresh read.
79
+ if (!config?.authSecret) {
80
+ console.error('[AUTH_SETTINGS] Refusing to serve policy: config has no authSecret ' +
81
+ '(likely served from cache before validation - see card 257661).');
82
+ return policyUnavailable('IDP_CONFIG_INVALID', 'Client configuration is incomplete; refusing to report a policy.');
83
+ }
84
+ // No `??` defaults. If the policy section is absent or malformed we did not
85
+ // read a policy, and inventing one is the defect this card exists for.
86
+ const authSettings = config.authSettings;
87
+ if (!authSettings ||
88
+ typeof authSettings.require2FA !== 'boolean' ||
89
+ !Array.isArray(authSettings.allowed2FAMethods)) {
90
+ console.error('[AUTH_SETTINGS] Refusing to serve policy: authSettings absent or malformed.');
91
+ return policyUnavailable('IDP_CONFIG_INVALID', 'Client configuration carried no usable auth policy.');
54
92
  }
93
+ const settings = {
94
+ enabledProviders: config.oauthProviders?.filter(p => p.enabled).map(p => p.provider) ?? [],
95
+ // Prefer the EXPLICIT policy field over inferring from the provider
96
+ // list (240447). Inferring is only a proxy: a tenant can have federated
97
+ // login switched off while enabled providers are still configured, and
98
+ // the inference reports `true` for it. Fall back to the inference only
99
+ // where the IDP does not state the field.
100
+ allowFederatedLogin: authSettings.allowSocialLogin ??
101
+ (config.oauthProviders?.some(p => p.enabled) ?? false),
102
+ // Real reads, no `??` literal. Absent stays absent (see interface).
103
+ allowPublicRegistration: authSettings.allowPublicRegistration,
104
+ enablePasswordReset: authSettings.enablePasswordReset,
105
+ require2FA: authSettings.require2FA,
106
+ allowed2FAMethods: authSettings.allowed2FAMethods,
107
+ };
108
+ return server_1.NextResponse.json({ success: true, data: settings }, { headers: { 'Cache-Control': 'no-store' } });
55
109
  }
@@ -44,7 +44,7 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
44
44
  accessTokenExpired: boolean;
45
45
  expiresAt: string;
46
46
  roles: string[];
47
- clientId: string;
47
+ clientId: string | null;
48
48
  }> | NextResponse<{
49
49
  viable: boolean;
50
50
  needsRefresh: boolean;