@payez/next-mvp 4.4.5 → 4.4.6

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 (189) hide show
  1. package/README.md +10 -3
  2. package/dist/api/auth-handler.js +5 -3
  3. package/dist/api-handlers/account/change-password.js +6 -3
  4. package/dist/api-handlers/account/masked-info.js +2 -1
  5. package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
  6. package/dist/api-handlers/account/recovery/forward.js +105 -0
  7. package/dist/api-handlers/account/recovery/initiate.js +4 -0
  8. package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
  9. package/dist/api-handlers/account/recovery/send-code.js +11 -84
  10. package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
  11. package/dist/api-handlers/account/recovery/verify-code.js +10 -84
  12. package/dist/api-handlers/account/reset-password.js +24 -1
  13. package/dist/api-handlers/account/send-code.d.ts +1 -15
  14. package/dist/api-handlers/account/send-code.js +3 -6
  15. package/dist/api-handlers/account/update-phone.js +6 -1
  16. package/dist/api-handlers/account/validate-password.js +3 -1
  17. package/dist/api-handlers/account/verify-email.d.ts +1 -17
  18. package/dist/api-handlers/account/verify-email.js +4 -6
  19. package/dist/api-handlers/account/verify-sms.d.ts +1 -17
  20. package/dist/api-handlers/account/verify-sms.js +4 -6
  21. package/dist/api-handlers/admin/analytics.js +12 -4
  22. package/dist/api-handlers/admin/audit.js +12 -4
  23. package/dist/api-handlers/admin/redis-sessions.js +16 -8
  24. package/dist/api-handlers/admin/sessions.js +14 -6
  25. package/dist/api-handlers/admin/site-logs.js +39 -16
  26. package/dist/api-handlers/admin/stats.js +14 -4
  27. package/dist/api-handlers/admin/users.js +14 -6
  28. package/dist/api-handlers/admin/vibe-data.js +19 -11
  29. package/dist/api-handlers/auth/jwks.js +2 -1
  30. package/dist/api-handlers/auth/login.js +13 -4
  31. package/dist/api-handlers/auth/passkey-signin.js +6 -0
  32. package/dist/api-handlers/auth/refresh.js +38 -11
  33. package/dist/api-handlers/auth/signout.js +2 -1
  34. package/dist/api-handlers/auth/status.js +4 -1
  35. package/dist/api-handlers/auth/update-session.d.ts +25 -4
  36. package/dist/api-handlers/auth/update-session.js +38 -11
  37. package/dist/api-handlers/auth/validate.js +4 -2
  38. package/dist/api-handlers/auth/verify-code.js +3 -2
  39. package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
  40. package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
  41. package/dist/api-handlers/mfa/backup-codes.js +2 -1
  42. package/dist/api-handlers/mfa/methods.js +2 -1
  43. package/dist/api-handlers/mfa/totp-confirm.js +2 -1
  44. package/dist/api-handlers/mfa/totp-enroll.js +2 -1
  45. package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
  46. package/dist/api-handlers/mfa/totp-verify.js +4 -6
  47. package/dist/api-handlers/passkeys/list.js +2 -1
  48. package/dist/api-handlers/passkeys/manage.js +2 -1
  49. package/dist/api-handlers/passkeys/register-options.js +2 -1
  50. package/dist/api-handlers/passkeys/register-verify.js +2 -1
  51. package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
  52. package/dist/api-handlers/session/refresh-viability.js +10 -7
  53. package/dist/api-handlers/session/viability.js +4 -3
  54. package/dist/api-handlers/test/force-expire.js +5 -3
  55. package/dist/auth/auth-decision.js +2 -1
  56. package/dist/auth/better-auth.d.ts +14 -0
  57. package/dist/auth/better-auth.js +35 -49
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/components/admin/DataBrowserTab.js +4 -1
  60. package/dist/components/admin/SessionsTab.js +9 -3
  61. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  62. package/dist/components/recovery/RequestedStep.js +14 -0
  63. package/dist/components/recovery/SetPasswordStep.js +10 -1
  64. package/dist/lib/app-slug.d.ts +21 -0
  65. package/dist/lib/app-slug.js +51 -0
  66. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  67. package/dist/lib/ensure-fresh-access-token.js +105 -4
  68. package/dist/lib/idp-client-config.d.ts +20 -0
  69. package/dist/lib/idp-client-config.js +66 -45
  70. package/dist/lib/idp-fetch.js +1 -1
  71. package/dist/lib/password-rules.d.ts +22 -0
  72. package/dist/lib/password-rules.js +43 -0
  73. package/dist/lib/safe-error-response.d.ts +67 -0
  74. package/dist/lib/safe-error-response.js +130 -0
  75. package/dist/lib/session-cookie-shape.d.ts +22 -19
  76. package/dist/lib/session-cookie-shape.js +14 -18
  77. package/dist/lib/session-store.d.ts +8 -1
  78. package/dist/lib/session-store.js +170 -24
  79. package/dist/lib/startup-init.d.ts +6 -0
  80. package/dist/lib/startup-init.js +30 -4
  81. package/dist/lib/token-lifecycle.js +2 -1
  82. package/dist/lib/token-log-id.d.ts +18 -0
  83. package/dist/lib/token-log-id.js +84 -0
  84. package/dist/lib/two-factor-verify-failure.d.ts +45 -0
  85. package/dist/lib/two-factor-verify-failure.js +143 -0
  86. package/dist/middleware/create-middleware.d.ts +2 -1
  87. package/dist/middleware/create-middleware.js +43 -8
  88. package/dist/pages/recovery/page.js +14 -4
  89. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  90. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  91. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  92. package/dist/pages/test-env/redact-credentials.js +101 -0
  93. package/dist/pages/verify-code/page.js +2 -1
  94. package/dist/routes/auth/logout.d.ts +2 -1
  95. package/dist/routes/auth/logout.js +4 -2
  96. package/dist/routes/auth/session.d.ts +0 -3
  97. package/dist/routes/auth/session.js +16 -10
  98. package/dist/routes/auth/viability.d.ts +3 -2
  99. package/dist/routes/auth/viability.js +11 -7
  100. package/dist/server/auth.js +10 -6
  101. package/dist/server/decode-session.js +15 -26
  102. package/dist/server/verified-session-token.d.ts +13 -0
  103. package/dist/server/verified-session-token.js +103 -0
  104. package/dist/stores/authStore.js +14 -3
  105. package/dist/types/recovery.d.ts +1 -1
  106. package/dist/utils/api.d.ts +18 -0
  107. package/dist/utils/api.js +51 -9
  108. package/dist/vibe/sessions.js +2 -1
  109. package/package.json +2 -1
  110. package/scripts/verify-release-provenance.mjs +410 -0
  111. package/src/api/auth-handler.ts +5 -3
  112. package/src/api-handlers/account/change-password.ts +6 -3
  113. package/src/api-handlers/account/masked-info.ts +2 -1
  114. package/src/api-handlers/account/recovery/forward.ts +117 -0
  115. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  116. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  117. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  118. package/src/api-handlers/account/reset-password.ts +30 -1
  119. package/src/api-handlers/account/send-code.ts +3 -9
  120. package/src/api-handlers/account/update-phone.ts +6 -1
  121. package/src/api-handlers/account/validate-password.ts +3 -1
  122. package/src/api-handlers/account/verify-email.ts +4 -9
  123. package/src/api-handlers/account/verify-sms.ts +4 -9
  124. package/src/api-handlers/admin/analytics.ts +13 -5
  125. package/src/api-handlers/admin/audit.ts +13 -5
  126. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  127. package/src/api-handlers/admin/sessions.ts +15 -7
  128. package/src/api-handlers/admin/site-logs.ts +39 -16
  129. package/src/api-handlers/admin/stats.ts +15 -5
  130. package/src/api-handlers/admin/users.ts +15 -7
  131. package/src/api-handlers/admin/vibe-data.ts +20 -12
  132. package/src/api-handlers/auth/jwks.ts +2 -1
  133. package/src/api-handlers/auth/login.ts +13 -4
  134. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  135. package/src/api-handlers/auth/refresh.ts +38 -11
  136. package/src/api-handlers/auth/signout.ts +2 -1
  137. package/src/api-handlers/auth/status.ts +4 -1
  138. package/src/api-handlers/auth/update-session.ts +45 -11
  139. package/src/api-handlers/auth/validate.ts +4 -2
  140. package/src/api-handlers/auth/verify-code.ts +3 -2
  141. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  142. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  143. package/src/api-handlers/mfa/methods.ts +2 -1
  144. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  145. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  146. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  147. package/src/api-handlers/passkeys/list.ts +2 -1
  148. package/src/api-handlers/passkeys/manage.ts +2 -1
  149. package/src/api-handlers/passkeys/register-options.ts +2 -1
  150. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  151. package/src/api-handlers/session/refresh-viability.ts +10 -7
  152. package/src/api-handlers/session/viability.ts +4 -3
  153. package/src/api-handlers/test/force-expire.ts +5 -3
  154. package/src/auth/auth-decision.ts +2 -1
  155. package/src/auth/better-auth.ts +37 -55
  156. package/src/auth/utils/idp-client.ts +2 -1
  157. package/src/components/admin/DataBrowserTab.tsx +4 -1
  158. package/src/components/admin/SessionsTab.tsx +10 -3
  159. package/src/components/recovery/RequestedStep.tsx +30 -0
  160. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  161. package/src/hooks/useViabilitySession.ts +2 -1
  162. package/src/lib/app-slug.ts +50 -0
  163. package/src/lib/ensure-fresh-access-token.ts +133 -4
  164. package/src/lib/idp-client-config.ts +91 -45
  165. package/src/lib/idp-fetch.ts +1 -1
  166. package/src/lib/password-rules.ts +50 -0
  167. package/src/lib/safe-error-response.ts +126 -0
  168. package/src/lib/session-cookie-shape.ts +23 -22
  169. package/src/lib/session-store.ts +167 -24
  170. package/src/lib/startup-init.ts +29 -4
  171. package/src/lib/token-lifecycle.ts +2 -1
  172. package/src/lib/token-log-id.ts +69 -0
  173. package/src/lib/two-factor-verify-failure.ts +167 -0
  174. package/src/middleware/create-middleware.ts +55 -9
  175. package/src/pages/recovery/page.tsx +21 -4
  176. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  177. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  178. package/src/pages/test-env/redact-credentials.ts +96 -0
  179. package/src/pages/verify-code/page.tsx +2 -1
  180. package/src/routes/auth/logout.ts +4 -2
  181. package/src/routes/auth/session.ts +16 -10
  182. package/src/routes/auth/viability.ts +11 -7
  183. package/src/server/auth.ts +10 -6
  184. package/src/server/decode-session.ts +16 -26
  185. package/src/server/verified-session-token.ts +103 -0
  186. package/src/stores/authStore.ts +16 -5
  187. package/src/types/recovery.ts +1 -1
  188. package/src/utils/api.ts +47 -8
  189. package/src/vibe/sessions.ts +2 -1
@@ -166,6 +166,75 @@ const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
166
166
  const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
167
167
  let operatorOverrideLogged = false;
168
168
 
169
+ // ============================================================================
170
+ // PAY-1460 - SECRETS ARE ENV-FIRST
171
+ // ============================================================================
172
+ /**
173
+ * The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
174
+ * caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
175
+ * them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
176
+ *
177
+ * So both are now resolved ENV FIRST, config second:
178
+ * signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
179
+ * Uses the module-load snapshot above: this module publishes the config value into
180
+ * process.env, so a live read would mistake the IdP's value for the operator's.
181
+ * provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
182
+ * (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
183
+ *
184
+ * The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
185
+ * environment and the config are empty.
186
+ *
187
+ * Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
188
+ * written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
189
+ */
190
+ export type SecretSource = string; // 'env:<VAR>' | 'idp-config' | 'none'
191
+
192
+ export function providerSecretEnvVar(provider: string): string {
193
+ return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
194
+ }
195
+
196
+ const loggedSecretSources = new Map<string, SecretSource>();
197
+ function logSecretSource(what: string, source: SecretSource): void {
198
+ if (loggedSecretSources.get(what) === source) return;
199
+ loggedSecretSources.set(what, source);
200
+ console.log(`[IDP_CONFIG] ${what} source: ${source}`);
201
+ }
202
+
203
+ function resolveAuthSecret(fromConfig: string | undefined): { value: string; source: SecretSource } {
204
+ if (OPERATOR_AUTH_SECRET) return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
205
+ if (OPERATOR_NEXTAUTH_SECRET) return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
206
+ if (fromConfig) return { value: fromConfig, source: 'idp-config' };
207
+ return { value: '', source: 'none' };
208
+ }
209
+
210
+ /** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
211
+ function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
212
+ const auth = resolveAuthSecret(config.authSecret);
213
+ logSecretSource('authSecret', auth.source);
214
+ return {
215
+ ...config,
216
+ authSecret: auth.value,
217
+ oauthProviders: (config.oauthProviders ?? []).map((p) => {
218
+ const envVar = providerSecretEnvVar(p.provider);
219
+ const fromEnv = process.env[envVar];
220
+ const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
221
+ logSecretSource(`${p.provider} clientSecret`, source);
222
+ return { ...p, clientSecret: fromEnv || p.clientSecret };
223
+ }),
224
+ };
225
+ }
226
+
227
+ /** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
228
+ function stripEnvSecrets(config: IDPClientConfig): IDPClientConfig {
229
+ return {
230
+ ...config,
231
+ authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
232
+ oauthProviders: (config.oauthProviders ?? []).map((p) =>
233
+ process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p
234
+ ),
235
+ };
236
+ }
237
+
169
238
  /** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
170
239
  function publishAuthSecret(value: string, source: string): void {
171
240
  if (OPERATOR_AUTH_SECRET) {
@@ -294,7 +363,8 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
294
363
  const key = getRedisConfigKey();
295
364
  const ttlSeconds = config.configCacheTtlSeconds || 300;
296
365
  const data: RedisCachedConfig = {
297
- config,
366
+ // PAY-1460: never persist a secret the environment supplied.
367
+ config: stripEnvSecrets(config),
298
368
  expiresAt: Date.now() + (ttlSeconds * 1000)
299
369
  };
300
370
  // Store with TTL slightly longer than the logical expiry to allow for clock skew
@@ -355,7 +425,10 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
355
425
 
356
426
  // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
357
427
  if (!forceRefresh) {
358
- const redisConfig = await getConfigFromRedis();
428
+ // PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
429
+ // environment is re-applied on read, BEFORE the emptiness test below.
430
+ const cachedRaw = await getConfigFromRedis();
431
+ const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
359
432
  // A cached entry is only USABLE if it carries a secret — see the note
360
433
  // below. `redisConfig && !authSecret` must behave like a cache MISS, so
361
434
  // the emptiness test belongs here, in the condition that gates the
@@ -505,7 +578,7 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
505
578
  // This throw is what stops that. It is also what turned the
506
579
  // 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
507
580
  // of a silently forgeable app.
508
- throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
581
+ throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
509
582
  }
510
583
 
511
584
  // Validated above — only now is it safe to cache or publish.
@@ -588,7 +661,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
588
661
 
589
662
  if (!resp.ok) {
590
663
  const txt = await resp.text().catch(() => 'Unknown error');
591
- throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} - ${txt}`);
664
+ throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
592
665
  }
593
666
 
594
667
  const body: any = await resp.json().catch(() => null);
@@ -603,7 +676,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
603
676
  throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
604
677
  }
605
678
 
606
- const config: IDPClientConfig = {
679
+ const config: IDPClientConfig = applyEnvSecrets({
607
680
  clientId: String(rawClientId),
608
681
  clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
609
682
  // Wire compatibility: accept new authSecret first, fall back to legacy
@@ -669,13 +742,13 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
669
742
  logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
670
743
  },
671
744
  baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
672
- };
745
+ });
673
746
 
674
747
  if (!config.authSecret) {
675
748
  // LOAD-BEARING — see the note on the external path's equivalent throw.
676
749
  // An empty secret reaching betterAuth() becomes the PUBLIC default, not an
677
750
  // error. Never replace this with a fallback.
678
- throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
751
+ throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
679
752
  }
680
753
 
681
754
  console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
@@ -751,7 +824,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
751
824
 
752
825
  if (!signingResp.ok) {
753
826
  const txt = await signingResp.text().catch(() => 'Unknown error');
754
- throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} - ${txt}`);
827
+ // PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
828
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
755
829
  }
756
830
 
757
831
  const signingBody: any = await signingResp.json().catch(() => null);
@@ -768,7 +842,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
768
842
  ) as string | undefined;
769
843
 
770
844
  if (!client_assertion) {
771
- console.error('[IDP_CONFIG] FATAL: Full response body:', JSON.stringify(signingBody, null, 2));
845
+ // PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
846
+ console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
772
847
  throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
773
848
  }
774
849
 
@@ -783,43 +858,13 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
783
858
  'X-Client-Id': clientIdStr,
784
859
  'X-Correlation-Id': randomUUID().replace(/-/g, ''),
785
860
  },
786
- // 217917 PHASE 1: explicit opt-in for provider secrets.
787
- //
788
- // This broker is the ONLY consumer of oauth_providers[].client_secret
789
- // (DotNetPert enumerated it: this file feeds auth/providers/oauth.ts,
790
- // which performs the server-side code exchange). Every OTHER caller of
791
- // ExternalAuth/client-config - notably every fielded acp-desktop - is
792
- // pushed the same secrets today and reads none of them: the desktop is
793
- // PKCE and dropped clientSecret from OAuthProviderConfig entirely.
794
- //
795
- // The IDP IGNORES this field today, so shipping it is a no-op and needs
796
- // no coordination window. Phase 2 (DotNetPert, IDP side) makes
797
- // client-config emit the secrets ONLY when the opt-in is present and
798
- // redact otherwise - which is why phase 1 MUST be live first, or the
799
- // broker loses the field and tenant OAuth sign-in dies.
800
- //
801
- // SCOPE, deliberately narrow - do NOT read this as a partial migration:
802
- // the sibling path fetchConfigFromInternalIDP() hits a DIFFERENT
803
- // endpoint (InternalClientConfig/{id}, GET) that is already
804
- // authenticated with CONTAINERS_KEY, is not reachable by a desktop, and
805
- // is therefore not part of 217917's exposure. It deliberately sends no
806
- // opt-in. If phase 2's redaction is implemented in shared config-
807
- // assembly code rather than in ExternalAuthController.GetClientConfig,
808
- // it will ALSO strip the internal route and break this broker's other
809
- // path with no opt-in to save it. Phase 2 must gate on the request.
810
- //
811
- // NOT A FIX ON ITS OWN: an attacker can set this flag too. Phase 2 ends
812
- // the AUTOMATIC delivery of every tenant's provider secrets to every
813
- // user laptop; it does not close disclosure to a determined caller.
814
- // That residual is card 175047 (unauthenticated mint, global partner
815
- // key) and 217917 does not close until 175047 lands or Jon accepts it.
816
- body: JSON.stringify({ client_assertion, include_provider_secrets: true }),
861
+ body: JSON.stringify({ client_assertion }),
817
862
  cache: 'no-store'
818
863
  } as RequestInit);
819
864
 
820
865
  if (!configResp.ok) {
821
866
  const txt = await configResp.text().catch(() => 'Unknown error');
822
- throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} - ${txt}`);
867
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
823
868
  }
824
869
 
825
870
  const configBody: any = await configResp.json().catch(() => null);
@@ -832,7 +877,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
832
877
  const configData = configBody?.data;
833
878
 
834
879
  if (!configData || typeof configData !== 'object') {
835
- console.error('[IDP_CONFIG] FATAL: Full config response body:', JSON.stringify(configBody, null, 2));
880
+ // PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
881
+ console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
836
882
  throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
837
883
  }
838
884
 
@@ -845,7 +891,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
845
891
  // Map response to our interface (IDP returns camelCase or snake_case).
846
892
  // Wire compatibility: accept new authSecret first, fall back to legacy
847
893
  // nextAuthSecret/next_auth_secret while IDP rename rolls out.
848
- const config: IDPClientConfig = {
894
+ const config: IDPClientConfig = applyEnvSecrets({
849
895
  clientId: String(rawClientId),
850
896
  clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
851
897
  authSecret:
@@ -919,7 +965,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
919
965
  logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
920
966
  },
921
967
  baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
922
- };
968
+ });
923
969
 
924
970
  // Debug: log what we got for baseClientUrl
925
971
  console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
@@ -931,7 +977,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
931
977
  if (!config.authSecret) {
932
978
  // LOAD-BEARING — empty must fail here, never downstream. betterAuth()
933
979
  // silently substitutes a public constant for an empty secret.
934
- throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
980
+ throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
935
981
  }
936
982
 
937
983
  // Success - reset failure tracking
@@ -58,7 +58,7 @@ async function ensureFreshAccessToken(req: NextRequest): Promise<{ sessionToken?
58
58
  const sessionToken = (token?.sessionToken || token?.redisSessionId) as string | undefined;
59
59
  console.log('[IDP_FETCH] ensureFreshAccessToken:', {
60
60
  hasToken: !!token,
61
- sessionToken: sessionToken?.substring(0, 20) + '...',
61
+ hasSessionToken: !!sessionToken, // PAY-1427 / BAPert 58829: no token material in logs, not even a stub (was a 20-char prefix)
62
62
  pathname: req.nextUrl.pathname
63
63
  });
64
64
  if (!sessionToken) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PAY-1591: the one sentence that states the password rules on the recovery set-password step.
3
+ *
4
+ * Built ONLY from the policy the IdP reported (usePasswordValidation.policy). A rule that is off is not stated, and with
5
+ * no policy at all we state no number: telling a user "at least 8 characters" when the client is configured for 12 is
6
+ * the same defect as saying nothing, one step later.
7
+ */
8
+ export interface PasswordPolicyLike {
9
+ min_length?: number;
10
+ require_uppercase?: boolean;
11
+ require_lowercase?: boolean;
12
+ require_digit?: boolean;
13
+ require_special?: boolean;
14
+ }
15
+
16
+ export function passwordRulesText(policy: PasswordPolicyLike | undefined | null): string {
17
+ if (!policy || typeof policy.min_length !== 'number') {
18
+ return "Use a password that meets this site's rules. We'll show what is missing as you type.";
19
+ }
20
+ const parts = [`at least ${policy.min_length} characters`];
21
+ if (policy.require_uppercase) parts.push('an upper-case letter');
22
+ if (policy.require_lowercase) parts.push('a lower-case letter');
23
+ if (policy.require_digit) parts.push('a number');
24
+ if (policy.require_special) parts.push('a symbol');
25
+ const [first, ...rest] = parts;
26
+ return rest.length ? `Your password needs ${first}, with ${rest.join(', ')}.` : `Your password needs ${first}.`;
27
+ }
28
+
29
+ /**
30
+ * PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
31
+ * portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
32
+ */
33
+ export const PASSWORD_RULE_SENTENCES: Readonly<Record<string, string>> = {
34
+ min_length: 'Use a longer password.',
35
+ uppercase: 'Add an upper-case letter.',
36
+ lowercase: 'Add a lower-case letter.',
37
+ digit: 'Add a number.',
38
+ special: 'Add a symbol.',
39
+ unique_chars: 'Use more different characters.',
40
+ recent_password: "Choose a password you haven't used recently.",
41
+ };
42
+
43
+ export function knownPasswordRules(rules: unknown): string[] {
44
+ if (!Array.isArray(rules)) return [];
45
+ return rules.filter((r): r is string => typeof r === 'string' && Object.prototype.hasOwnProperty.call(PASSWORD_RULE_SENTENCES, r));
46
+ }
47
+
48
+ export function passwordRuleSentences(rules: string[]): string {
49
+ return rules.map((r) => PASSWORD_RULE_SENTENCES[r]).join(' ');
50
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * PAY-1438: what a USER-FACING handler may put in an error response.
3
+ *
4
+ * Several of these routes are reachable before authentication (session check, viability probe,
5
+ * logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
6
+ *
7
+ * Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
8
+ * JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
9
+ * a code and a correlationId; the log line carries the exception and the same id.
10
+ *
11
+ * Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
12
+ * never passed through whole. Only the display contract is projected: code, message, resolution,
13
+ * request_id - each only when it is a string. `details` (free-form, may carry validation internals
14
+ * or exception data) is dropped, except for the password-policy messages change-password's UI reads.
15
+ */
16
+
17
+ import { knownPasswordRules } from './password-rules';
18
+
19
+ export interface IdpErrorProjection {
20
+ code?: string;
21
+ message?: string;
22
+ resolution?: string;
23
+ request_id?: string;
24
+ }
25
+
26
+ const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
27
+
28
+ /** Log an exception with a fresh correlationId and return the id for the response body. */
29
+ export function logExceptionWithCorrelation(tag: string, err: unknown): string {
30
+ const correlationId = globalThis.crypto.randomUUID();
31
+ console.error(tag, { correlationId, error: err });
32
+ return correlationId;
33
+ }
34
+
35
+ /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
36
+ export function projectIdpError(json: unknown): IdpErrorProjection {
37
+ if (!json || typeof json !== 'object') return {};
38
+ const top = json as Record<string, unknown>;
39
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
40
+ const support = err.support && typeof err.support === 'object' ? (err.support as Record<string, unknown>) : {};
41
+ const out: IdpErrorProjection = {};
42
+ const code = str(err.code) ?? str(top.code) ?? str(top.error_code);
43
+ const message = str(err.message) ?? str(top.message);
44
+ const resolution = str(err.resolution) ?? str(top.resolution);
45
+ const requestId = str(support.request_id) ?? str(top.request_id);
46
+ if (code !== undefined) out.code = code;
47
+ if (message !== undefined) out.message = message;
48
+ if (resolution !== undefined) out.resolution = resolution;
49
+ if (requestId !== undefined) out.request_id = requestId;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * change-password's `details`: the security pages show `details.value[0].message` (else
55
+ * `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
56
+ * strings; drop everything else.
57
+ */
58
+ export function projectPasswordPolicyDetails(details: unknown): { message?: string; value?: Array<{ message: string }> } | undefined {
59
+ if (!details || typeof details !== 'object') return undefined;
60
+ const d = details as Record<string, unknown>;
61
+ const out: { message?: string; value?: Array<{ message: string }> } = {};
62
+ if (Array.isArray(d.value)) {
63
+ const value = d.value
64
+ .map((v) => (v && typeof v === 'object' ? str((v as Record<string, unknown>).message) : undefined))
65
+ .filter((m): m is string => m !== undefined)
66
+ .map((message) => ({ message }));
67
+ if (value.length) out.value = value;
68
+ }
69
+ const message = str(d.message);
70
+ if (message !== undefined) out.message = message;
71
+ return out.value || out.message !== undefined ? out : undefined;
72
+ }
73
+
74
+ /**
75
+ * For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
76
+ * reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
77
+ * SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
78
+ * whitelisted projection. The success path keeps its passthrough.
79
+ */
80
+ export function idpErrorEnvelope(json: unknown, fallbackMessage = 'Request failed'): {
81
+ success: false;
82
+ message: string;
83
+ error: { code?: string; message: string; resolution?: string };
84
+ request_id?: string;
85
+ } {
86
+ const p = projectIdpError(json);
87
+ const message = p.message ?? fallbackMessage;
88
+ const error: { code?: string; message: string; resolution?: string } = { message };
89
+ if (p.code !== undefined) error.code = p.code;
90
+ if (p.resolution !== undefined) error.resolution = p.resolution;
91
+ const out: { success: false; message: string; error: typeof error; request_id?: string } = { success: false, message, error };
92
+ if (p.request_id !== undefined) out.request_id = p.request_id;
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
98
+ * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
99
+ * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
100
+ * (the caller must be able to tell a refused password from a dead recovery session).
101
+ */
102
+ /**
103
+ * True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
104
+ * ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
105
+ * expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
106
+ * Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
107
+ * no code at all, and calling that 'link expired' loops the user.
108
+ */
109
+ export function projectResetTokenInvalid(json: unknown): boolean {
110
+ if (!json || typeof json !== 'object') return false;
111
+ const top = json as Record<string, unknown>;
112
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
113
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
114
+ if (!details) return false;
115
+ if (details.code === 'token_expired' || details.code === 'token_used') return true;
116
+ return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && (v as Record<string, unknown>).code === 'InvalidToken');
117
+ }
118
+
119
+ export function projectPasswordPolicyRules(json: unknown): string[] | undefined {
120
+ if (!json || typeof json !== 'object') return undefined;
121
+ const top = json as Record<string, unknown>;
122
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
123
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
124
+ if (!details || details.code !== 'password_policy') return undefined;
125
+ return knownPasswordRules(details.rules);
126
+ }
@@ -39,11 +39,16 @@ import {
39
39
  * instead of degrading to one clean Redis-canonical lookup (or a genuine
40
40
  * 401 if the record really is gone).
41
41
  *
42
- * No signature is verified for the BA-hmac shape here — same posture the
43
- * pre-existing JWS fallback already took (see auth.ts's `getSession` Path B
44
- * comment): Redis is the liveness authority downstream, so a forged token
45
- * still has to land on a live session record to do anything. This function
46
- * only recovers the CANDIDATE token; callers must still look it up.
42
+ * No signature is verified here, and none CAN be: this module is secret-free
43
+ * and synchronous. It only CLASSIFIES and recovers the candidate token.
44
+ *
45
+ * PAY-1476: the earlier claim that "Redis is the liveness authority, so a
46
+ * forged token still has to land on a live session record" was the defect.
47
+ * A LEAKED token lands on a live record by definition, so without a signature
48
+ * check the token string alone was a bearer credential. The candidate token
49
+ * returned here must NEVER be handed to the session store directly: go through
50
+ * server/verified-session-token.ts resolveVerifiedSessionToken, which verifies
51
+ * the HMAC (ba-hmac) or the JWS signature first.
47
52
  */
48
53
 
49
54
  export type SessionCookieShape = 'jws' | 'ba-hmac';
@@ -90,20 +95,18 @@ export function extractSessionTokenFromCookieValue(rawValue: string | null | und
90
95
  return null;
91
96
  }
92
97
 
93
- /** Extract the session token from the Better Auth session cookie WITHOUT
94
- * going through Better Auth's `auth.api.getSession()`. Handles both
95
- * single-cookie and chunked-cookie cases (Better Auth chunks long values
96
- * across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
97
- * has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
98
- * `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
99
- * the shape-blind version of this function is what turned a single
100
- * BA-primary miss into total, secret-independent fallback failure for
101
- * every current cookie, since Better Auth never mints the JWS shape this
102
- * used to require). No signature check — Redis is the liveness gate, so
103
- * an attacker forging a token still has to land on a real Redis session
104
- * (infeasible against high-entropy UUIDs). Returns null if no cookie is
105
- * present or neither known shape matches. */
106
- export function extractSessionTokenFromCookie(request: Request): string | null {
98
+ /** The RAW session-cookie value from a Request, WITHOUT going through Better
99
+ * Auth's `auth.api.getSession()`. Handles single and chunked cookies (Better
100
+ * Auth chunks long values across `{name}.0`, `{name}.1`, …) and every name the
101
+ * cookie is minted under (240654).
102
+ *
103
+ * PAY-1476: this used to be `extractSessionTokenFromCookie`, which returned the
104
+ * TOKEN with "No signature check — Redis is the liveness gate". That turned any
105
+ * leaked token into a bearer credential. It now returns the raw value only, so
106
+ * the one way to get a token out of a request cookie is
107
+ * server/verified-session-token.ts resolveVerifiedSessionTokenFromRequest.
108
+ * Returns null if no session cookie is present. */
109
+ export function extractSessionCookieValue(request: Request): string | null {
107
110
  const cookieHeader = request.headers.get('cookie');
108
111
  if (!cookieHeader) return null;
109
112
 
@@ -178,7 +181,5 @@ export function extractSessionTokenFromCookie(request: Request): string | null {
178
181
  }
179
182
  }
180
183
 
181
- if (!rawJwt) return null;
182
-
183
- return extractSessionTokenFromCookieValue(rawJwt)?.token ?? null;
184
+ return rawJwt || null;
184
185
  }