@payez/next-mvp 4.12.0 → 4.13.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 (77) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/api-handlers/auth/credential-login.js +28 -4
  3. package/dist/api-handlers/mfa/totp-confirm.js +8 -4
  4. package/dist/api-handlers/mfa/totp-enroll.js +8 -4
  5. package/dist/api-handlers/session/viability.js +6 -2
  6. package/dist/auth/better-auth.d.ts +6 -0
  7. package/dist/auth/better-auth.js +29 -2
  8. package/dist/auth/session-reset-link.d.ts +32 -0
  9. package/dist/auth/session-reset-link.js +45 -1
  10. package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
  11. package/dist/components/auth/CredentialSignInForm.js +13 -4
  12. package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
  13. package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
  14. package/dist/components/mfa/QrCode.d.ts +6 -0
  15. package/dist/components/mfa/QrCode.js +14 -0
  16. package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
  17. package/dist/components/mfa/TotpEnrollPanel.js +117 -0
  18. package/dist/edge/middleware.d.ts +1 -1
  19. package/dist/edge/middleware.js +2 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/credential-sign-in-client.d.ts +26 -1
  23. package/dist/lib/credential-sign-in-client.js +32 -6
  24. package/dist/lib/ensure-fresh-access-token.js +15 -10
  25. package/dist/lib/idp-client-config.js +4 -1
  26. package/dist/lib/login-error-message.d.ts +3 -0
  27. package/dist/lib/login-error-message.js +12 -1
  28. package/dist/lib/mfa-json-guard.d.ts +12 -0
  29. package/dist/lib/mfa-json-guard.js +19 -0
  30. package/dist/lib/nextauth-secret.js +3 -1
  31. package/dist/lib/payez-client-secret.d.ts +14 -0
  32. package/dist/lib/payez-client-secret.js +41 -0
  33. package/dist/lib/qrcode.d.ts +22 -0
  34. package/dist/lib/qrcode.js +362 -0
  35. package/dist/lib/sign-in-failure.d.ts +35 -0
  36. package/dist/lib/sign-in-failure.js +85 -0
  37. package/dist/lib/twofa-rejection.d.ts +2 -0
  38. package/dist/lib/twofa-rejection.js +5 -2
  39. package/dist/middleware/create-middleware.d.ts +24 -0
  40. package/dist/middleware/create-middleware.js +56 -9
  41. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  42. package/dist/middleware/prefetch-guard-status.js +42 -0
  43. package/dist/pages/enroll/page.d.ts +7 -0
  44. package/dist/pages/enroll/page.js +15 -0
  45. package/dist/pages/login/page.js +1 -1
  46. package/dist/routes/auth/session-reset.d.ts +11 -1
  47. package/dist/routes/auth/session-reset.js +38 -13
  48. package/dist/routes/auth/viability.js +11 -2
  49. package/package.json +11 -1
  50. package/src/api-handlers/auth/credential-login.ts +29 -4
  51. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  52. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  53. package/src/api-handlers/session/viability.ts +6 -2
  54. package/src/auth/better-auth.ts +27 -2
  55. package/src/auth/session-reset-link.ts +47 -0
  56. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  57. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  58. package/src/components/mfa/QrCode.tsx +24 -0
  59. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  60. package/src/edge/middleware.ts +1 -1
  61. package/src/index.ts +1 -1
  62. package/src/lib/credential-sign-in-client.ts +48 -8
  63. package/src/lib/ensure-fresh-access-token.ts +15 -10
  64. package/src/lib/idp-client-config.ts +5 -1
  65. package/src/lib/login-error-message.ts +13 -1
  66. package/src/lib/mfa-json-guard.ts +19 -0
  67. package/src/lib/nextauth-secret.ts +3 -1
  68. package/src/lib/payez-client-secret.ts +39 -0
  69. package/src/lib/qrcode.ts +348 -0
  70. package/src/lib/sign-in-failure.ts +88 -0
  71. package/src/lib/twofa-rejection.ts +7 -2
  72. package/src/middleware/create-middleware.ts +69 -9
  73. package/src/middleware/prefetch-guard-status.ts +56 -0
  74. package/src/pages/enroll/page.tsx +22 -0
  75. package/src/pages/login/page.tsx +2 -1
  76. package/src/routes/auth/session-reset.ts +46 -11
  77. package/src/routes/auth/viability.ts +10 -2
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+ import type { NextStep } from '../../lib/credential-sign-in-client';
3
+
4
+ /**
5
+ * PAY-2630: the code step's offer to set up an authenticator.
6
+ *
7
+ * WHY. The IdP answers the first sign-in turn with next_step 'enroll_mfa' when the user has NO usable second factor on this
8
+ * client (factors are per client). The step used to ignore that and show only "enter the code we emailed you", so a user of a
9
+ * generated app could never enrol a factor on that client. Measured on the PAY-2305 walk (client 90662, user with a TOTP on
10
+ * client 12 only): requiresTwoFactor true, sentVia 'email', nextStep 'enroll_mfa', and a page with Verify / Back and nothing else.
11
+ *
12
+ * WHAT IT IS, AND IS NOT. It renders ONLY for 'enroll_mfa'. It names the lane the user is on (the emailed code) and offers to take
13
+ * them to the enrolment page right after they sign in with it. It does NOT enrol in place: the enrolment endpoints need a signed-in
14
+ * session and this step has none yet (the provisional bearer is parked server-side). For 'mfa_verify', null or anything else it
15
+ * renders nothing: a user who HAS a factor must not be told to set one up.
16
+ */
17
+ export function EnrollAuthenticatorOffer({
18
+ nextStep,
19
+ checked,
20
+ onChange,
21
+ disabled,
22
+ }: {
23
+ nextStep: NextStep;
24
+ checked: boolean;
25
+ onChange: (next: boolean) => void;
26
+ disabled?: boolean;
27
+ }) {
28
+ if (nextStep !== 'enroll_mfa') return null;
29
+ return (
30
+ <div data-testid="enroll-authenticator-offer" className="mt-4 text-sm" style={{ color: 'var(--text-primary, #111827)' }}>
31
+ <p data-testid="enroll-authenticator-lane">
32
+ You have no authenticator set up for this app yet, so this time you sign in with the emailed code.
33
+ </p>
34
+ <label className="flex items-center gap-2 mt-2">
35
+ <input
36
+ type="checkbox"
37
+ data-testid="enroll-authenticator-checkbox"
38
+ checked={checked}
39
+ disabled={disabled}
40
+ onChange={(e) => onChange(e.target.checked)}
41
+ />
42
+ <span>Take me to set up an authenticator right after I sign in</span>
43
+ </label>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export default EnrollAuthenticatorOffer;
@@ -0,0 +1,24 @@
1
+ import React, { useMemo } from 'react';
2
+ import { encodeQr, qrToSvgPath } from '../../lib/qrcode';
3
+
4
+ /** PAY-2631: client-side QR from an otpauth:// URI. Inline SVG, quiet zone of 4 modules; no network, no library. */
5
+ export function QrCode({ value, size = 168 }: { value: string; size?: number }) {
6
+ const qr = useMemo(() => encodeQr(value), [value]);
7
+ const path = useMemo(() => qrToSvgPath(qr), [qr]);
8
+ const modules = qr.size;
9
+ return (
10
+ <svg
11
+ width={size}
12
+ height={size}
13
+ viewBox={`-4 -4 ${modules + 8} ${modules + 8}`}
14
+ role="img"
15
+ aria-label="QR code for authenticator setup"
16
+ data-testid="totp-qr"
17
+ >
18
+ <rect x={-4} y={-4} width={modules + 8} height={modules + 8} fill="#ffffff" />
19
+ <path d={path} fill="#212126" />
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ export default QrCode;
@@ -0,0 +1,203 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { QrCode } from './QrCode';
5
+ import { stepUpRefusal } from '../../lib/two-factor-verify-failure';
6
+
7
+ /**
8
+ * PAY-2631: the signed-in authenticator enrolment surface of a generated app.
9
+ *
10
+ * enrol (POST /api/mfa/totp/enroll) -> QR drawn here from otpauth_uri + the manual secret -> 6-digit confirm
11
+ * (POST /api/mfa/totp/confirm) -> done. Port of the portal's TotpEnrollPanel without its list / replace / remove.
12
+ *
13
+ * SECRET HANDLING. The secret and otpauth_uri live in component state ONLY: never a URL, storage, console or a third-party QR
14
+ * service. They die on reload (a pending enrolment can only be restarted, which is what "Start again" does).
15
+ * WHO MAY CALL. The browser sends its session cookie; the app attaches the IdP bearer. No session -> 401 -> "sign in first".
16
+ * A provisional (pre-MFA) session may enrol only while the account holds no confirmed factor (IdP rule, by design).
17
+ * COPY after a good confirm says the next sign-in asks for the code: the app session is not upgraded by confirm.
18
+ */
19
+
20
+ type Enrollment = { secret: string; otpauth_uri: string; enrollment_id: number | string };
21
+ type Stage = 'idle' | 'enrolling' | 'done' | 'signed-out';
22
+
23
+ export const STEP_UP_SENTENCE =
24
+ 'This session was signed in with an emailed or texted code only. Sign in with your current authenticator, then try again.';
25
+
26
+ async function readJson(res: Response): Promise<unknown> {
27
+ return res.json().catch(() => null);
28
+ }
29
+
30
+ export function TotpEnrollPanel({ fetchImpl, onConfirmed }: { fetchImpl?: typeof fetch; onConfirmed?: () => void } = {}) {
31
+ const doFetch: typeof fetch = fetchImpl ?? ((...a) => fetch(...a));
32
+ const [stage, setStage] = useState<Stage>('idle');
33
+ const [enroll, setEnroll] = useState<Enrollment | null>(null);
34
+ const [code, setCode] = useState('');
35
+ const [error, setError] = useState<string | null>(null);
36
+ const [busy, setBusy] = useState(false);
37
+
38
+ async function begin() {
39
+ setBusy(true);
40
+ setError(null);
41
+ try {
42
+ const res = await doFetch('/api/mfa/totp/enroll', {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ friendly_name: 'This device' }),
46
+ });
47
+ if (res.ok) {
48
+ const body = (await readJson(res)) as Partial<Enrollment> | null;
49
+ if (!body || !body.secret || !body.otpauth_uri || body.enrollment_id === undefined) {
50
+ setError('Something went wrong on our end. Try again.');
51
+ return;
52
+ }
53
+ setEnroll({ secret: body.secret, otpauth_uri: body.otpauth_uri, enrollment_id: body.enrollment_id });
54
+ setCode('');
55
+ setStage('enrolling');
56
+ return;
57
+ }
58
+ if (res.status === 401) {
59
+ setStage('signed-out');
60
+ return;
61
+ }
62
+ const json = await readJson(res);
63
+ if (stepUpRefusal(res.status, json)) {
64
+ setError(STEP_UP_SENTENCE);
65
+ return;
66
+ }
67
+ if (res.status === 403) {
68
+ setError('This account already has a second factor set up, so an authenticator app cannot be added here. Sign in with your existing method.');
69
+ return;
70
+ }
71
+ setError('Something went wrong on our end. Try again.');
72
+ } finally {
73
+ setBusy(false);
74
+ }
75
+ }
76
+
77
+ async function confirm() {
78
+ if (!enroll || !/^\d{6}$/.test(code)) {
79
+ setError('Enter the 6-digit code from your app.');
80
+ return;
81
+ }
82
+ setBusy(true);
83
+ setError(null);
84
+ try {
85
+ const res = await doFetch('/api/mfa/totp/confirm', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }),
89
+ });
90
+ if (res.ok) {
91
+ setEnroll(null);
92
+ setCode('');
93
+ setStage('done');
94
+ onConfirmed?.();
95
+ return;
96
+ }
97
+ if (res.status === 401) {
98
+ setStage('signed-out');
99
+ return;
100
+ }
101
+ const json = await readJson(res);
102
+ if (stepUpRefusal(res.status, json)) {
103
+ setError(STEP_UP_SENTENCE);
104
+ return;
105
+ }
106
+ setCode('');
107
+ setError(
108
+ res.status === 400 || res.status === 422
109
+ ? 'That code did not match. Check the time on your phone and try the next code.'
110
+ : 'Setup was not completed. Try again.',
111
+ );
112
+ } finally {
113
+ setBusy(false);
114
+ }
115
+ }
116
+
117
+ if (stage === 'signed-out') {
118
+ return (
119
+ <div data-testid="totp-signed-out">
120
+ <a href="/account-auth/login">Sign in</a> to set up an authenticator app.
121
+ </div>
122
+ );
123
+ }
124
+
125
+ if (stage === 'done') {
126
+ return (
127
+ <div data-testid="totp-done" role="status">
128
+ <p>Authenticator app added.</p>
129
+ <p>The next time you sign in on this app, you will be asked for the 6-digit code from it.</p>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ if (stage === 'enrolling' && enroll) {
135
+ return (
136
+ <div data-testid="totp-enrolling" style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
137
+ <p>Open your authenticator app and scan this code. A phone camera works too.</p>
138
+ <QrCode value={enroll.otpauth_uri} />
139
+ <div>
140
+ <div>Can&apos;t scan? Enter this code manually:</div>
141
+ <code data-testid="totp-secret" style={{ wordBreak: 'break-all' }}>
142
+ {enroll.secret}
143
+ </code>
144
+ </div>
145
+ <form
146
+ onSubmit={(e) => {
147
+ e.preventDefault();
148
+ void confirm();
149
+ }}
150
+ >
151
+ <label htmlFor="totp-code">Enter the 6-digit code from your app:</label>
152
+ <br />
153
+ <input
154
+ id="totp-code"
155
+ data-testid="totp-code"
156
+ inputMode="numeric"
157
+ autoComplete="one-time-code"
158
+ maxLength={6}
159
+ value={code}
160
+ disabled={busy}
161
+ onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
162
+ />{' '}
163
+ <button type="submit" data-testid="totp-confirm" disabled={busy || code.length !== 6}>
164
+ Confirm
165
+ </button>
166
+ </form>
167
+ {error ? (
168
+ <p role="alert" data-testid="totp-error">
169
+ {error}
170
+ </p>
171
+ ) : null}
172
+ <button
173
+ type="button"
174
+ data-testid="totp-restart"
175
+ disabled={busy}
176
+ onClick={() => {
177
+ setEnroll(null);
178
+ setCode('');
179
+ setError(null);
180
+ setStage('idle');
181
+ }}
182
+ >
183
+ Start again
184
+ </button>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ return (
190
+ <div data-testid="totp-idle">
191
+ <button type="button" data-testid="totp-begin" disabled={busy} onClick={() => void begin()}>
192
+ {busy ? 'Starting…' : 'Add authenticator app'}
193
+ </button>
194
+ {error ? (
195
+ <p role="alert" data-testid="totp-error">
196
+ {error}
197
+ </p>
198
+ ) : null}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ export default TotpEnrollPanel;
@@ -16,7 +16,7 @@ export {
16
16
  get2FABypassConfig
17
17
  } from '../auth/route-config';
18
18
  export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
19
- export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
19
+ export { createMvpMiddleware, isPrefetchRequest, isKnownNonNavigationRequest } from '../middleware/create-middleware';
20
20
  export type {
21
21
  MvpMiddlewareOptions,
22
22
  CircuitBreakerProvider,
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from '.
27
27
  // The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
28
28
  // copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
29
29
  export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
30
- export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
30
+ export { createMvpMiddleware, isPrefetchRequest, isKnownNonNavigationRequest } from './middleware/create-middleware';
31
31
 
32
32
  // Account Components
33
33
  export { UserAvatarMenu, MobileNavDrawer } from './components/account';
@@ -14,6 +14,7 @@
14
14
  * literally true (PAY-2284).
15
15
  */
16
16
  import { NO_CODE_REQUEST, readCodeRequest, type CodeRequest } from './code-step-copy';
17
+ import { SIGN_IN_FAILURE_COPY, capPolicyDetail, isSignInFailureCode, signInFailureMessage, tooManyAttemptsMessage } from './sign-in-failure';
17
18
 
18
19
  export const CREDENTIAL_LOGIN_URL = '/api/auth/admin-login';
19
20
  export const CREDENTIAL_EMAIL_CODE_URL = '/api/auth/admin-login/email-code';
@@ -22,11 +23,18 @@ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
22
23
 
23
24
  export type CredentialSignInResult =
24
25
  | { kind: 'success' }
25
- | { kind: 'two_factor'; codeRequest: CodeRequest; devCode: string | null }
26
- | { kind: 'rate_limited'; message: string }
27
- | { kind: 'error'; message: string; offerEmailCode: boolean };
26
+ | { kind: 'two_factor'; codeRequest: CodeRequest; devCode: string | null; nextStep: NextStep }
27
+ | { kind: 'rate_limited'; message: string; code: ErrorCode }
28
+ | { kind: 'error'; message: string; code: ErrorCode; offerEmailCode: boolean };
28
29
 
29
- export const MSG_RATE_LIMITED = 'Too many attempts. Please try again later.';
30
+ /**
31
+ * The stable machine code a rendered error carries (data-error-code). The three from the closed table in lib/sign-in-failure,
32
+ * plus 'policy_message' (an allowlisted SsoRequired / InvalidRequest refusal, whose detail the server may send) and
33
+ * 'sign_in_failed' (anything this client could not map: a 400/5xx, an unknown code, a thrown fetch).
34
+ */
35
+ export type ErrorCode = keyof typeof SIGN_IN_FAILURE_COPY | 'policy_message' | 'sign_in_failed';
36
+
37
+ export const MSG_RATE_LIMITED = SIGN_IN_FAILURE_COPY.too_many_attempts;
30
38
  export const MSG_CANNOT_SIGN_IN = 'Could not sign in. Check your details and try again.';
31
39
  export const MSG_UNEXPECTED = 'Something went wrong signing you in. Please try again.';
32
40
 
@@ -46,6 +54,29 @@ export function validateCredentials(email: string, password: string): { email?:
46
54
  return errors.email || errors.password ? errors : null;
47
55
  }
48
56
 
57
+ /**
58
+ * PAY-2630: what the IdP says is outstanding for this user ON THIS CLIENT. The door (api-handlers/auth/credential-login)
59
+ * already relays the IdP's next_step as `nextStep`; this form used to read ZERO of it. 'enroll_mfa' = the user has no usable
60
+ * second factor on this client, so a code prompt alone is a trap; 'mfa_verify' = a factor exists, the prompt is right.
61
+ * Anything else (absent, null, an unknown string, wrong case) is null = "not told", never a guess.
62
+ */
63
+ export type NextStep = 'enroll_mfa' | 'mfa_verify' | null;
64
+
65
+ export function readNextStep(body: any): NextStep {
66
+ const v = body?.nextStep;
67
+ return v === 'enroll_mfa' || v === 'mfa_verify' ? v : null;
68
+ }
69
+
70
+ /**
71
+ * Where the browser goes after the code was accepted. A user the IdP told to enrol, who kept the offer ticked, goes to the
72
+ * enrolment page; everyone else goes where they were headed. The destination is never decided from anything but the IdP's
73
+ * answer and the user's own tick. With NO enrolHref (the host mounts no enrolment page) nobody is rerouted: there is no default, because
74
+ * a default that a host does not serve is a 404 after a successful sign-in (QAPert, PR 1687 read).
75
+ */
76
+ export function postSignInDestination(destination: string, nextStep: NextStep, enrolAfter: boolean, enrolHref?: string): string {
77
+ return enrolHref && nextStep === 'enroll_mfa' && enrolAfter ? enrolHref : destination;
78
+ }
79
+
49
80
  /** The dev echo: the door passes the IdP's DevVerificationCode through as `devVerificationCode`. Nothing else is ever a dev code. */
50
81
  function readDevCode(body: any): string | null {
51
82
  return typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null;
@@ -68,14 +99,23 @@ export async function credentialSignIn(
68
99
  });
69
100
  const body: any = await res.json().catch(() => null);
70
101
 
71
- if (res.status === 429) return { kind: 'rate_limited', message: asString(body?.error) ?? MSG_RATE_LIMITED };
72
- if (body?.requiresTwoFactor) return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body) };
102
+ // The sentence comes from the closed table, never from the body (BAPert 85415 rule 4).
103
+ if (res.status === 429) {
104
+ const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
105
+ return { kind: 'rate_limited', message: tooManyAttemptsMessage(body?.retryAfter, code), code };
106
+ }
107
+ if (body?.requiresTwoFactor) return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
73
108
  if (!res.ok || body?.success !== true) {
74
- return { kind: 'error', message: asString(body?.error) ?? MSG_CANNOT_SIGN_IN, offerEmailCode: body?.offerEmailCode === true };
109
+ const offerEmailCode = body?.offerEmailCode === true;
110
+ if (isSignInFailureCode(body?.code)) return { kind: 'error', message: signInFailureMessage(body.code) as string, code: body.code, offerEmailCode };
111
+ // The one passthrough: an allowlisted policy refusal (SsoRequired / InvalidRequest), flagged by the server with this exact code.
112
+ const policy = body?.code === 'policy_message' ? capPolicyDetail(body?.error) : null;
113
+ if (policy) return { kind: 'error', message: policy, code: 'policy_message', offerEmailCode };
114
+ return { kind: 'error', message: MSG_CANNOT_SIGN_IN, code: 'sign_in_failed', offerEmailCode };
75
115
  }
76
116
  return { kind: 'success' };
77
117
  } catch {
78
- return { kind: 'error', message: MSG_UNEXPECTED, offerEmailCode: false };
118
+ return { kind: 'error', message: MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
79
119
  }
80
120
  }
81
121
 
@@ -349,19 +349,20 @@ export async function ensureFreshAccessToken(
349
349
  } else if (typeof session.authenticationMethods === 'string') {
350
350
  try { authMethods = JSON.parse(session.authenticationMethods); } catch { /* fall through */ }
351
351
  }
352
- const isOAuthSession = !!session.oauthProvider;
353
- if (authMethods.length === 0 && isOAuthSession) {
354
- authMethods = ['pwd', 'mfa'];
355
- }
352
+ // PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
353
+ // (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
354
+ // a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
355
+ // could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
356
+ // 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
357
+ // the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
358
+ // re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
359
+ // (e6e3b6c3, e3b3bb12) and was never merged.
356
360
  const twoFactorMethod =
357
361
  authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
358
362
  session.mfaMethod ||
359
- (isOAuthSession ? 'oauth' : null);
363
+ null;
360
364
 
361
- let acrValue = String(session.authenticationLevel ?? '1');
362
- if (isOAuthSession && session.mfaVerified && acrValue === '1') {
363
- acrValue = '2';
364
- }
365
+ const acrValue = String(session.authenticationLevel ?? '1');
365
366
 
366
367
  // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
367
368
  // `session` was read before we acquired the lock; a concurrent refresh that
@@ -397,7 +398,11 @@ export async function ensureFreshAccessToken(
397
398
  amr: authMethods,
398
399
  acr: acrValue,
399
400
  };
400
- if (session.mfaVerified) body.two_factor_verified = true;
401
+ // PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
402
+ // bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
403
+ // poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
404
+ const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
405
+ if (session.mfaVerified && methodsAttestSecondFactor) body.two_factor_verified = true;
401
406
  if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
402
407
  if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
403
408
 
@@ -43,6 +43,7 @@
43
43
  import 'server-only';
44
44
  import { createHash, randomUUID } from 'crypto';
45
45
  import { markNeverInResponse } from './never-in-response';
46
+ import { clientSecretBody, warnIfProviderSecretRedacted } from './payez-client-secret';
46
47
  import { errForLog } from './log-safe';
47
48
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
48
49
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
@@ -816,6 +817,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
816
817
  subject: clientIdStr,
817
818
  audience: 'urn:payez:externalauth:clientconfig',
818
819
  expires_in: 60,
820
+ ...clientSecretBody(),
819
821
  };
820
822
 
821
823
  const signingResp = await fetch(signingUrl, {
@@ -866,7 +868,7 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
866
868
  'X-Client-Id': clientIdStr,
867
869
  'X-Correlation-Id': randomUUID().replace(/-/g, ''),
868
870
  },
869
- body: JSON.stringify({ client_assertion }),
871
+ body: JSON.stringify({ client_assertion, ...clientSecretBody() }),
870
872
  cache: 'no-store'
871
873
  } as RequestInit);
872
874
 
@@ -988,6 +990,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
988
990
  throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
989
991
  }
990
992
 
993
+ warnIfProviderSecretRedacted(configResp.headers?.get?.('X-PayEz-Secrets-Redacted'), config.oauthProviders ?? [], providerSecretEnvVar);
994
+
991
995
  // Success - reset failure tracking
992
996
  consecutiveFailures = 0;
993
997
  return config;
@@ -9,13 +9,20 @@
9
9
  * sentence; nothing from the URL is ever shown verbatim.
10
10
  */
11
11
 
12
+ import { SIGN_IN_FAILURE_COPY, type SignInFailureCode } from './sign-in-failure';
13
+
12
14
  export const GENERIC_LOGIN_ERROR = 'We could not sign you in. Please try again.';
13
15
 
14
16
  const KNOWN: Record<string, string> = {
15
17
  TwoFactorTimeout: 'Your verification step timed out before the code was confirmed. Please sign in again.',
16
18
  AUTHORIZE_RESUME_REFUSED: 'That sign-in request could not be continued. Please sign in again.',
19
+ // PAY-2279 / PAY-2005(b): the one IdP refusal code the abort path forwards (IDP_ERROR_CODES_TO_FORWARD). Wording is the card's proposal.
20
+ // (OAUTH_SIGN_IN_FAILED has no sentence in THIS change: PR 1606 (PAY-2554) owns that code's redirect and its sentence; once both land the map carries both.)
21
+ REGISTRATION_NOT_ALLOWED: 'Registration is not open for this site. Ask your administrator for an invitation.',
17
22
  ServiceUnavailable: 'Authentication service is temporarily unavailable. Please try again in a few minutes.',
18
- CredentialsSignin: 'Invalid email or password. Please try again.',
23
+ CredentialsSignin: SIGN_IN_FAILURE_COPY.invalid_credentials,
24
+ // PAY-2554: the neutral code the OAuth callback failure / state-error / aborted-exchange redirects carry (better-auth.ts OAUTH_SIGN_IN_FAILED_ERROR).
25
+ OAUTH_SIGN_IN_FAILED: "Sign-in didn't complete. Please try again, or use email.",
19
26
  };
20
27
 
21
28
  /**
@@ -39,3 +46,8 @@ export function oauthFailureMessage(provider: string | null | undefined): string
39
46
  if (!/^[a-z][a-z0-9_-]{0,31}$/i.test(provider)) return GENERIC_LOGIN_ERROR;
40
47
  return `${provider.charAt(0).toUpperCase()}${provider.slice(1)} sign-in did not complete. Please try again.`;
41
48
  }
49
+
50
+ /** The machine code (data-error-code) for a `?error=` code, or null when it has none in the closed table. Nothing from the URL is echoed. */
51
+ export function loginErrorCode(code: string | null | undefined): SignInFailureCode | null {
52
+ return code === 'CredentialsSignin' ? 'invalid_credentials' : null;
53
+ }
@@ -0,0 +1,19 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * PAY-2631: the TOTP enrol/confirm handlers forward the browser body verbatim, so a cross-site form post
5
+ * (Content-Type text/plain, application/x-www-form-urlencoded) could reach them. A browser cannot send
6
+ * application/json cross-site without a CORS preflight, so requiring it closes that path.
7
+ * The responses carry the shared secret and the otpauth URI, so they are never cached.
8
+ */
9
+ export const NO_STORE = { 'Cache-Control': 'no-store' } as const;
10
+
11
+ /** A 415 response when the request is not application/json, otherwise null. */
12
+ export function refuseNonJson(req: NextRequest): NextResponse | null {
13
+ const ct = (req.headers.get('content-type') || '').toLowerCase();
14
+ if (ct.split(';')[0].trim() === 'application/json') return null;
15
+ return NextResponse.json(
16
+ { success: false, message: 'Content-Type must be application/json', error: { code: 'UNSUPPORTED_MEDIA_TYPE' } },
17
+ { status: 415, headers: NO_STORE },
18
+ );
19
+ }
@@ -42,6 +42,7 @@
42
42
  import 'server-only';
43
43
  import { randomUUID } from 'node:crypto';
44
44
  import { validateAuthSecret } from './secret-validation';
45
+ import { clientSecretBody } from './payez-client-secret';
45
46
 
46
47
  let cachedSecret: string | null = null;
47
48
  let lastFetchedAt = 0;
@@ -104,6 +105,7 @@ export async function resolveNextAuthSecret(): Promise<string> {
104
105
  subject: clientIdStr,
105
106
  audience: 'urn:payez:externalauth:nextauthsecret',
106
107
  expires_in: 60,
108
+ ...clientSecretBody(),
107
109
  }),
108
110
  cache: 'no-store',
109
111
  });
@@ -136,7 +138,7 @@ export async function resolveNextAuthSecret(): Promise<string> {
136
138
  'X-Client-Id': clientIdStr,
137
139
  'X-Correlation-Id': randomUUID().replace(/-/g, ''),
138
140
  },
139
- body: JSON.stringify({ client_assertion: clientAssertion }),
141
+ body: JSON.stringify({ client_assertion: clientAssertion, ...clientSecretBody() }),
140
142
  cache: 'no-store',
141
143
  });
142
144
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * PAY-1460 (BAPert 86061): the app's own client secret, so the IdP can tell this app from a caller that merely knows its
3
+ * client id. One env var per app, PAYEZ_CLIENT_SECRET, read at CALL time (not a module-load snapshot: the deployment may
4
+ * inject it late). Unset or blank: NOTHING is added to any request body, so an app that has no secret yet sends exactly
5
+ * what it sent before. The value is never logged and never leaves in a response (markNeverInResponse).
6
+ */
7
+ import { markNeverInResponse } from './never-in-response';
8
+
9
+ export const CLIENT_SECRET_ENV = 'PAYEZ_CLIENT_SECRET';
10
+
11
+ /** `{ client_secret }` when the env var is set to a non-blank value, else `{}`. Spread it into a JSON body. */
12
+ export function clientSecretBody(): { client_secret?: string } {
13
+ const v = process.env[CLIENT_SECRET_ENV];
14
+ if (typeof v !== 'string' || v.trim() === '') return {};
15
+ markNeverInResponse(v);
16
+ return { client_secret: v };
17
+ }
18
+
19
+ const warnedProviders = new Set<string>();
20
+
21
+ /**
22
+ * The IdP marks a view whose secrets it withheld with the response header X-PayEz-Secrets-Redacted: true. An app with no
23
+ * <PROVIDER>_CLIENT_SECRET in env then boots and the provider fails at the code exchange. Say so, once per provider per
24
+ * process, naming the provider only.
25
+ */
26
+ export function warnIfProviderSecretRedacted(
27
+ redactedHeader: string | null | undefined,
28
+ providers: ReadonlyArray<{ provider: string; enabled?: boolean }>,
29
+ envVarFor: (provider: string) => string,
30
+ ): void {
31
+ if (String(redactedHeader ?? '').trim().toLowerCase() !== 'true') return;
32
+ for (const p of providers) {
33
+ if (!p.enabled || !p.provider) continue;
34
+ if (process.env[envVarFor(p.provider)]) continue;
35
+ if (warnedProviders.has(p.provider)) continue;
36
+ warnedProviders.add(p.provider);
37
+ console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${p.provider}`);
38
+ }
39
+ }