@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.
- package/CHANGELOG.md +51 -0
- package/dist/api-handlers/auth/credential-login.js +28 -4
- package/dist/api-handlers/mfa/totp-confirm.js +8 -4
- package/dist/api-handlers/mfa/totp-enroll.js +8 -4
- package/dist/api-handlers/session/viability.js +6 -2
- package/dist/auth/better-auth.d.ts +6 -0
- package/dist/auth/better-auth.js +29 -2
- package/dist/auth/session-reset-link.d.ts +32 -0
- package/dist/auth/session-reset-link.js +45 -1
- package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
- package/dist/components/auth/CredentialSignInForm.js +13 -4
- package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
- package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
- package/dist/components/mfa/QrCode.d.ts +6 -0
- package/dist/components/mfa/QrCode.js +14 -0
- package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
- package/dist/components/mfa/TotpEnrollPanel.js +117 -0
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/credential-sign-in-client.d.ts +26 -1
- package/dist/lib/credential-sign-in-client.js +32 -6
- package/dist/lib/ensure-fresh-access-token.js +15 -10
- package/dist/lib/idp-client-config.js +4 -1
- package/dist/lib/login-error-message.d.ts +3 -0
- package/dist/lib/login-error-message.js +12 -1
- package/dist/lib/mfa-json-guard.d.ts +12 -0
- package/dist/lib/mfa-json-guard.js +19 -0
- package/dist/lib/nextauth-secret.js +3 -1
- package/dist/lib/payez-client-secret.d.ts +14 -0
- package/dist/lib/payez-client-secret.js +41 -0
- package/dist/lib/qrcode.d.ts +22 -0
- package/dist/lib/qrcode.js +362 -0
- package/dist/lib/sign-in-failure.d.ts +35 -0
- package/dist/lib/sign-in-failure.js +85 -0
- package/dist/lib/twofa-rejection.d.ts +2 -0
- package/dist/lib/twofa-rejection.js +5 -2
- package/dist/middleware/create-middleware.d.ts +24 -0
- package/dist/middleware/create-middleware.js +56 -9
- package/dist/middleware/prefetch-guard-status.d.ts +21 -0
- package/dist/middleware/prefetch-guard-status.js +42 -0
- package/dist/pages/enroll/page.d.ts +7 -0
- package/dist/pages/enroll/page.js +15 -0
- package/dist/pages/login/page.js +1 -1
- package/dist/routes/auth/session-reset.d.ts +11 -1
- package/dist/routes/auth/session-reset.js +38 -13
- package/dist/routes/auth/viability.js +11 -2
- package/package.json +11 -1
- package/src/api-handlers/auth/credential-login.ts +29 -4
- package/src/api-handlers/mfa/totp-confirm.ts +7 -4
- package/src/api-handlers/mfa/totp-enroll.ts +7 -4
- package/src/api-handlers/session/viability.ts +6 -2
- package/src/auth/better-auth.ts +27 -2
- package/src/auth/session-reset-link.ts +47 -0
- package/src/components/auth/CredentialSignInForm.tsx +21 -3
- package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
- package/src/components/mfa/QrCode.tsx +24 -0
- package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/credential-sign-in-client.ts +48 -8
- package/src/lib/ensure-fresh-access-token.ts +15 -10
- package/src/lib/idp-client-config.ts +5 -1
- package/src/lib/login-error-message.ts +13 -1
- package/src/lib/mfa-json-guard.ts +19 -0
- package/src/lib/nextauth-secret.ts +3 -1
- package/src/lib/payez-client-secret.ts +39 -0
- package/src/lib/qrcode.ts +348 -0
- package/src/lib/sign-in-failure.ts +88 -0
- package/src/lib/twofa-rejection.ts +7 -2
- package/src/middleware/create-middleware.ts +69 -9
- package/src/middleware/prefetch-guard-status.ts +56 -0
- package/src/pages/enroll/page.tsx +22 -0
- package/src/pages/login/page.tsx +2 -1
- package/src/routes/auth/session-reset.ts +46 -11
- 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'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;
|
package/src/edge/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
363
|
+
null;
|
|
360
364
|
|
|
361
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|