@payez/next-mvp 4.12.1 → 4.13.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 (115) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/api-handlers/account/profile.d.ts +2 -39
  3. package/dist/api-handlers/account/profile.js +5 -2
  4. package/dist/api-handlers/anon/preferences.d.ts +1 -14
  5. package/dist/api-handlers/anon/preferences.js +5 -5
  6. package/dist/api-handlers/auth/credential-login.js +28 -4
  7. package/dist/api-handlers/health.d.ts +5 -0
  8. package/dist/api-handlers/health.js +48 -0
  9. package/dist/api-handlers/mfa/totp-confirm.js +8 -4
  10. package/dist/api-handlers/mfa/totp-enroll.js +8 -4
  11. package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
  12. package/dist/api-handlers/session/refresh-viability.js +5 -2
  13. package/dist/api-handlers/session/viability.d.ts +3 -8
  14. package/dist/api-handlers/session/viability.js +11 -4
  15. package/dist/auth/better-auth-exposure.d.ts +11 -0
  16. package/dist/auth/better-auth-exposure.js +73 -0
  17. package/dist/auth/better-auth.d.ts +6 -0
  18. package/dist/auth/better-auth.js +42 -3
  19. package/dist/auth/route-config.js +9 -0
  20. package/dist/auth/session-reset-link.d.ts +32 -0
  21. package/dist/auth/session-reset-link.js +45 -1
  22. package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
  23. package/dist/components/auth/CredentialSignInForm.js +13 -4
  24. package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
  25. package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
  26. package/dist/components/mfa/QrCode.d.ts +6 -0
  27. package/dist/components/mfa/QrCode.js +14 -0
  28. package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
  29. package/dist/components/mfa/TotpEnrollPanel.js +117 -0
  30. package/dist/edge/middleware.d.ts +1 -1
  31. package/dist/edge/middleware.js +2 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/lib/credential-sign-in-client.d.ts +26 -1
  35. package/dist/lib/credential-sign-in-client.js +32 -6
  36. package/dist/lib/idp-client-config.js +4 -1
  37. package/dist/lib/login-error-message.d.ts +3 -0
  38. package/dist/lib/login-error-message.js +12 -1
  39. package/dist/lib/mfa-json-guard.d.ts +12 -0
  40. package/dist/lib/mfa-json-guard.js +19 -0
  41. package/dist/lib/nextauth-secret.js +3 -1
  42. package/dist/lib/no-store.d.ts +13 -0
  43. package/dist/lib/no-store.js +35 -0
  44. package/dist/lib/payez-client-secret.d.ts +14 -0
  45. package/dist/lib/payez-client-secret.js +41 -0
  46. package/dist/lib/qrcode.d.ts +22 -0
  47. package/dist/lib/qrcode.js +362 -0
  48. package/dist/lib/sign-in-failure.d.ts +35 -0
  49. package/dist/lib/sign-in-failure.js +85 -0
  50. package/dist/lib/twofa-rejection.d.ts +2 -0
  51. package/dist/lib/twofa-rejection.js +5 -2
  52. package/dist/middleware/create-middleware.d.ts +24 -0
  53. package/dist/middleware/create-middleware.js +56 -9
  54. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  55. package/dist/middleware/prefetch-guard-status.js +42 -0
  56. package/dist/pages/enroll/page.d.ts +7 -0
  57. package/dist/pages/enroll/page.js +15 -0
  58. package/dist/pages/login/page.js +1 -1
  59. package/dist/routes/auth/session-reset.d.ts +11 -1
  60. package/dist/routes/auth/session-reset.js +38 -13
  61. package/dist/routes/auth/session.d.ts +2 -9
  62. package/dist/routes/auth/session.js +5 -2
  63. package/dist/routes/auth/viability.d.ts +3 -54
  64. package/dist/routes/auth/viability.js +16 -4
  65. package/dist/routes/health.d.ts +1 -0
  66. package/dist/routes/health.js +16 -0
  67. package/dist/routes/mfa/methods.d.ts +2 -2
  68. package/dist/routes/mfa/methods.js +3 -1
  69. package/dist/routes/passkeys/list.d.ts +2 -10
  70. package/dist/routes/passkeys/list.js +4 -2
  71. package/dist/routes/session/refresh-viability.d.ts +2 -1
  72. package/dist/routes/session/refresh-viability.js +4 -2
  73. package/dist/utils/api.d.ts +33 -6
  74. package/dist/utils/api.js +35 -2
  75. package/package.json +21 -1
  76. package/src/api-handlers/account/profile.ts +5 -1
  77. package/src/api-handlers/anon/preferences.ts +5 -4
  78. package/src/api-handlers/auth/credential-login.ts +29 -4
  79. package/src/api-handlers/health.ts +53 -0
  80. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  81. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  82. package/src/api-handlers/session/refresh-viability.ts +5 -1
  83. package/src/api-handlers/session/viability.ts +12 -4
  84. package/src/auth/better-auth-exposure.ts +69 -0
  85. package/src/auth/better-auth.ts +42 -3
  86. package/src/auth/route-config.ts +9 -0
  87. package/src/auth/session-reset-link.ts +47 -0
  88. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  89. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  90. package/src/components/mfa/QrCode.tsx +24 -0
  91. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  92. package/src/edge/middleware.ts +1 -1
  93. package/src/index.ts +1 -1
  94. package/src/lib/credential-sign-in-client.ts +48 -8
  95. package/src/lib/idp-client-config.ts +5 -1
  96. package/src/lib/login-error-message.ts +13 -1
  97. package/src/lib/mfa-json-guard.ts +19 -0
  98. package/src/lib/nextauth-secret.ts +3 -1
  99. package/src/lib/no-store.ts +30 -0
  100. package/src/lib/payez-client-secret.ts +39 -0
  101. package/src/lib/qrcode.ts +348 -0
  102. package/src/lib/sign-in-failure.ts +88 -0
  103. package/src/lib/twofa-rejection.ts +7 -2
  104. package/src/middleware/create-middleware.ts +69 -9
  105. package/src/middleware/prefetch-guard-status.ts +56 -0
  106. package/src/pages/enroll/page.tsx +22 -0
  107. package/src/pages/login/page.tsx +2 -1
  108. package/src/routes/auth/session-reset.ts +46 -11
  109. package/src/routes/auth/session.ts +5 -1
  110. package/src/routes/auth/viability.ts +16 -4
  111. package/src/routes/health.ts +14 -0
  112. package/src/routes/mfa/methods.ts +3 -1
  113. package/src/routes/passkeys/list.ts +5 -1
  114. package/src/routes/session/refresh-viability.ts +5 -1
  115. package/src/utils/api.ts +50 -8
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
+ import { noStore } from '../../lib/no-store';
4
5
 
5
6
  /**
6
7
  * Account Profile API Handler
@@ -41,7 +42,7 @@ import { ENV_CONFIG } from '../../config/env';
41
42
  * @package @payez/next-mvp
42
43
  */
43
44
 
44
- export async function GET(req: NextRequest) {
45
+ async function getProfile(req: NextRequest) {
45
46
  const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/profile`, {
46
47
  method: 'GET',
47
48
  headers: { 'Content-Type': 'application/json' },
@@ -65,6 +66,9 @@ export async function GET(req: NextRequest) {
65
66
  return NextResponse.json(result.json, { status: 200 });
66
67
  }
67
68
 
69
+ /** PAY-2994: the profile is one person's data; never cached (noStore). */
70
+ export const GET = noStore(getProfile);
71
+
68
72
  export async function PUT(req: NextRequest) {
69
73
  let body = '{}';
70
74
  try {
@@ -16,13 +16,12 @@ import {
16
16
  AnonSessionPreferences,
17
17
  } from '../../lib/anon-session';
18
18
  import { errForLog } from '../../lib/log-safe';
19
+ import { noStore } from '../../lib/no-store';
19
20
 
20
21
  const COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
21
22
 
22
- /**
23
- * GET handler - retrieves anonymous session preferences
24
- */
25
- export async function GET(request: NextRequest) {
23
+ /** Retrieves anonymous session preferences. Exported as GET below, wrapped in noStore (PAY-2994). */
24
+ async function getPreferences(request: NextRequest) {
26
25
  try {
27
26
  const cookieStore = await cookies();
28
27
  let anonId = cookieStore.get(ANON_COOKIE_NAME)?.value;
@@ -64,6 +63,8 @@ export async function GET(request: NextRequest) {
64
63
  /**
65
64
  * POST handler - updates anonymous session preferences
66
65
  */
66
+ export const GET = noStore(getPreferences);
67
+
67
68
  export async function POST(request: NextRequest) {
68
69
  try {
69
70
  const cookieStore = await cookies();
@@ -38,6 +38,7 @@
38
38
  * lib/session-store.js").
39
39
  */
40
40
  import { NextRequest, NextResponse } from 'next/server';
41
+ import { capPolicyDetail, classifySignInFailure, signInFailureMessage, type SignInFailureCode } from '../../lib/sign-in-failure';
41
42
  import { SignJWT } from 'jose';
42
43
  import { getIDPClientConfig } from '../../lib/idp-client-config';
43
44
  import { resolveClientKey } from '../../lib/client-key';
@@ -325,10 +326,15 @@ export async function POST(req: NextRequest) {
325
326
  // (MfaController.VerifyTotp answers a 15-minute lockout as 400 {code:"locked"}), which status-only
326
327
  // classification told to "try again".
327
328
  const rejection = classifyTwofaRejection(laneResults, idpResponse.headers.get('retry-after'));
329
+ // PAY-2568 follow-up (BAPert 85795): the page takes its sentence from a CODE in the closed table, so this lane must send one.
330
+ // The branch already decided the cause (classifyTwofaRejection); the code follows its status, the IdP's wording is never used.
331
+ const rejectionCode: SignInFailureCode =
332
+ rejection.status === 429 ? (rejection.rateLimited ? 'second_factor_rate_limited' : 'too_many_attempts') : rejection.status === 502 ? 'second_factor_unavailable' : 'invalid_second_factor';
328
333
  return NextResponse.json(
329
334
  {
330
335
  success: false,
331
- error: rejection.error,
336
+ code: rejectionCode,
337
+ error: signInFailureMessage(rejectionCode),
332
338
  ...(rejection.retryAfter !== undefined ? { retryAfter: rejection.retryAfter } : {}),
333
339
  // PAY-2284: the card shows a real "email me a code" action only when this is true.
334
340
  ...(rejection.offerEmailCode ? { offerEmailCode: true } : {}),
@@ -389,19 +395,25 @@ export async function POST(req: NextRequest) {
389
395
  if (idpResponse.status === 429) {
390
396
  const retryAfter = idpResponse.headers.get('retry-after');
391
397
  return NextResponse.json(
392
- { success: false, error: 'Too many attempts. Please try again later.', retryAfter: retryAfter ? parseInt(retryAfter, 10) : 60 },
398
+ {
399
+ success: false,
400
+ code: 'too_many_attempts',
401
+ error: signInFailureMessage('too_many_attempts'),
402
+ // BAPert 85807: the page prints this number as minutes, so only a real Retry-After is sent; no invented default.
403
+ ...(retryAfter && Number.isFinite(parseInt(retryAfter, 10)) && parseInt(retryAfter, 10) > 0 ? { retryAfter: parseInt(retryAfter, 10) } : {}),
404
+ },
393
405
  { status: 429 }
394
406
  );
395
407
  }
396
408
 
397
409
  if (!idpResponse.ok) {
398
410
  const f = logIdpAuthRefusal('non-ok', idpResponse.status, data);
399
- return NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: idpResponse.status });
411
+ return NextResponse.json(refusalBody(f, idpResponse.status), { status: idpResponse.status });
400
412
  }
401
413
 
402
414
  if (data?.success === false) {
403
415
  const f = logIdpAuthRefusal('envelope-success-false', idpResponse.status, data);
404
- return NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: 200 });
416
+ return NextResponse.json(refusalBody(f, idpResponse.status), { status: 200 });
405
417
  }
406
418
 
407
419
  // Unwrap the IDP's canonical envelope, same shape login.js normalizes.
@@ -761,6 +773,19 @@ function publicRefusalMessage(f: IdpFailure): string {
761
773
  return UNIFORM_REFUSAL;
762
774
  }
763
775
 
776
+ /**
777
+ * The browser-facing body of a refusal (BAPert 85415): a stable machine `code` from the closed table in lib/sign-in-failure, and
778
+ * the fixed sentence for it. The IdP's own message text is never copied in. The one exception is the allowlisted policy refusals
779
+ * (SsoRequired / SsoRequiredBreakGlass / InvalidRequest): a property of the email domain or the request, not of an account, and the
780
+ * user cannot proceed without being told; they carry code 'policy_message' and the allowlisted detail (see SAFE_TO_SHOW).
781
+ */
782
+ function refusalBody(f: IdpFailure, status: number): { success: false; code: string; error: string } {
783
+ const policy = f.code && SAFE_TO_SHOW.has(f.code) ? capPolicyDetail(f.detail) : null;
784
+ if (policy) return { success: false, code: 'policy_message', error: policy };
785
+ const code = classifySignInFailure(status, f.code);
786
+ return { success: false, code, error: signInFailureMessage(code) as string };
787
+ }
788
+
764
789
  function logIdpAuthRefusal(branch: string, httpStatus: number, data: unknown): IdpFailure {
765
790
  const f = extractIdpFailure(data);
766
791
  const d = (data ?? {}) as Record<string, unknown>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * /api/health handler (PAY-2804, AUTH-045; BAPert ruling 88065).
3
+ *
4
+ * Sessions are Redis-only. The body reports state ONLY: `redis: 'up' | 'down' | 'unknown'` and
5
+ * `status: 'ok' | 'degraded'`. No REDIS_URL, host, port, error text or stack, to any caller (anonymous route).
6
+ *
7
+ * The HTTP status is 200 even when Redis is down: this route is the ALB target-group check, and a 503
8
+ * there would make the ALB pull every task for an outage that replacing tasks cannot fix.
9
+ * HEALTH_REDIS_FAILS_STATUS=1 turns Redis-down into 503 as a READINESS setting for a deployer's OWN probe;
10
+ * it is default off and must never be set on a load-balancer health check.
11
+ */
12
+ import { NextResponse } from 'next/server';
13
+
14
+ export interface HealthRedis {
15
+ ping(): Promise<unknown>;
16
+ }
17
+
18
+ export type RedisState = 'up' | 'down' | 'unknown';
19
+
20
+ const PING_BUDGET_MS = 2000;
21
+
22
+ async function probe(redis: HealthRedis): Promise<RedisState> {
23
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
+ try {
25
+ const timeout = new Promise<never>((_, reject) => {
26
+ timer = setTimeout(() => reject(new Error('ping timeout')), PING_BUDGET_MS);
27
+ });
28
+ await Promise.race([redis.ping(), timeout]);
29
+ return 'up';
30
+ } catch {
31
+ return 'down';
32
+ } finally {
33
+ if (timer) clearTimeout(timer);
34
+ }
35
+ }
36
+
37
+ export function createHealthHandler(getRedis: () => HealthRedis) {
38
+ return async function GET(): Promise<Response> {
39
+ let redis: RedisState;
40
+ let client: HealthRedis | undefined;
41
+ try {
42
+ client = getRedis();
43
+ } catch {
44
+ client = undefined;
45
+ }
46
+ redis = client ? await probe(client) : 'unknown';
47
+ const failsStatus = process.env.HEALTH_REDIS_FAILS_STATUS === '1';
48
+ return NextResponse.json(
49
+ { status: redis === 'up' ? 'ok' : 'degraded', redis },
50
+ { status: redis !== 'up' && failsStatus ? 503 : 200, headers: { 'Cache-Control': 'no-store' } },
51
+ );
52
+ };
53
+ }
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
4
  import { projectIdpError } from '../../lib/safe-error-response';
5
+ import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
5
6
 
6
7
  /**
7
8
  * Confirm TOTP enrolment with the first authenticator code
@@ -10,6 +11,8 @@ import { projectIdpError } from '../../lib/safe-error-response';
10
11
  * the method ACTIVE.
11
12
  */
12
13
  export async function POST(req: NextRequest) {
14
+ const refused = refuseNonJson(req);
15
+ if (refused) return refused;
13
16
  let body = '{}';
14
17
  try {
15
18
  const raw = await req.text();
@@ -30,14 +33,14 @@ export async function POST(req: NextRequest) {
30
33
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
31
34
  meta: { attemptedRefresh: result.attemptedRefresh },
32
35
  },
33
- { status: result.status },
36
+ { status: result.status, headers: NO_STORE },
34
37
  );
35
38
  }
36
39
 
37
40
  const bodyJson: any = result.json;
38
41
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
39
- if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
40
- return NextResponse.json(bodyJson, { status: 200 });
42
+ if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
43
+ return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
41
44
  }
42
- return NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
43
46
  }
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
4
  import { projectIdpError } from '../../lib/safe-error-response';
5
+ import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
5
6
 
6
7
  /**
7
8
  * Begin TOTP enrolment (POST /api/Account/mfa/totp/enroll, user JWT).
@@ -10,6 +11,8 @@ import { projectIdpError } from '../../lib/safe-error-response';
10
11
  * as "Active" (MFA honesty fixes, 2026-08-01).
11
12
  */
12
13
  export async function POST(req: NextRequest) {
14
+ const refused = refuseNonJson(req);
15
+ if (refused) return refused;
13
16
  let body = '{}';
14
17
  try {
15
18
  const raw = await req.text();
@@ -30,14 +33,14 @@ export async function POST(req: NextRequest) {
30
33
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
31
34
  meta: { attemptedRefresh: result.attemptedRefresh },
32
35
  },
33
- { status: result.status },
36
+ { status: result.status, headers: NO_STORE },
34
37
  );
35
38
  }
36
39
 
37
40
  const bodyJson: any = result.json;
38
41
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
39
- if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
40
- return NextResponse.json(bodyJson, { status: 200 });
42
+ if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
43
+ return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
41
44
  }
42
- return NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
43
46
  }
@@ -5,9 +5,10 @@ import { getTokenTestAware } from '../../lib/test-aware-get-token';
5
5
  import { logger } from '../../config/logger';
6
6
  import { tokenLogId } from '../../lib/token-log-id';
7
7
  import { errForLog } from '../../lib/log-safe';
8
+ import { noStore } from '../../lib/no-store';
8
9
  import { resolveSessionState } from '../../server/auth';
9
10
 
10
- export async function GET(req: NextRequest) {
11
+ async function getRefreshViability(req: NextRequest) {
11
12
  try {
12
13
  const sessionToken = req.nextUrl.searchParams.get('token');
13
14
  let finalSessionToken = sessionToken;
@@ -55,3 +56,6 @@ export async function GET(req: NextRequest) {
55
56
  return NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
56
57
  }
57
58
  }
59
+
60
+ /** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
61
+ export const GET = noStore(getRefreshViability);
@@ -13,8 +13,9 @@ import { getIDPClientConfig } from '../../lib/idp-client-config';
13
13
  import { tokenLogId } from '../../lib/token-log-id';
14
14
  import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
15
15
  import { errForLog } from '../../lib/log-safe';
16
+ import { noStore } from '../../lib/no-store';
16
17
 
17
- export async function GET(req: NextRequest) {
18
+ async function getViability(req: NextRequest) {
18
19
  try {
19
20
  // Ensure initialization is complete (auth signing secret resolved from IDP)
20
21
  if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
@@ -76,6 +77,8 @@ export async function GET(req: NextRequest) {
76
77
  if (betterAuthSession && sessionToken) {
77
78
  // Try legacy session store first, then Better Auth format
78
79
  let sessionData = await getRedisSession(sessionToken);
80
+ // PAY-2467: see routes/auth/viability.ts - a boolean mfaVerified on the raw canonical row is a measurement.
81
+ const fromCanonicalStore = !!sessionData;
79
82
  if (!sessionData) {
80
83
  // Better Auth stores sessions with ba:{appSlug}:{token} prefix
81
84
  sessionData = await getBetterAuthSession(sessionToken);
@@ -127,9 +130,11 @@ export async function GET(req: NextRequest) {
127
130
  // keep gating on `mfaVerified`, which stays fail-closed at all five sites. R807 is
128
131
  // void if this signal reaches an auth decision.
129
132
  if (
130
- sessionData.mfaSignalPresent === true &&
133
+ (sessionData.mfaSignalPresent === true || (fromCanonicalStore && typeof sessionData.mfaVerified === 'boolean')) &&
131
134
  sessionData.mfaVerified === false &&
132
- !sessionData.idpRefreshToken
135
+ !sessionData.idpRefreshToken &&
136
+ // same exception as routes/auth/viability: an IdP-discarded refresh token is a dead session, not a pending second factor
137
+ !String((sessionData as any)?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN')
133
138
  ) {
134
139
  requires2FA = true;
135
140
  }
@@ -237,4 +242,7 @@ export async function GET(req: NextRequest) {
237
242
  console.error('[API Viability] Error checking session viability:', errForLog(error));
238
243
  return NextResponse.json({ authenticated: false }, { status: 500 });
239
244
  }
240
- }
245
+ }
246
+
247
+ /** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
248
+ export const GET = noStore(getViability);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * PAY-2592 slice 1: the exposure guard for better-auth GHSA-965c-763c-88jm (critical, CVSS 9.1; affected >=1.4.0-beta.18 <1.7.7).
3
+ *
4
+ * "OAuth state can be used as a Magic Link to sign in as another user." It needs ALL of: the Magic Link plugin enabled, an OAuth provider, and OAuth state
5
+ * stored with `account.storeStateStrategy: "database"` (better-auth's default when a database OR secondaryStorage exists). This package has secondaryStorage,
6
+ * so what keeps it unexposed is, today:
7
+ * 1. `account.storeStateStrategy: 'cookie'` is pinned in better-auth.ts (the advisory: cookie-backed state blocks the path), and
8
+ * 2. the magic-link plugin registers ONLY when a consumer passes `opts.magicLink`.
9
+ * Nothing in this package fixes the flaw; the fix is better-auth >= 1.7.7 (PAY-2592, spike pending). Until then a consumer that turns the plugin on is told, loudly,
10
+ * once per process.
11
+ *
12
+ * test/next-mvp-pay-2592-exposure-pins.test.cjs holds both conditions and this warning.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ export const MAGIC_LINK_ADVISORY = 'GHSA-965c-763c-88jm';
18
+ export const MAGIC_LINK_FIX_VERSION = '1.7.7';
19
+
20
+ /** The installed better-auth version, read from its own package.json (the package does not export './package.json'), or null when it cannot be found. */
21
+ export function installedBetterAuthVersion(): string | null {
22
+ try {
23
+ let dir = dirname(require.resolve('better-auth'));
24
+ for (let i = 0; i < 8; i++) {
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string };
27
+ if (pkg.name === 'better-auth' && typeof pkg.version === 'string') return pkg.version;
28
+ } catch {
29
+ /* no package.json at this level */
30
+ }
31
+ const up = dirname(dir);
32
+ if (up === dir) break;
33
+ dir = up;
34
+ }
35
+ } catch {
36
+ /* unresolvable */
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** True when `version` is strictly below `floor` (numeric major.minor.patch; a pre-release suffix is ignored). An unparseable version is NOT proven fixed, so it counts as below. */
42
+ export function versionBelow(version: string | null, floor: string): boolean {
43
+ const parse = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number) ?? null;
44
+ const a = version ? parse(version) : null;
45
+ const b = parse(floor);
46
+ if (!a || !b) return true;
47
+ for (let i = 0; i < 3; i++) {
48
+ if (a[i] !== b[i]) return a[i] < b[i];
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Warn when the magic-link plugin is enabled on a better-auth below the fix. Returns whether it warned (so a test can assert it). `warn` and `version` are
55
+ * injectable for the same reason.
56
+ */
57
+ export function warnIfMagicLinkBelowFix(
58
+ magicLinkEnabled: boolean,
59
+ warn: (msg: string) => void = (m) => console.warn(m),
60
+ version: string | null = installedBetterAuthVersion(),
61
+ ): boolean {
62
+ if (!magicLinkEnabled) return false;
63
+ if (!versionBelow(version, MAGIC_LINK_FIX_VERSION)) return false;
64
+ warn(
65
+ `[BETTER_AUTH] WARNING ${MAGIC_LINK_ADVISORY} (critical): the magic-link plugin is enabled on better-auth ${version ?? '(version unknown)'}, below the fix ${MAGIC_LINK_FIX_VERSION}. ` +
66
+ `This package keeps account.storeStateStrategy='cookie', which blocks the attack; do NOT change that while magic link is on, and upgrade better-auth (PAY-2592).`,
67
+ );
68
+ return true;
69
+ }
@@ -34,6 +34,11 @@ import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
34
34
  import { SESSION_TTL } from '../lib/session-store';
35
35
  import { tokenLogId } from '../lib/token-log-id';
36
36
  import { errForLog } from '../lib/log-safe';
37
+ import { warnIfMagicLinkBelowFix } from './better-auth-exposure';
38
+ import { noStore } from '../lib/no-store';
39
+
40
+ /** PAY-2592: the magic-link advisory warning fires once per process. */
41
+ let magicLinkAdvisoryWarned = false;
37
42
 
38
43
  /**
39
44
  * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
@@ -589,6 +594,13 @@ export function createBetterAuthInstance(
589
594
  assertAppSlugMatchesIdp(idpConfig.clientSlug);
590
595
  const appSlug = idpConfig.clientSlug || getAppSlug();
591
596
 
597
+ // PAY-2592 (GHSA-965c-763c-88jm): a consumer that enables the magic-link plugin on a better-auth below 1.7.7 is told, once per process. What keeps the
598
+ // package unexposed is account.storeStateStrategy='cookie' below plus the plugin being opt-in; see better-auth-exposure.ts.
599
+ if (opts.magicLink && !magicLinkAdvisoryWarned) {
600
+ magicLinkAdvisoryWarned = true;
601
+ warnIfMagicLinkBelowFix(true);
602
+ }
603
+
592
604
  // 185121 line 1: name the secret this instance PINNED, as a hash, never the
593
605
  // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
594
606
  // caused by a divergent secret is only diagnosable if every instance
@@ -1563,6 +1575,20 @@ async function abortOAuthSignIn(
1563
1575
  return new Response(null, { status: 302, headers });
1564
1576
  }
1565
1577
 
1578
+ /**
1579
+ * PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
1580
+ * token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
1581
+ * or absolute; the base is a placeholder used only for parsing.
1582
+ */
1583
+ export function authErrorCodeFromLocation(location: string): string | null {
1584
+ try {
1585
+ const code = new URL(location, 'http://placeholder.invalid').searchParams.get('error');
1586
+ return code && /^[A-Za-z0-9_.-]{1,64}$/.test(code) ? code : null;
1587
+ } catch {
1588
+ return null;
1589
+ }
1590
+ }
1591
+
1566
1592
  /**
1567
1593
  * Create a production-ready GET handler for the auth catch-all route.
1568
1594
  *
@@ -1582,7 +1608,7 @@ async function abortOAuthSignIn(
1582
1608
  * ```
1583
1609
  */
1584
1610
  export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
1585
- return async function GET(request: Request): Promise<Response> {
1611
+ const get = async function GET(request: Request): Promise<Response> {
1586
1612
  // Card 138635: establish the request-scoped carrier for the provider's raw
1587
1613
  // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
1588
1614
  // hook that sees the token) and the exchange below (which forwards it).
@@ -1600,7 +1626,15 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1600
1626
  if (response.status === 302) {
1601
1627
  const location = response.headers.get('location') || '';
1602
1628
  if (location.includes('/api/auth/error') || location.includes('please_restart')) {
1603
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1629
+ // PAY-2568: log WHICH error. Better Auth routes every callback failure to /api/auth/error?error=<code>
1630
+ // (signup_disabled, state_mismatch, ...); the old fixed 'OAuth state error' sentence made distinct faults
1631
+ // look identical. Only the code is logged - never the Location (it can carry other params).
1632
+ const authErrorCode = authErrorCodeFromLocation(location);
1633
+ if (authErrorCode) {
1634
+ console.warn('[BETTER_AUTH] auth error code=' + authErrorCode + ', redirecting to login');
1635
+ } else {
1636
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1637
+ }
1604
1638
  // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
1605
1639
  // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
1606
1640
  // runtime and again on node 24:
@@ -1637,7 +1671,10 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1637
1671
  // Works whether loginPath is relative (package default '/account-auth/login',
1638
1672
  // and the graft passes '/sign-in') or absolute — an absolute Location is also
1639
1673
  // legal, so a consumer passing a full URL still behaves.
1640
- return new Response(null, { status: 302, headers: { location: loginPath } });
1674
+ // PAY-2554: the redirect used to carry NO code, so the login page showed nothing and looked like nothing happened.
1675
+ // It now carries the same neutral OAUTH_SIGN_IN_FAILED the abort path uses (the login page maps it to one sentence;
1676
+ // no provider text, URL or internal detail goes on the URL).
1677
+ return new Response(null, { status: 302, headers: { location: `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${OAUTH_SIGN_IN_FAILED_ERROR}` } });
1641
1678
  }
1642
1679
  }
1643
1680
 
@@ -1686,4 +1723,6 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1686
1723
  return response;
1687
1724
  });
1688
1725
  };
1726
+ // PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
1727
+ return noStore(get);
1689
1728
  }
@@ -92,6 +92,15 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
92
92
  allowDuringCircuitBreakerOpen: true,
93
93
  requiresRateLimit: false
94
94
  },
95
+ {
96
+ // PAY-2804: the exact path. '/api/health/*' matches startsWith('/api/health/'), which the bare
97
+ // '/api/health' (the ALB check) does not, so without this entry it sat behind sign-in.
98
+ pattern: '/api/health',
99
+ description: 'Health check endpoint (ALB target; anonymous, state only)',
100
+ allowDuringCircuitBreakerOpen: true,
101
+ // An ALB check comes from few IPs: a rate limit here would answer 429 and get tasks pulled (NightHawk 89179).
102
+ requiresRateLimit: false
103
+ },
95
104
  {
96
105
  pattern: '/api/health/*',
97
106
  description: 'Health check endpoints',
@@ -91,6 +91,32 @@ export function resetSecret(): string | undefined {
91
91
  return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
92
92
  }
93
93
 
94
+ /**
95
+ * The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
96
+ * key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
97
+ */
98
+ export const RESET_COOKIE_KEY_CONTEXT = 'payez:session-reset:v1:cookie-derived';
99
+
100
+ /**
101
+ * The secret the reset link is signed and verified with.
102
+ *
103
+ * 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
104
+ * 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
105
+ * Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
106
+ * produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
107
+ * the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
108
+ * RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
109
+ * session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
110
+ * set or shadow cookies and log the victim out, so the scheme adds no capability.
111
+ * 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
112
+ * Never log the cookie value or the returned key.
113
+ */
114
+ export async function resetSigningSecret(envSecret: string | undefined, sessionCookieValue: string | undefined): Promise<string | undefined> {
115
+ if (envSecret) return envSecret;
116
+ if (!sessionCookieValue) return undefined;
117
+ return b64url(await hmacBytes(encoder.encode(sessionCookieValue), RESET_COOKIE_KEY_CONTEXT));
118
+ }
119
+
94
120
  /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
95
121
  export function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined {
96
122
  return get(secureName) || get(plainName) || undefined;
@@ -137,6 +163,27 @@ export async function signedResetHref(
137
163
  return `${route}?${q.toString()}`;
138
164
  }
139
165
 
166
+ /**
167
+ * VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
168
+ * secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
169
+ * signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
170
+ * cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
171
+ * signed with neither key, or with a key derived from a different cookie, is refused.
172
+ */
173
+ export async function readResetRequestAnyKey(
174
+ search: URLSearchParams,
175
+ envSecret: string | undefined,
176
+ sessionCookieValue: string | undefined,
177
+ nowMs: number = Date.now()
178
+ ): Promise<{ params: ResetParams; signed: boolean; via: 'env' | 'cookie' | null }> {
179
+ if (envSecret) {
180
+ const viaEnv = await readResetRequest(search, envSecret, sessionCookieValue, nowMs);
181
+ if (viaEnv.signed) return { ...viaEnv, via: 'env' };
182
+ }
183
+ const viaCookie = await readResetRequest(search, await resetSigningSecret(undefined, sessionCookieValue), sessionCookieValue, nowMs);
184
+ return { ...viaCookie, via: viaCookie.signed ? 'cookie' : null };
185
+ }
186
+
140
187
  /**
141
188
  * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
142
189
  * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
@@ -6,11 +6,14 @@ import { safeCallbackPath } from '../../auth/safe-callback';
6
6
  import { authorizeResumeHref } from '../../lib/authorize-resume';
7
7
  import { NO_CODE_REQUEST, codeStepInstruction, type CodeRequest } from '../../lib/code-step-copy';
8
8
  import { DevCodeNotice } from './DevCodeNotice';
9
+ import { EnrollAuthenticatorOffer } from './EnrollAuthenticatorOffer';
9
10
  import {
10
11
  credentialSignIn,
12
+ postSignInDestination,
11
13
  requestEmailCode,
12
14
  validateCredentials,
13
15
  type FetchLike,
16
+ type NextStep,
14
17
  } from '../../lib/credential-sign-in-client';
15
18
 
16
19
  /**
@@ -42,6 +45,12 @@ interface Props {
42
45
  /** Test seam: navigation after success. */
43
46
  onSignedIn?: (destination: string) => void;
44
47
  showForgotPassword?: boolean;
48
+ /**
49
+ * PAY-2630: the enrolment page a user the IdP told to enrol is taken to after signing in. NO DEFAULT: the offer renders only when
50
+ * the host passes this, because the host is the one that serves the page (the CLI scaffold writes no /account/security, so a
51
+ * default would 404 a generated app's user right after a good sign-in).
52
+ */
53
+ enrollHref?: string;
45
54
  }
46
55
 
47
56
  const inputStyle: React.CSSProperties = {
@@ -64,27 +73,32 @@ export function resolveDestination(explicit?: string): string {
64
73
  return safeCallbackPath(authorizeResumeHref(returnUrl), safeCallbackPath(raw, '/dashboard'));
65
74
  }
66
75
 
67
- export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false }: Props) {
76
+ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }: Props) {
68
77
  const [step, setStep] = useState<'credentials' | 'code'>('credentials');
69
78
  const [email, setEmail] = useState('');
70
79
  const [password, setPassword] = useState('');
71
80
  const [code, setCode] = useState('');
72
81
  const [busy, setBusy] = useState(false);
73
82
  const [error, setError] = useState<string | null>(null);
83
+ const [errorCode, setErrorCode] = useState<string | null>(null);
74
84
  const [fieldError, setFieldError] = useState<string | null>(null);
75
85
  const [codeRequest, setCodeRequest] = useState<CodeRequest>(NO_CODE_REQUEST);
76
86
  const [offerEmailCode, setOfferEmailCode] = useState(false);
77
87
  const [devCode, setDevCode] = useState<string | null>(null);
78
88
  const [emailCodeMsg, setEmailCodeMsg] = useState<string | null>(null);
89
+ // PAY-2630: what the IdP said is outstanding on this client, and whether the user kept the 'take me to enrol' offer ticked.
90
+ const [nextStep, setNextStep] = useState<NextStep>(null);
91
+ const [enrolAfter, setEnrolAfter] = useState(true);
79
92
 
80
93
  async function submit(withCode?: string) {
81
94
  setBusy(true);
82
95
  setError(null);
96
+ setErrorCode(null);
83
97
  setOfferEmailCode(false);
84
98
  try {
85
99
  const r = await credentialSignIn({ email, password, code: withCode }, fetchImpl);
86
100
  if (r.kind === 'success') {
87
- const destination = resolveDestination(callbackUrl);
101
+ const destination = postSignInDestination(resolveDestination(callbackUrl), nextStep, enrolAfter, enrollHref);
88
102
  if (onSignedIn) onSignedIn(destination);
89
103
  else window.location.href = destination;
90
104
  return;
@@ -92,10 +106,12 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
92
106
  if (r.kind === 'two_factor') {
93
107
  setCodeRequest(r.codeRequest);
94
108
  setDevCode(r.devCode);
109
+ setNextStep(r.nextStep);
95
110
  setStep('code');
96
111
  return;
97
112
  }
98
113
  setError(r.message);
114
+ setErrorCode(r.code);
99
115
  if (r.kind === 'error') setOfferEmailCode(r.offerEmailCode);
100
116
  } finally {
101
117
  setBusy(false);
@@ -128,6 +144,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
128
144
  setStep('credentials');
129
145
  setCodeRequest(NO_CODE_REQUEST);
130
146
  setDevCode(null);
147
+ setNextStep(null);
131
148
  setCode('');
132
149
  setFieldError(null);
133
150
  setError(null);
@@ -156,7 +173,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
156
173
 
157
174
  const problem = fieldError ?? error;
158
175
  const problemLine = problem && (
159
- <p role="alert" data-testid="credential-sign-in-error" className="text-sm mt-3" style={{ color: 'var(--color-error, #ef4444)' }}>
176
+ <p role="alert" data-testid="credential-sign-in-error" data-error-code={fieldError ? 'invalid_input' : (errorCode ?? undefined)} className="text-sm mt-3" style={{ color: 'var(--color-error, #ef4444)' }}>
160
177
  {problem}
161
178
  </p>
162
179
  );
@@ -198,6 +215,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
198
215
  {busy ? 'Verifying…' : 'Verify'}
199
216
  </button>
200
217
  </form>
218
+ <EnrollAuthenticatorOffer nextStep={enrollHref ? nextStep : null} checked={enrolAfter} onChange={setEnrolAfter} disabled={busy} />
201
219
  <button type="button" onClick={onBack} disabled={busy} className="text-sm underline mt-4" style={{ background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }}>
202
220
  Back
203
221
  </button>