@payez/next-mvp 4.1.20 → 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 (177) 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/components/account/UserAvatarMenu.js +12 -0
  31. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  32. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  33. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  34. package/dist/hooks/useAuthSettings.js +14 -2
  35. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  36. package/dist/hooks/usePublicAuthSettings.js +28 -14
  37. package/dist/lib/client-key.d.ts +61 -0
  38. package/dist/lib/client-key.js +72 -0
  39. package/dist/lib/demo-mode.js +2 -1
  40. package/dist/lib/ensure-fresh-access-token.js +12 -1
  41. package/dist/lib/idp-client-config.d.ts +25 -2
  42. package/dist/lib/idp-client-config.js +381 -36
  43. package/dist/lib/idp-fetch.d.ts +2 -0
  44. package/dist/lib/idp-fetch.js +31 -1
  45. package/dist/lib/idp-url-guard.d.ts +29 -0
  46. package/dist/lib/idp-url-guard.js +38 -0
  47. package/dist/lib/redis.js +77 -5
  48. package/dist/lib/session-cookie-shape.d.ts +69 -0
  49. package/dist/lib/session-cookie-shape.js +126 -0
  50. package/dist/lib/session-store.d.ts +35 -2
  51. package/dist/lib/session-store.js +135 -4
  52. package/dist/lib/single-flight.d.ts +35 -0
  53. package/dist/lib/single-flight.js +30 -0
  54. package/dist/lib/token-lifecycle.js +12 -0
  55. package/dist/middleware/create-middleware.js +12 -0
  56. package/dist/models/SessionModel.d.ts +41 -0
  57. package/dist/models/SessionModel.js +9 -0
  58. package/dist/pages/admin-login/page.d.ts +1 -1
  59. package/dist/pages/admin-login/page.js +5 -41
  60. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  61. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  62. package/dist/pages/security/index.d.ts +1 -0
  63. package/dist/pages/security/index.js +3 -0
  64. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  65. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  66. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  67. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  68. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  69. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  70. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  71. package/dist/pages/test-env/TestEnvPage.js +16 -4
  72. package/dist/pages/test-env/guarded.d.ts +4 -0
  73. package/dist/pages/test-env/guarded.js +86 -0
  74. package/dist/pages/test-env/index.d.ts +50 -7
  75. package/dist/pages/test-env/index.js +58 -12
  76. package/dist/routes/auth/logout.js +3 -2
  77. package/dist/routes/auth/refresh.js +27 -1
  78. package/dist/routes/auth/session.d.ts +12 -3
  79. package/dist/routes/auth/session.js +92 -9
  80. package/dist/routes/auth/settings.d.ts +34 -4
  81. package/dist/routes/auth/settings.js +87 -33
  82. package/dist/routes/auth/viability.d.ts +1 -1
  83. package/dist/routes/auth/viability.js +122 -11
  84. package/dist/server/auth-guard.js +22 -15
  85. package/dist/server/auth.d.ts +170 -4
  86. package/dist/server/auth.js +42 -75
  87. package/dist/server/decode-session.js +84 -10
  88. package/dist/types/auth.d.ts +18 -0
  89. package/package.json +969 -958
  90. package/scripts/check-internal-url-usage.sh +73 -73
  91. package/scripts/check-publish-health.mjs +120 -0
  92. package/scripts/dev-broker.ps1 +71 -6
  93. package/scripts/print-client-config.mjs +162 -0
  94. package/scripts/server-only-stub.cjs +4 -0
  95. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  96. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  97. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  98. package/src/api-handlers/account/reset-password.ts +35 -2
  99. package/src/api-handlers/account/validate-password.ts +22 -3
  100. package/src/api-handlers/admin/analytics.ts +2 -1
  101. package/src/api-handlers/admin/audit.ts +2 -1
  102. package/src/api-handlers/admin/sessions.ts +2 -1
  103. package/src/api-handlers/admin/stats.ts +2 -1
  104. package/src/api-handlers/admin/users.ts +2 -1
  105. package/src/api-handlers/admin/vibe-data.ts +2 -1
  106. package/src/api-handlers/auth/jwks.ts +10 -0
  107. package/src/api-handlers/auth/login.ts +5 -2
  108. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  109. package/src/api-handlers/auth/refresh.ts +112 -15
  110. package/src/api-handlers/auth/signout.ts +4 -3
  111. package/src/api-handlers/auth/verify-code.ts +5 -2
  112. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  113. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  114. package/src/api-handlers/mfa/methods.ts +46 -46
  115. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  116. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  117. package/src/api-handlers/passkeys/list.ts +31 -31
  118. package/src/api-handlers/passkeys/manage.ts +54 -54
  119. package/src/api-handlers/passkeys/register-options.ts +42 -42
  120. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  121. package/src/api-handlers/session/viability.ts +29 -1
  122. package/src/api-handlers/test/force-expire.ts +118 -13
  123. package/src/auth/better-auth.ts +486 -51
  124. package/src/auth/utils/idp-client.ts +1 -1
  125. package/src/client/AuthContext.tsx +55 -19
  126. package/src/components/account/UserAvatarMenu.tsx +11 -0
  127. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  128. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  129. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  130. package/src/hooks/useAuthSettings.ts +14 -2
  131. package/src/hooks/usePublicAuthSettings.ts +38 -16
  132. package/src/lib/client-key.ts +88 -0
  133. package/src/lib/demo-mode.ts +2 -1
  134. package/src/lib/ensure-fresh-access-token.ts +12 -1
  135. package/src/lib/idp-client-config.ts +399 -36
  136. package/src/lib/idp-fetch.ts +36 -1
  137. package/src/lib/idp-url-guard.ts +37 -0
  138. package/src/lib/redis.ts +84 -6
  139. package/src/lib/session-cookie-shape.ts +184 -0
  140. package/src/lib/session-store.ts +152 -6
  141. package/src/lib/single-flight.ts +64 -0
  142. package/src/lib/token-lifecycle.ts +12 -0
  143. package/src/middleware/create-middleware.ts +11 -0
  144. package/src/models/SessionModel.ts +49 -0
  145. package/src/pages/admin-login/page.tsx +17 -127
  146. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  147. package/src/pages/security/index.ts +3 -0
  148. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  149. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  150. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  151. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  152. package/src/pages/test-env/guarded.tsx +81 -0
  153. package/src/pages/test-env/index.ts +59 -7
  154. package/src/routes/auth/logout.ts +4 -3
  155. package/src/routes/auth/passkey-signin.ts +13 -13
  156. package/src/routes/auth/refresh.ts +30 -1
  157. package/src/routes/auth/session.ts +98 -9
  158. package/src/routes/auth/settings.ts +144 -42
  159. package/src/routes/auth/viability.ts +129 -11
  160. package/src/routes/mfa/backup-code-verify.ts +1 -1
  161. package/src/routes/mfa/backup-codes.ts +10 -10
  162. package/src/routes/mfa/methods.ts +22 -22
  163. package/src/routes/mfa/totp-confirm.ts +10 -10
  164. package/src/routes/mfa/totp-enroll.ts +10 -10
  165. package/src/routes/mfa/totp-verify.ts +14 -14
  166. package/src/routes/passkeys/list.ts +10 -10
  167. package/src/routes/passkeys/manage.ts +23 -23
  168. package/src/routes/passkeys/register-options.ts +10 -10
  169. package/src/routes/passkeys/register-verify.ts +10 -10
  170. package/src/server/auth-guard.ts +24 -17
  171. package/src/server/auth.ts +54 -74
  172. package/src/server/decode-session.ts +95 -11
  173. package/src/templates/instrumentation.ts +21 -2
  174. package/src/types/auth.ts +20 -0
  175. package/dist/lib/auth-secret.d.ts +0 -17
  176. package/dist/lib/auth-secret.js +0 -116
  177. package/src/lib/auth-secret.ts +0 -137
@@ -1,10 +1,10 @@
1
- /**
2
- * Ready-to-Use TOTP Enrol Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/mfa/totp/enroll/route.ts
7
- * export { POST } from '@payez/next-mvp/routes/mfa/totp-enroll';
8
- * ```
9
- */
10
- export { POST } from '../../api-handlers/mfa/totp-enroll';
1
+ /**
2
+ * Ready-to-Use TOTP Enrol Route
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/mfa/totp/enroll/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/totp-enroll';
8
+ * ```
9
+ */
10
+ export { POST } from '../../api-handlers/mfa/totp-enroll';
@@ -1,14 +1,14 @@
1
- /**
2
- * Ready-to-Use TOTP Verify Route (2FA CHALLENGE, not enrolment)
3
- *
4
- * Verifies an authenticator code at LOGIN and transitions the session to
5
- * MFA-verified. Pair with totp-enroll/totp-confirm, which only ENROL a factor
6
- * and deliberately do not touch session 2FA state.
7
- *
8
- * @example
9
- * ```typescript
10
- * // app/api/mfa/totp/verify/route.ts
11
- * export { POST } from '@payez/next-mvp/routes/mfa/totp-verify';
12
- * ```
13
- */
14
- export { POST } from '../../api-handlers/mfa/totp-verify';
1
+ /**
2
+ * Ready-to-Use TOTP Verify Route (2FA CHALLENGE, not enrolment)
3
+ *
4
+ * Verifies an authenticator code at LOGIN and transitions the session to
5
+ * MFA-verified. Pair with totp-enroll/totp-confirm, which only ENROL a factor
6
+ * and deliberately do not touch session 2FA state.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // app/api/mfa/totp/verify/route.ts
11
+ * export { POST } from '@payez/next-mvp/routes/mfa/totp-verify';
12
+ * ```
13
+ */
14
+ export { POST } from '../../api-handlers/mfa/totp-verify';
@@ -1,10 +1,10 @@
1
- /**
2
- * Ready-to-Use Passkey List Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/passkeys/route.ts
7
- * export { GET } from '@payez/next-mvp/routes/passkeys/list';
8
- * ```
9
- */
10
- export { GET } from '../../api-handlers/passkeys/list';
1
+ /**
2
+ * Ready-to-Use Passkey List Route
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/passkeys/route.ts
7
+ * export { GET } from '@payez/next-mvp/routes/passkeys/list';
8
+ * ```
9
+ */
10
+ export { GET } from '../../api-handlers/passkeys/list';
@@ -1,23 +1,23 @@
1
- /**
2
- * Ready-to-Use Passkey Manage Route (rename / revoke one key)
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/passkeys/[id]/route.ts
7
- * export { PATCH, DELETE } from '@payez/next-mvp/routes/passkeys/manage';
8
- * ```
9
- */
10
- import { NextRequest } from 'next/server';
11
- import { PATCH as patchOne, DELETE as deleteOne } from '../../api-handlers/passkeys/manage';
12
-
13
- type Ctx = { params: Promise<{ id: string }> };
14
-
15
- export async function PATCH(req: NextRequest, ctx: Ctx) {
16
- const { id } = await ctx.params;
17
- return patchOne(req, id);
18
- }
19
-
20
- export async function DELETE(req: NextRequest, ctx: Ctx) {
21
- const { id } = await ctx.params;
22
- return deleteOne(req, id);
23
- }
1
+ /**
2
+ * Ready-to-Use Passkey Manage Route (rename / revoke one key)
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/passkeys/[id]/route.ts
7
+ * export { PATCH, DELETE } from '@payez/next-mvp/routes/passkeys/manage';
8
+ * ```
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ import { PATCH as patchOne, DELETE as deleteOne } from '../../api-handlers/passkeys/manage';
12
+
13
+ type Ctx = { params: Promise<{ id: string }> };
14
+
15
+ export async function PATCH(req: NextRequest, ctx: Ctx) {
16
+ const { id } = await ctx.params;
17
+ return patchOne(req, id);
18
+ }
19
+
20
+ export async function DELETE(req: NextRequest, ctx: Ctx) {
21
+ const { id } = await ctx.params;
22
+ return deleteOne(req, id);
23
+ }
@@ -1,10 +1,10 @@
1
- /**
2
- * Ready-to-Use Passkey Registration Options Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/passkeys/register/options/route.ts
7
- * export { POST } from '@payez/next-mvp/routes/passkeys/register-options';
8
- * ```
9
- */
10
- export { POST } from '../../api-handlers/passkeys/register-options';
1
+ /**
2
+ * Ready-to-Use Passkey Registration Options Route
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/passkeys/register/options/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/passkeys/register-options';
8
+ * ```
9
+ */
10
+ export { POST } from '../../api-handlers/passkeys/register-options';
@@ -1,10 +1,10 @@
1
- /**
2
- * Ready-to-Use Passkey Registration Verify Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/passkeys/register/verify/route.ts
7
- * export { POST } from '@payez/next-mvp/routes/passkeys/register-verify';
8
- * ```
9
- */
10
- export { POST } from '../../api-handlers/passkeys/register-verify';
1
+ /**
2
+ * Ready-to-Use Passkey Registration Verify Route
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/passkeys/register/verify/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/passkeys/register-verify';
8
+ * ```
9
+ */
10
+ export { POST } from '../../api-handlers/passkeys/register-verify';
@@ -110,31 +110,38 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
110
110
  }
111
111
 
112
112
  // --- 2FA check ---
113
+ // This try wraps ONLY the config fetch, because that is the only thing here
114
+ // that can genuinely fail. Do NOT widen it to cover the redirect below:
115
+ // next/navigation's redirect() signals by THROWING a NEXT_REDIRECT digest, so
116
+ // a redirect inside this try is caught by the catch and rewritten into a
117
+ // service-unavailable page - denying a 2FA user the one page that would let
118
+ // them satisfy the challenge, and reporting it as an outage. See card 312242.
119
+ let requires2FA: boolean;
113
120
  try {
114
121
  const config = await getIDPClientConfig();
115
- const requires2FA = config.authSettings?.require2FA ?? true;
116
-
117
- if (requires2FA) {
118
- const mfaVerified = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
119
- const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
120
- const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
121
-
122
- if (!mfaVerified || mfaExpired) {
123
- console.log('[AUTH-GUARD] 2FA required', {
124
- mfaVerified,
125
- mfaExpired,
126
- userId: sessionData.userId,
127
- pathname,
128
- });
129
- redirect(`${verifyCodeUrl}?callbackUrl=${callbackUrl}`);
130
- }
131
- }
122
+ requires2FA = config.authSettings?.require2FA ?? true;
132
123
  } catch (error) {
133
124
  // If we can't check 2FA config, fail closed
134
125
  console.error('[AUTH-GUARD] 2FA config check failed:', error instanceof Error ? error.message : String(error));
135
126
  redirect(serviceUnavailableUrl);
136
127
  }
137
128
 
129
+ if (requires2FA) {
130
+ const mfaVerified = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
131
+ const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
132
+ const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
133
+
134
+ if (!mfaVerified || mfaExpired) {
135
+ console.log('[AUTH-GUARD] 2FA required', {
136
+ mfaVerified,
137
+ mfaExpired,
138
+ userId: sessionData.userId,
139
+ pathname,
140
+ });
141
+ redirect(`${verifyCodeUrl}?callbackUrl=${callbackUrl}`);
142
+ }
143
+ }
144
+
138
145
  // --- Custom checks (beta, admin, etc.) ---
139
146
  if (options?.checks) {
140
147
  for (const check of options.checks) {
@@ -6,21 +6,29 @@
6
6
  */
7
7
 
8
8
  import 'server-only';
9
- import { createBetterAuthInstance } from '../auth/better-auth';
9
+ import { createBetterAuthInstance, secretFingerprint } from '../auth/better-auth';
10
10
  import { getIDPClientConfig } from '../lib/idp-client-config';
11
11
  import {
12
12
  getSession as getRedisSession,
13
13
  getBetterAuthSession as getBetterAuthRedisSession,
14
14
  type SessionData,
15
15
  } from '../lib/session-store';
16
- import { getSessionCookieName } from '../lib/app-slug';
16
+ import { extractSessionTokenFromCookie } from '../lib/session-cookie-shape';
17
+ import { createSingleFlight } from '../lib/single-flight';
17
18
 
18
19
  let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
19
- let authInitPromise: Promise<ReturnType<typeof createBetterAuthInstance>> | null = null;
20
20
 
21
21
  export type IdpTokenResult =
22
22
  | { success: true; accessToken: string; sessionData: SessionData }
23
- | { success: false; error: 'NO_SESSION' | 'NO_TOKEN'; terminal: true };
23
+ | { success: false; error: 'NO_SESSION'; terminal: true }
24
+ // Card 187871: a Better Auth session can exist with NO idpAccessToken yet
25
+ // because 2FA is required and not yet completed - the IDP never issued the
26
+ // full token. That is a RECOVERABLE state (route to 2FA), not a dead
27
+ // session. Only collapse it to NO_TOKEN/terminal when the session itself
28
+ // already claims 2FA is done (sessionData.mfaVerified === true) and there is
29
+ // still no token - that combination is genuinely broken.
30
+ | { success: false; error: 'NO_TOKEN'; terminal: true }
31
+ | { success: false; error: 'NO_TOKEN_PENDING_2FA'; terminal: false; sessionData: SessionData };
24
32
 
25
33
  function buildSessionDataFromAuthSession(session: any): SessionData | null {
26
34
  const user = session?.user;
@@ -83,81 +91,45 @@ function attachSessionData(session: any, sessionData: SessionData | null, sessio
83
91
 
84
92
  /**
85
93
  * Get the initialized Better Auth instance (singleton).
94
+ *
95
+ * 244549: the single-flight memoisation lives in lib/single-flight.ts rather
96
+ * than inline here, and that placement is deliberate rather than tidiness.
97
+ * The bug being fixed -- a REJECTED init promise cached for the life of the
98
+ * process, so recovery was restart-only -- went unnoticed precisely because
99
+ * this module cannot be loaded in a unit test: importing it pulls in
100
+ * better-auth and the whole IDP config stack. Extracted into a zero-import
101
+ * module, the retry semantics are covered by
102
+ * test/single-flight-retry.test.cjs, which imports dist/ and therefore
103
+ * exercises the artefact that actually ships.
104
+ *
105
+ * Semantics, all asserted by that suite: concurrent callers share ONE
106
+ * underlying init; a rejection reaches every caller awaiting that attempt
107
+ * (failures stay loud -- getIDPClientConfig is fail-loud by design and callers
108
+ * depend on it); the NEXT call after a failure starts a fresh attempt; and a
109
+ * slow failure never evicts a newer in-flight attempt.
86
110
  */
111
+ const authInit = createSingleFlight(async () => {
112
+ const config = await getIDPClientConfig(true);
113
+ authInstance = createBetterAuthInstance(config);
114
+ // 185121 line 1, path 2: this module-scope instance had NO creation log
115
+ // line — first-called during a config transition window it pinned a
116
+ // divergent secret INVISIBLY. Name the path and the secret fingerprint
117
+ // (the createBetterAuthInstance line already logged the same hash;
118
+ // this line is what attributes it to THIS init path).
119
+ // Guard matches createBetterAuthInstance's: getIDPClientConfig currently
120
+ // throws FATAL on a missing authSecret (QAPert 27328 verified), but that
121
+ // guarantee is one refactor away — log ABSENT rather than ever throw here.
122
+ console.log(
123
+ `[AUTH] getAuthInstance: created BA instance via server/auth.ts — authSecret ${config.authSecret ? `sha256:${secretFingerprint(config.authSecret as string)}` : 'ABSENT'}`
124
+ );
125
+ return authInstance;
126
+ });
127
+
87
128
  export async function getAuthInstance() {
88
129
  if (authInstance) return authInstance;
89
- if (!authInitPromise) {
90
- authInitPromise = getIDPClientConfig(true).then(config => {
91
- authInstance = createBetterAuthInstance(config);
92
- return authInstance;
93
- });
94
- }
95
- return authInitPromise;
96
- }
97
-
98
- /**
99
- * JWT body-decode helper — base64-decode only, NO signature verification.
100
- * Safe because the value is then used as a Redis lookup key: Redis is the
101
- * liveness gate, so an attacker forging a JWT body with a guessed
102
- * sessionToken claim still has to land on a real Redis session (infeasible
103
- * against high-entropy UUIDs). Used by the canonical-fallback path in
104
- * `getSession` to extract the session token when Better Auth's primary
105
- * path returns null (cookie cache miss / secondary storage eviction).
106
- */
107
- function decodeJwtBody(jwt: string): any {
108
- try {
109
- const parts = jwt.split('.');
110
- if (parts.length !== 3) return null;
111
- const decoded = Buffer.from(parts[1], 'base64').toString('utf-8');
112
- return JSON.parse(decoded);
113
- } catch {
114
- return null;
115
- }
130
+ return authInit.get();
116
131
  }
117
132
 
118
- /** Extract the session-token claim from the Better Auth session cookie
119
- * WITHOUT going through Better Auth's `auth.api.getSession()`. Handles
120
- * both single-cookie and chunked-cookie cases (Better Auth chunks long
121
- * JWTs across `{name}.0`, `{name}.1`, …). Returns the raw `sessionToken`
122
- * claim suitable for use as a Redis key. Returns null if no cookie is
123
- * present or the JWT can't be parsed. */
124
- function extractSessionTokenFromCookie(request: Request): string | null {
125
- const cookieHeader = request.headers.get('cookie');
126
- if (!cookieHeader) return null;
127
-
128
- const cookieName = getSessionCookieName();
129
- let rawJwt: string | null = null;
130
-
131
- // Direct cookie first.
132
- const chunks: Array<{ idx: number; value: string }> = [];
133
- for (const part of cookieHeader.split(';')) {
134
- const trimmed = part.trim();
135
- const eq = trimmed.indexOf('=');
136
- if (eq === -1) continue;
137
- const k = trimmed.slice(0, eq);
138
- const v = trimmed.slice(eq + 1);
139
- if (k === cookieName) {
140
- rawJwt = decodeURIComponent(v);
141
- break;
142
- }
143
- if (k.startsWith(`${cookieName}.`)) {
144
- const idx = parseInt(k.split('.').pop() || '0', 10);
145
- if (Number.isFinite(idx)) chunks.push({ idx, value: decodeURIComponent(v) });
146
- }
147
- }
148
- // Chunked cookies fallback (Better Auth splits long JWTs across .0/.1/.2 …).
149
- if (!rawJwt && chunks.length > 0) {
150
- chunks.sort((a, b) => a.idx - b.idx);
151
- rawJwt = chunks.map(c => c.value).join('');
152
- }
153
- if (!rawJwt) return null;
154
-
155
- const decoded = decodeJwtBody(rawJwt);
156
- if (decoded && typeof decoded === 'object' && typeof decoded.sessionToken === 'string') {
157
- return decoded.sessionToken;
158
- }
159
- return null;
160
- }
161
133
 
162
134
  /**
163
135
  * Get the current session from a request.
@@ -271,6 +243,14 @@ export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
271
243
 
272
244
  const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
273
245
  if (!accessToken) {
246
+ // Card 187871: a live Better Auth session with no idpAccessToken and
247
+ // mfaVerified !== true is a user mid-2FA, not a dead session - the IDP
248
+ // withholds the full token until 2FA completes (idp-fetch.ts:81/113).
249
+ // Only treat this as terminal once the session itself claims 2FA is
250
+ // already done; a token missing AFTER that is genuinely broken.
251
+ if (sessionData.mfaVerified !== true) {
252
+ return { success: false, error: 'NO_TOKEN_PENDING_2FA', terminal: false, sessionData };
253
+ }
274
254
  return { success: false, error: 'NO_TOKEN', terminal: true };
275
255
  }
276
256
 
@@ -12,6 +12,7 @@ import { getSession, type SessionData } from '../lib/session-store';
12
12
  import { getIDPClientConfig } from '../lib/idp-client-config';
13
13
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
14
14
  import { ensureInitialized } from '../lib/startup-init';
15
+ import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
15
16
 
16
17
  export interface DecodedSession {
17
18
  sessionData: SessionData;
@@ -100,6 +101,48 @@ async function tryBetterAuthSession(
100
101
  }
101
102
  } catch { /* Redis unavailable */ }
102
103
 
104
+ // LEG-1 FAIL-CLOSED ON UNUSABLE ROLES (card 340015, BAPert R412, 2026-09-03).
105
+ //
106
+ // `idpTokens.roles` is the ONLY source of roles for every role-gated route
107
+ // (see :109 below). When it is empty this leg used to return a perfectly
108
+ // valid-LOOKING session carrying `roles: []` — so the caller was
109
+ // authenticated but authorised for nothing. Every admin screen 403s and
110
+ // renders its chrome with no data and NO ERROR TEXT: it does not look
111
+ // broken, it looks empty.
112
+ //
113
+ // Three independent causes produce that state, so the test is on the
114
+ // CONDITION THAT ACTUALLY HARMS — "no usable roles" — rather than on any
115
+ // one route to it. A fourth cause is then covered for free:
116
+ // A MINT the password / admin-login lane never writes the block
117
+ // (it never calls storeIdpTokensInSession), so it is
118
+ // absent from birth.
119
+ // B CLOBBER better-auth's own 24h session refresh rebuilds the
120
+ // `ba:` record as `{session, user}` and drops the
121
+ // `idpTokens` SIBLING structurally, not conditionally
122
+ // (better-auth internal-adapter.mjs:287). This hits the
123
+ // OAuth and passkey lanes too — they are safe for 24
124
+ // hours and then stop being safe.
125
+ // C DERIVATION the block is PRESENT and fully populated with only
126
+ // `roles` empty (card 339748). `!idpTokens` is FALSE
127
+ // here and cannot see it — which is exactly why the
128
+ // predicate is `!idpTokens?.roles?.length` and not an
129
+ // absence check.
130
+ //
131
+ // Declining here hands the request to the legacy `{slug}:sess:` leg below,
132
+ // which carries the same roles. *** IF THAT RECORD IS ALSO ABSENT THE USER
133
+ // IS SIGNED OUT. THAT IS DELIBERATE AND ACCEPTED, NOT A REGRESSION *** —
134
+ // an honest, visible sign-out in place of a silently half-authorised
135
+ // session. Read the commit message before treating a sign-out here as a
136
+ // bug introduced by this fix.
137
+ if (!idpTokens?.roles?.length) {
138
+ console.warn(
139
+ '[DECODE-SESSION] Leg 1 declining: idpTokens unusable (no roles) for session',
140
+ result.session.token.substring(0, 10),
141
+ '— falling through to the sess: leg'
142
+ );
143
+ return null;
144
+ }
145
+
103
146
  // Map Better Auth session + IDP tokens to SessionData
104
147
  const sessionData: SessionData = {
105
148
  userId: idpTokens?.userId || result.user.id || '',
@@ -111,6 +154,9 @@ async function tryBetterAuthSession(
111
154
  idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
112
155
  || (result.session.expiresAt ? new Date(result.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
113
156
  mfaVerified: idpTokens?.mfaVerified ?? false,
157
+ // R807 (362550): fail-closed collapse above stays; this preserves the bit it
158
+ // destroys. Explicit `false` counts as a signal - hence `!== undefined`.
159
+ mfaSignalPresent: idpTokens?.mfaVerified !== undefined,
114
160
  /**
115
161
  * ⚠ WAS HARDCODED `'google'` — third site of QAPert finding #3 (card
116
162
  * 167892), found by sweeping rather than by report.
@@ -193,24 +239,62 @@ export async function decodeSession(
193
239
  return null;
194
240
  }
195
241
 
196
- const secretKey = new TextEncoder().encode(secret);
197
- let payload: JWTPayload;
198
- try {
199
- const result = await jwtVerify(cookieValue, secretKey);
200
- payload = result.payload;
201
- } catch (jwtError) {
202
- console.warn('[DECODE-SESSION] JWT verification failed:', jwtError instanceof Error ? jwtError.message : String(jwtError));
203
- return null;
242
+ // BA-SHAPE-AWARE FALLBACK LEG (183047, 2026-08-08).
243
+ //
244
+ // Better Auth mints session cookies as `{token}.{base64(hmac)}` — TWO
245
+ // segments, one dot. jose's `jwtVerify()` below requires a genuine
246
+ // compact JWS — THREE segments — and throws "Invalid Compact JWS" on a
247
+ // BA-shaped cookie unconditionally, before it ever looks at the secret.
248
+ // Since Better Auth is the only minter in production, that meant this
249
+ // fallback could never succeed for the cookies it actually receives: any
250
+ // single Better-Auth-primary miss (secondary-storage eviction, disabled
251
+ // cookie cache, in-flight secret divergence) read as total session
252
+ // death, because the ONLY fallback was structurally blind to the only
253
+ // shape it would ever be handed.
254
+ //
255
+ // Check the shape FIRST. A BA-hmac cookie skips jose entirely and goes
256
+ // straight to the Redis-canonical liveness check below — same posture
257
+ // as the JWS path always had (no signature re-check here either; Redis
258
+ // presence is the authority). A JWS-shaped cookie (legacy NextAuth-era,
259
+ // if any still linger) keeps the original jwtVerify path unchanged.
260
+ const shaped = extractSessionTokenFromCookieValue(cookieValue);
261
+
262
+ let sessionToken: string | undefined;
263
+ let payload: JWTPayload | undefined;
264
+
265
+ if (shaped?.shape === 'ba-hmac') {
266
+ sessionToken = shaped.token;
267
+ } else {
268
+ const secretKey = new TextEncoder().encode(secret);
269
+ try {
270
+ const result = await jwtVerify(cookieValue, secretKey);
271
+ payload = result.payload;
272
+ } catch (jwtError) {
273
+ console.warn('[DECODE-SESSION] JWT verification failed:', jwtError instanceof Error ? jwtError.message : String(jwtError));
274
+ return null;
275
+ }
276
+ sessionToken = (payload as any).sessionToken || (payload as any).redisSessionId;
204
277
  }
205
278
 
206
- const sessionToken = (payload as any).sessionToken || (payload as any).redisSessionId;
207
279
  if (!sessionToken) {
208
- console.warn('[DECODE-SESSION] JWT payload missing sessionToken/redisSessionId');
280
+ console.warn('[DECODE-SESSION] No sessionToken resolved from cookie (shape:', shaped?.shape ?? 'unrecognized', ')');
209
281
  return null;
210
282
  }
211
283
 
212
284
  const sessionData = await getSession(sessionToken);
213
285
  if (!sessionData) {
286
+ // The OTHER half of R412(ii). There are two ways decodeSession returns
287
+ // null and they mean different things, so they must not both be silent:
288
+ // Leg 1 declining is routine and recoverable (this leg normally holds
289
+ // the roles), whereas reaching HERE means there is no fallback record
290
+ // either and the caller is about to be signed out. Different meanings,
291
+ // different remediations — do not ship a second silent path while
292
+ // fixing a silent path.
293
+ console.warn(
294
+ '[DECODE-SESSION] No sess: record for token',
295
+ sessionToken.substring(0, 10),
296
+ '— no fallback available, session will be signed out'
297
+ );
214
298
  return null;
215
299
  }
216
300
 
@@ -226,7 +310,7 @@ export async function decodeSession(
226
310
 
227
311
  return {
228
312
  sessionData,
229
- jwtPayload: payload as DecodedSession['jwtPayload'],
313
+ jwtPayload: (payload ?? { sessionToken }) as DecodedSession['jwtPayload'],
230
314
  };
231
315
  } catch (error) {
232
316
  console.error('[DECODE-SESSION] Error:', error instanceof Error ? error.message : String(error));
@@ -34,8 +34,27 @@ export async function register() {
34
34
  console.log('Server startup initialization complete!');
35
35
  console.log('');
36
36
  } catch (error) {
37
- console.error('Server startup initialization failed:', error);
38
- console.error('Will retry initialization on first request.');
37
+ // DO NOT SWALLOW THIS. The previous version logged "Will retry initialization on
38
+ // first request" and let the server come up - so a site printed "Ready" while
39
+ // holding NO auth config, and betterAuth() got an empty secret, which it silently
40
+ // upgrades to the public DEFAULT_SECRET. Unavailable beats forgeable.
41
+ //
42
+ // payez_idp_admin_web/src/instrumentation.ts is the reference: it does not catch
43
+ // at all, and has a deliberate, named EMERGENCY_BYPASS_IDP escape hatch instead.
44
+ const msg = error instanceof Error ? error.message : String(error);
45
+ console.error('');
46
+ console.error('╔══════════════════════════════════════════════════════════════╗');
47
+ console.error('║ STARTUP FAILED — REFUSING TO SERVE ║');
48
+ console.error('╚══════════════════════════════════════════════════════════════╝');
49
+ console.error('');
50
+ console.error(' ' + msg);
51
+ console.error('');
52
+ console.error(' This server cannot obtain its auth configuration from the IDP,');
53
+ console.error(' so it has no signing secret and no sign-in policy.');
54
+ console.error('');
55
+ // Next CATCHES errors thrown from register() and continues; exiting is the only
56
+ // thing that actually stops the server coming up.
57
+ process.exit(1);
39
58
  }
40
59
  }
41
60
  }
package/src/types/auth.ts CHANGED
@@ -4,7 +4,27 @@ export type AuthMode = 'traditional' | 'federated';
4
4
  export type FederatedProvider = 'google' | 'microsoft' | 'facebook' | 'github' | 'apple';
5
5
 
6
6
  export interface AuthConfig {
7
+ /**
8
+ * @deprecated Superseded by allowFederatedLogin / allowTraditionalLogin.
9
+ * A single mode made the two mutually exclusive, so a site could only ever show ONE of
10
+ * OAuth or username/password - and because sites set it themselves, a client's sign-in
11
+ * screen could contradict what its own IDP offered. Kept so existing callers compile.
12
+ */
7
13
  mode: AuthMode;
14
+
15
+ /**
16
+ * Whether federated (OAuth) sign-in is offered. Defaults to what the IDP reports at
17
+ * /api/auth/settings; a site may override. Both this and allowTraditionalLogin can be
18
+ * true at once - that is the normal case.
19
+ */
20
+ allowFederatedLogin?: boolean;
21
+
22
+ /**
23
+ * Whether username/password sign-in is offered. DEFAULTS ON. The IDP does not carry an
24
+ * explicit flag for it yet; until it does, it is available unless a site opts out.
25
+ * An OAuth-only client is a deliberate choice, never the default.
26
+ */
27
+ allowTraditionalLogin?: boolean;
8
28
  providers: FederatedProvider[];
9
29
  enableRecovery: boolean;
10
30
  enableEmailSignup: boolean;
@@ -1,17 +0,0 @@
1
- import 'server-only';
2
- /**
3
- * Resolve the Better Auth signing secret (server-only).
4
- *
5
- * Priority:
6
- * 1) Use process.env.BETTER_AUTH_SECRET (preferred) or NEXTAUTH_SECRET (legacy)
7
- * if present — allows overrides/production via env.
8
- * 2) Fetch from IDP broker endpoint — IDP handles all Key Vault/signing.
9
- * 3) Cache result in-memory and set process.env.BETTER_AUTH_SECRET for
10
- * subsequent calls.
11
- *
12
- * NOTE on naming: this secret is the cryptographic key Better Auth uses to
13
- * sign session JWTs. The IDP backend still names the broker endpoint and
14
- * response field with the legacy "next-auth" / "nextAuthSecret" names; we
15
- * read both new and legacy on the wire during the migration window.
16
- */
17
- export declare function resolveAuthSecret(): Promise<string>;