@payez/next-mvp 4.13.1 → 4.15.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 (58) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/api-handlers/auth/credential-login.js +46 -0
  3. package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
  4. package/dist/api-handlers/mfa/first-enrol.js +58 -0
  5. package/dist/api-handlers/mfa/totp-confirm.d.ts +1 -1
  6. package/dist/api-handlers/mfa/totp-confirm.js +10 -6
  7. package/dist/api-handlers/test/force-expire.d.ts +4 -5
  8. package/dist/api-handlers/test/force-expire.js +14 -63
  9. package/dist/auth/better-auth.d.ts +16 -0
  10. package/dist/auth/better-auth.js +34 -0
  11. package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
  12. package/dist/components/auth/CredentialSignInForm.js +15 -1
  13. package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
  14. package/dist/components/mfa/FirstEnrolPanel.js +115 -0
  15. package/dist/lib/credential-sign-in-client.d.ts +4 -0
  16. package/dist/lib/credential-sign-in-client.js +13 -1
  17. package/dist/lib/first-enrol.d.ts +66 -0
  18. package/dist/lib/first-enrol.js +188 -0
  19. package/dist/lib/idp-client-config.js +1 -1
  20. package/dist/lib/log-safe.d.ts +6 -0
  21. package/dist/lib/log-safe.js +9 -0
  22. package/dist/lib/payez-client-secret.js +2 -1
  23. package/dist/lib/token-pages-switch.d.ts +23 -0
  24. package/dist/lib/token-pages-switch.js +48 -0
  25. package/dist/pages/first-enroll/page.d.ts +7 -0
  26. package/dist/pages/first-enroll/page.js +15 -0
  27. package/dist/pages/test-env/JwtInspectPage.d.ts +16 -14
  28. package/dist/pages/test-env/JwtInspectPage.js +16 -14
  29. package/dist/pages/test-env/RefreshTokenPage.d.ts +7 -10
  30. package/dist/pages/test-env/RefreshTokenPage.js +7 -10
  31. package/dist/pages/test-env/guarded.js +21 -57
  32. package/dist/pages/test-env/index.d.ts +15 -24
  33. package/dist/pages/test-env/index.js +15 -26
  34. package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
  35. package/dist/routes/mfa/first-enrol-confirm.js +14 -0
  36. package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
  37. package/dist/routes/mfa/first-enrol-enroll.js +14 -0
  38. package/package.json +26 -1
  39. package/src/api-handlers/auth/credential-login.ts +49 -0
  40. package/src/api-handlers/mfa/first-enrol.ts +55 -0
  41. package/src/api-handlers/mfa/totp-confirm.ts +13 -5
  42. package/src/api-handlers/test/force-expire.ts +13 -70
  43. package/src/auth/better-auth.ts +44 -2
  44. package/src/components/auth/CredentialSignInForm.tsx +18 -1
  45. package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
  46. package/src/lib/credential-sign-in-client.ts +13 -1
  47. package/src/lib/first-enrol.ts +180 -0
  48. package/src/lib/idp-client-config.ts +2 -2
  49. package/src/lib/log-safe.ts +9 -0
  50. package/src/lib/payez-client-secret.ts +2 -1
  51. package/src/lib/token-pages-switch.ts +44 -0
  52. package/src/pages/first-enroll/page.tsx +19 -0
  53. package/src/pages/test-env/JwtInspectPage.tsx +16 -14
  54. package/src/pages/test-env/RefreshTokenPage.tsx +7 -10
  55. package/src/pages/test-env/guarded.tsx +21 -55
  56. package/src/pages/test-env/index.ts +15 -26
  57. package/src/routes/mfa/first-enrol-confirm.ts +10 -0
  58. package/src/routes/mfa/first-enrol-enroll.ts +10 -0
@@ -27,13 +27,13 @@ import {
27
27
  type IdpVerifiedClaims,
28
28
  } from './idp-oauth';
29
29
  import type { IDPClientConfig } from '../lib/idp-client-config';
30
- import { getIDPClientConfig } from '../lib/idp-client-config';
30
+ import { getIDPClientConfig, providerSecretEnvVar } from '../lib/idp-client-config';
31
31
  import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
32
32
  import { getRedis } from '../lib/redis';
33
33
  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
- import { errForLog } from '../lib/log-safe';
36
+ import { errForLog, logSafeName } from '../lib/log-safe';
37
37
  import { warnIfMagicLinkBelowFix } from './better-auth-exposure';
38
38
  import { noStore } from '../lib/no-store';
39
39
 
@@ -277,6 +277,28 @@ export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProvid
277
277
  };
278
278
  }
279
279
 
280
+ /**
281
+ * PAY-3139: the credentials better-auth ITSELF requires before it can build a provider's authorize URL.
282
+ *
283
+ * Read from @better-auth/core 1.5.6 (social-providers/*.mjs, createAuthorizationURL): google throws
284
+ * `BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED")` when clientId OR clientSecret is empty. That is a plain
285
+ * throw, not an APIError, so the sign-in door answered a 500 with an EMPTY body (dev-93, 14 Nov: the IdP served
286
+ * the public, secret-redacted client config, so google's secret arrived empty). apple, facebook and github have
287
+ * no such guard (facebook signs in by id_token; apple's secret is the IdP's), so only the id is required there:
288
+ * an empty client id cannot produce a working authorize URL for any provider.
289
+ *
290
+ * Returns the missing FIELD NAMES, never a value.
291
+ */
292
+ export function missingProviderCredentials(
293
+ name: string,
294
+ oauth: { clientId?: string | null; clientSecret?: string | null }
295
+ ): Array<'clientId' | 'clientSecret'> {
296
+ const required: Array<'clientId' | 'clientSecret'> =
297
+ name === 'google' ? ['clientId', 'clientSecret'] : ['clientId'];
298
+ return required.filter((field) => String(oauth[field] ?? '').trim() === '');
299
+ }
300
+
301
+
280
302
  export function buildBetterAuthProviders(
281
303
  config: IDPClientConfig
282
304
  ): Record<string, BetterAuthSocialProvider> {
@@ -285,6 +307,26 @@ export function buildBetterAuthProviders(
285
307
  for (const oauth of config.oauthProviders || []) {
286
308
  if (!oauth.enabled) continue;
287
309
  const name = oauth.provider.toLowerCase();
310
+
311
+ /**
312
+ * PAY-3139: an ENABLED row that lacks a credential better-auth requires is LEFT OUT, loudly. Left in, its
313
+ * sign-in throws inside better-auth and the door answers an empty 500 that names nothing. Left out, the
314
+ * same door answers better-auth's typed 404 PROVIDER_NOT_FOUND, and this line names the provider and the
315
+ * missing field (never a value), once per instance build.
316
+ */
317
+ const missing = missingProviderCredentials(name, oauth);
318
+ if (missing.length > 0) {
319
+ const secretHint = missing.includes('clientSecret')
320
+ ? ` (the secret is read env-first from ${providerSecretEnvVar(oauth.provider)}, else from the IdP's ` +
321
+ `secret-bearing client config, which a client that sends no valid client_secret does not receive)`
322
+ : '';
323
+ console.error(
324
+ `[BETTER_AUTH] PROVIDER_CREDENTIALS_MISSING provider=${logSafeName(name)} missing=${missing.join(',')}: ` +
325
+ `left out of the sign-in providers, so its sign-in answers PROVIDER_NOT_FOUND until the ` +
326
+ `${missing.join(' and ')} reach this app${secretHint}`
327
+ );
328
+ continue;
329
+ }
288
330
  const additionalParams = oauth.additionalParams ?? {};
289
331
  const rawPrompt = additionalParams.prompt;
290
332
  const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
@@ -51,6 +51,11 @@ interface Props {
51
51
  * default would 404 a generated app's user right after a good sign-in).
52
52
  */
53
53
  enrollHref?: string;
54
+ /**
55
+ * AUTH-045: where a person is sent when the IdP says they must ENROL a factor and they have NO session (the door parked an httpOnly enrolment cookie).
56
+ * Distinct from enrollHref (which needs a session). NO DEFAULT: without it the form says so instead of redrawing the code box.
57
+ */
58
+ firstEnrolHref?: string;
54
59
  }
55
60
 
56
61
  const inputStyle: React.CSSProperties = {
@@ -73,7 +78,7 @@ export function resolveDestination(explicit?: string): string {
73
78
  return safeCallbackPath(authorizeResumeHref(returnUrl), safeCallbackPath(raw, '/dashboard'));
74
79
  }
75
80
 
76
- export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }: Props) {
81
+ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref, firstEnrolHref }: Props) {
77
82
  const [step, setStep] = useState<'credentials' | 'code'>('credentials');
78
83
  const [email, setEmail] = useState('');
79
84
  const [password, setPassword] = useState('');
@@ -103,6 +108,18 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
103
108
  else window.location.href = destination;
104
109
  return;
105
110
  }
111
+ if (r.kind === 'enrol_required') {
112
+ // AUTH-045: the code was accepted as contact proof; the door parked the enrolment-scoped token in an httpOnly cookie. Go and enrol: never redraw
113
+ // the code box (the 14 Nov walk looped there). A host that gave no enrolment page gets a sentence, not a silent loop.
114
+ if (firstEnrolHref) {
115
+ if (onSignedIn) onSignedIn(firstEnrolHref);
116
+ else window.location.href = firstEnrolHref;
117
+ return;
118
+ }
119
+ setError('This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner.');
120
+ setErrorCode('sign_in_failed');
121
+ return;
122
+ }
106
123
  if (r.kind === 'two_factor') {
107
124
  setCodeRequest(r.codeRequest);
108
125
  setDevCode(r.devCode);
@@ -0,0 +1,189 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import { QrCode } from './QrCode';
5
+
6
+ /**
7
+ * AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969): the panel for a person the IdP told to ENROL a factor on THIS app, who has NO session.
8
+ * Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
9
+ * POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
10
+ * POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
11
+ *
12
+ * Rules: the secret and otpauth_uri live in component state only (never a URL, storage, console or a third-party QR service); a 401 is never a
13
+ * message box (the person is sent back to sign-in); a fixed sentence per refusal code, never the server's text; no session is minted here.
14
+ */
15
+
16
+ export const FIRST_ENROL_ENROLL_URL = '/api/auth/first-enrol/enroll';
17
+ export const FIRST_ENROL_CONFIRM_URL = '/api/auth/first-enrol/confirm';
18
+
19
+ /** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
20
+ export const PER_APP_SENTENCE = 'This app has its own authenticator entry: an authenticator you set up for another app or site does not carry over, so you add one here once.';
21
+ export const DONE_SENTENCE = 'Your authenticator is set. Sign in with your password and the code from your app.';
22
+ export const SIGN_IN_AGAIN_SENTENCE = 'This setup timed out or the sign-in expired. Sign in again to continue.';
23
+
24
+ const REFUSAL_COPY: Record<string, string> = {
25
+ CODE_INVALID: 'That code did not work. Check the 6 digits in your authenticator app and try again.',
26
+ TOO_MANY: 'Too many attempts. Wait a few minutes and sign in again.',
27
+ ENROLMENT_RESTART: 'This setup timed out. Reload the page to get a new code to scan.',
28
+ UNAVAILABLE: 'We could not reach the sign-in service. Nothing was changed. Try again in a moment.',
29
+ };
30
+ const FALLBACK_COPY = 'Setup was not completed. Try again.';
31
+
32
+ type Enrollment = { secret: string; otpauth_uri: string; enrollment_id: number };
33
+ type Stage = 'starting' | 'enrolling' | 'done' | 'failed';
34
+
35
+ const readJson = async (res: Response): Promise<any> => res.json().catch(() => null);
36
+
37
+ export function FirstEnrolPanel({
38
+ fetchImpl,
39
+ signInHref = '/account-auth/login',
40
+ onSignInAgain,
41
+ }: { fetchImpl?: typeof fetch; signInHref?: string; onSignInAgain?: (href: string) => void } = {}) {
42
+ const doFetch: typeof fetch = fetchImpl ?? ((...a) => fetch(...a));
43
+ const [stage, setStage] = useState<Stage>('starting');
44
+ const [enroll, setEnroll] = useState<Enrollment | null>(null);
45
+ const [code, setCode] = useState('');
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [codes, setCodes] = useState<string[] | null>(null);
48
+ const [busy, setBusy] = useState(false);
49
+ const started = useRef(false);
50
+
51
+ const toSignIn = () => {
52
+ if (onSignInAgain) onSignInAgain(signInHref);
53
+ else window.location.href = signInHref;
54
+ };
55
+
56
+ async function begin() {
57
+ setBusy(true);
58
+ setError(null);
59
+ try {
60
+ const res = await doFetch(FIRST_ENROL_ENROLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ friendly_name: 'This device' }) });
61
+ if (res.status === 401) return toSignIn(); // a 401 is never a message box
62
+ const body = await readJson(res);
63
+ if (res.ok && body?.data?.secret && body?.data?.otpauth_uri && Number.isInteger(body?.data?.enrollment_id)) {
64
+ setEnroll({ secret: body.data.secret, otpauth_uri: body.data.otpauth_uri, enrollment_id: body.data.enrollment_id });
65
+ setStage('enrolling');
66
+ return;
67
+ }
68
+ setError(REFUSAL_COPY[body?.code as string] ?? FALLBACK_COPY);
69
+ setStage('failed');
70
+ } catch {
71
+ setError(REFUSAL_COPY.UNAVAILABLE);
72
+ setStage('failed');
73
+ } finally {
74
+ setBusy(false);
75
+ }
76
+ }
77
+
78
+ useEffect(() => {
79
+ if (started.current) return;
80
+ started.current = true;
81
+ void begin();
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, []);
84
+
85
+ async function confirm() {
86
+ if (!enroll || !/^\d{6}$/.test(code)) {
87
+ setError('Enter the 6-digit code from your app.');
88
+ return;
89
+ }
90
+ setBusy(true);
91
+ setError(null);
92
+ try {
93
+ const res = await doFetch(FIRST_ENROL_CONFIRM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }) });
94
+ if (res.status === 401) return toSignIn();
95
+ const body = await readJson(res);
96
+ if (res.ok && body?.confirmed === true) {
97
+ setEnroll(null);
98
+ setCode('');
99
+ setCodes(Array.isArray(body.codes) ? body.codes.filter((c: unknown) => typeof c === 'string') : null);
100
+ setStage('done');
101
+ return;
102
+ }
103
+ setCode('');
104
+ setError(REFUSAL_COPY[body?.code as string] ?? FALLBACK_COPY);
105
+ } catch {
106
+ setError(REFUSAL_COPY.UNAVAILABLE);
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ }
111
+
112
+ if (stage === 'done') {
113
+ return (
114
+ <div data-testid="first-enrol-done" role="status">
115
+ <p>{DONE_SENTENCE}</p>
116
+ {codes && codes.length > 0 ? (
117
+ <>
118
+ <p>Save these recovery codes somewhere safe. Each one works once, and they are not shown again.</p>
119
+ <ul data-testid="first-enrol-codes">
120
+ {codes.map((c) => (
121
+ <li key={c}>
122
+ <code>{c}</code>
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ </>
127
+ ) : (
128
+ <p data-testid="first-enrol-no-codes">We could not show your recovery codes. You can create new ones after you sign in.</p>
129
+ )}
130
+ <p>
131
+ <a href={signInHref}>Sign in</a>
132
+ </p>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ return (
138
+ <div data-testid="first-enrol-panel" style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
139
+ <p data-testid="first-enrol-per-app">{PER_APP_SENTENCE}</p>
140
+ {stage === 'starting' ? <p data-testid="first-enrol-starting">Setting up…</p> : null}
141
+ {stage === 'enrolling' && enroll ? (
142
+ <>
143
+ <p>Open your authenticator app and scan this code. A phone camera works too.</p>
144
+ <QrCode value={enroll.otpauth_uri} />
145
+ <div>
146
+ <div>Can&apos;t scan? Enter this code manually:</div>
147
+ <code data-testid="first-enrol-secret" style={{ wordBreak: 'break-all' }}>
148
+ {enroll.secret}
149
+ </code>
150
+ </div>
151
+ <form
152
+ onSubmit={(e) => {
153
+ e.preventDefault();
154
+ void confirm();
155
+ }}
156
+ >
157
+ <label htmlFor="first-enrol-code">Enter the 6-digit code from your app:</label>
158
+ <br />
159
+ <input
160
+ id="first-enrol-code"
161
+ data-testid="first-enrol-code"
162
+ inputMode="numeric"
163
+ autoComplete="one-time-code"
164
+ maxLength={6}
165
+ value={code}
166
+ disabled={busy}
167
+ onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
168
+ />{' '}
169
+ <button type="submit" data-testid="first-enrol-confirm" disabled={busy || code.length !== 6}>
170
+ Confirm
171
+ </button>
172
+ </form>
173
+ </>
174
+ ) : null}
175
+ {error ? (
176
+ <p role="alert" data-testid="first-enrol-error">
177
+ {error}
178
+ </p>
179
+ ) : null}
180
+ {stage === 'failed' ? (
181
+ <p>
182
+ <a href={signInHref}>Sign in again</a>
183
+ </p>
184
+ ) : null}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ export default FirstEnrolPanel;
@@ -24,6 +24,8 @@ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
24
24
  export type CredentialSignInResult =
25
25
  | { kind: 'success' }
26
26
  | { kind: 'two_factor'; codeRequest: CodeRequest; devCode: string | null; nextStep: NextStep }
27
+ /** AUTH-045: the code was accepted as contact proof and the IdP says this user must ENROL a factor on this client. Its own state, so it can never be drawn as the code box. */
28
+ | { kind: 'enrol_required' }
27
29
  | { kind: 'rate_limited'; message: string; code: ErrorCode }
28
30
  | { kind: 'error'; message: string; code: ErrorCode; offerEmailCode: boolean };
29
31
 
@@ -104,7 +106,17 @@ export async function credentialSignIn(
104
106
  const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
105
107
  return { kind: 'rate_limited', message: tooManyAttemptsMessage(body?.retryAfter, code), code };
106
108
  }
107
- if (body?.requiresTwoFactor) return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
109
+ if (body?.requiresTwoFactor) {
110
+ // AUTH-045 (BAPert 100969 rows 1-2): requiresTwoFactor alone cannot mean "go enrol" (it is also the password turn's "a code was sent"); the walk of
111
+ // 14 Nov showed the form redrawing the code box forever. The door's enrolmentRequired flag is the only signal for enrolment, and anything the
112
+ // client does not understand is a VISIBLE error, never a redraw.
113
+ const raw = body?.nextStep;
114
+ const unexpected: CredentialSignInResult = { kind: 'error', message: MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
115
+ if (body?.enrolmentRequired === true) return raw === 'enroll_mfa' ? { kind: 'enrol_required' } : unexpected;
116
+ if (raw !== undefined && raw !== null && raw !== 'enroll_mfa' && raw !== 'mfa_verify') return unexpected;
117
+ if (args.code && raw === 'enroll_mfa') return unexpected; // a CODE turn told "enrol" without the flag is an older door: never a redraw
118
+ return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
119
+ }
108
120
  if (!res.ok || body?.success !== true) {
109
121
  const offerEmailCode = body?.offerEmailCode === true;
110
122
  if (isSignInFailureCode(body?.code)) return { kind: 'error', message: signInFailureMessage(body.code) as string, code: body.code, offerEmailCode };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969): the server logic of the two enrolment calls a generated app makes for a user the IdP told
3
+ * to ENROL a factor on this client. Ported from VibeID-Customer-Portal PAY-2798 part 2 (src/lib/auth/enrol-core.ts + enrol-required.ts at a9ab685c);
4
+ * the pattern is not redesigned.
5
+ *
6
+ * WHY IT EXISTS. The credential door (api-handlers/auth/credential-login) answers an enrolment-scoped token (access, NO refresh) with the enrolment
7
+ * answer and mints NO session, so the package's session-gated MFA routes cannot serve this user. The door parks that token in an httpOnly cookie
8
+ * (ENROL_COOKIE); the two routes built on this module read ONLY that cookie, and ONLY for these two operations. No other route reads it.
9
+ *
10
+ * Rules held here (each has a row in test/first-enrol-core.test.cjs): the scoped token and the full pair a confirm answers NEVER appear in a returned
11
+ * body; the IdP's prose is never relayed; nothing is logged; the confirm body is a whitelist; the recovery codes are fetched with the confirm's full
12
+ * pair SERVER-SIDE ONLY and that pair is then dropped (this module mints no session: what follows is the sign-in the person already knows).
13
+ *
14
+ * No framework imports: a test runs it with a stubbed fetch.
15
+ */
16
+
17
+ export const ENROL_COOKIE = 'payez_enrol';
18
+ export const ENROL_COOKIE_MAX_AGE = 600;
19
+ export type EnrolStep = 'enroll' | 'confirm';
20
+ export const ENROL_STEPS: readonly EnrolStep[] = ['enroll', 'confirm'];
21
+
22
+ export type Fetcher = (url: string, init: RequestInit & { headers: Record<string, string> }) => Promise<Response>;
23
+ export type CoreDeps = { fetchImpl: Fetcher; idp: string; clientKey: string };
24
+ /** `cookie`: what the route does with the enrolment cookie. */
25
+ export type CoreResult = { status: number; body: Record<string, unknown>; cookie: 'clear' | 'keep' };
26
+
27
+ const SENTENCE_UNAVAILABLE = 'We could not reach the sign-in service. Nothing was changed. Try again in a moment.';
28
+
29
+ export function enrolIdpPath(step: EnrolStep): string {
30
+ return step === 'enroll' ? '/api/Account/mfa/totp/enroll' : '/api/Account/mfa/totp/confirm';
31
+ }
32
+
33
+ /** The body each step may carry: enroll { friendly_name? }, confirm { enrollment_id, code }. null = nothing is sent. */
34
+ export function enrolBodyFor(step: EnrolStep, raw: unknown): Record<string, unknown> | null {
35
+ const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
36
+ if (step === 'enroll') {
37
+ const n = src.friendly_name;
38
+ return typeof n === 'string' && n.trim() !== '' && n.length <= 64 ? { friendly_name: n.trim() } : {};
39
+ }
40
+ const id = src.enrollment_id;
41
+ const code = src.code;
42
+ if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) return null;
43
+ if (typeof code !== 'string' || !/^\d{6}$/.test(code)) return null;
44
+ return { enrollment_id: id, code };
45
+ }
46
+
47
+ const unwrap = (b: unknown): Record<string, unknown> | null => {
48
+ if (!b || typeof b !== 'object' || Array.isArray(b)) return null;
49
+ const o = b as Record<string, unknown>;
50
+ return o.data && typeof o.data === 'object' && !Array.isArray(o.data) ? (o.data as Record<string, unknown>) : o;
51
+ };
52
+ const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : null);
53
+
54
+ export function parseEnrol(body: unknown): { secret: string; otpauthUri: string; enrollmentId: number } | null {
55
+ const d = unwrap(body);
56
+ const secret = str(d?.secret);
57
+ const uri = str(d?.otpauth_uri);
58
+ const id = d?.enrollment_id;
59
+ if (!d || !secret || !uri || !uri.startsWith('otpauth://totp/') || typeof id !== 'number' || !Number.isInteger(id)) return null;
60
+ return { secret, otpauthUri: uri, enrollmentId: id };
61
+ }
62
+
63
+ export function parseConfirmed(body: unknown): boolean {
64
+ return unwrap(body)?.confirmed === true;
65
+ }
66
+
67
+ export function parseCodes(body: unknown): string[] | null {
68
+ const d = unwrap(body);
69
+ const codes = d?.codes;
70
+ if (!Array.isArray(codes) || codes.length === 0 || !codes.every((c) => typeof c === 'string' && c !== '')) return null;
71
+ return codes as string[];
72
+ }
73
+
74
+ export function codesForDisplay(codes: readonly string[]): string[] {
75
+ return codes.map((c) => c.trim()).filter((c) => c !== '');
76
+ }
77
+
78
+ /** What a person reads for a failed enrol / confirm call (never the server's text). 'sign-in' = 401/403: the page goes to sign-in, never a message box. */
79
+ export function enrolRefusal(step: EnrolStep, status: number, body: unknown): { status: number; code: string; text: string } | 'sign-in' {
80
+ if (status === 401 || status === 403) return 'sign-in';
81
+ const c = (() => {
82
+ const b = body as { code?: unknown; error?: { code?: unknown } | string } | null;
83
+ if (b && typeof b.code === 'string') return b.code;
84
+ if (b && typeof b.error === 'object' && b.error && typeof (b.error as { code?: unknown }).code === 'string') return (b.error as { code: string }).code;
85
+ return null;
86
+ })();
87
+ if (status === 429) return { status: 429, code: 'TOO_MANY', text: 'Too many attempts. Wait a few minutes and try again.' };
88
+ if (step === 'confirm' && status >= 400 && status < 500) {
89
+ if (c === 'enrollment_not_found') return { status: 400, code: 'ENROLMENT_RESTART', text: 'This setup timed out. Reload the page to get a new code to scan.' };
90
+ return { status: 400, code: 'CODE_INVALID', text: 'That code did not work. Check the 6 digits in your authenticator app and try again.' };
91
+ }
92
+ return { status: 502, code: 'UNAVAILABLE', text: SENTENCE_UNAVAILABLE };
93
+ }
94
+
95
+ const headers = (token: string, clientKey: string): Record<string, string> => ({
96
+ 'Content-Type': 'application/json',
97
+ Accept: 'application/json',
98
+ Authorization: `Bearer ${token}`,
99
+ 'X-Client-Id': clientKey,
100
+ });
101
+ const asJson = async (res: Response) => res.json().catch(() => null);
102
+
103
+ export async function runEnrolStep(deps: CoreDeps, input: { step: EnrolStep; token: string; body: unknown }): Promise<CoreResult> {
104
+ const body = enrolBodyFor(input.step, input.body);
105
+ if (body === null) return { status: 400, body: { success: false, code: 'INVALID_REQUEST' }, cookie: 'keep' };
106
+
107
+ let res: Response;
108
+ try {
109
+ res = await deps.fetchImpl(`${deps.idp}${enrolIdpPath(input.step)}`, {
110
+ method: 'POST',
111
+ headers: headers(input.token, deps.clientKey),
112
+ body: JSON.stringify(body),
113
+ cache: 'no-store',
114
+ });
115
+ } catch {
116
+ return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: SENTENCE_UNAVAILABLE }, cookie: 'keep' };
117
+ }
118
+ const answer = await asJson(res);
119
+
120
+ if (res.status < 200 || res.status >= 300) {
121
+ const refusal = enrolRefusal(input.step, res.status, answer);
122
+ if (refusal === 'sign-in') return { status: 401, body: { success: false, code: 'SIGN_IN_AGAIN' }, cookie: 'clear' };
123
+ // A mistyped code must not cost a whole sign-in (the attempt limit bounds it); every other refusal ends this enrolment.
124
+ return { status: refusal.status, body: { success: false, code: refusal.code, error: refusal.text }, cookie: refusal.code === 'CODE_INVALID' ? 'keep' : 'clear' };
125
+ }
126
+
127
+ if (input.step === 'enroll') {
128
+ const e = parseEnrol(answer);
129
+ if (!e) return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: 'We could not read the setup details. Nothing was changed. Reload the page.' }, cookie: 'clear' };
130
+ return { status: 200, body: { success: true, data: { secret: e.secret, otpauth_uri: e.otpauthUri, enrollment_id: e.enrollmentId } }, cookie: 'keep' };
131
+ }
132
+
133
+ // confirm: the factor is confirmed; the answer carries the FULL pair, used here ONLY to fetch the recovery codes, then dropped.
134
+ if (!parseConfirmed(answer)) return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: 'We could not confirm that. Try the code again.' }, cookie: 'keep' };
135
+ const d = unwrap(answer);
136
+ const full = str(d?.access_token);
137
+ let codes: string[] | null = null;
138
+ if (full) {
139
+ try {
140
+ const c = await deps.fetchImpl(`${deps.idp}/api/Account/mfa/backup-codes`, { method: 'POST', headers: headers(full, deps.clientKey), body: '{}', cache: 'no-store' });
141
+ const list = c.ok ? parseCodes(await asJson(c)) : null;
142
+ codes = list ? codesForDisplay(list) : null;
143
+ } catch {
144
+ codes = null;
145
+ }
146
+ }
147
+ return { status: 200, body: { success: true, confirmed: true, codes }, cookie: 'clear' };
148
+ }
149
+
150
+ // ── attempt limit: per enrolment cookie (in-memory, per instance), hard-capped ──
151
+ const WINDOW_MS = ENROL_COOKIE_MAX_AGE * 1000;
152
+ export const LIMIT_PER_COOKIE = 5;
153
+ const hits = new Map<string, number[]>();
154
+ /**
155
+ * HARD CAP (portal PAY-2913, BAPert 90048): the map never holds more than this many keys; past it the OLDEST-TOUCHED key is evicted.
156
+ * KNOWN TRADE: a flood of forged cookies can push a real caller's counter out and reset its budget; bounded by this cap and by the IdP's own throttle.
157
+ * Not ported from the portal: the per-IP limit (it needs the trusted-proxy setting, PORTAL_TRUSTED_PROXY_HOPS, which a generated app does not have).
158
+ */
159
+ export const MAX_TRACKED_KEYS = 5000;
160
+
161
+ /** True when this key may attempt now; records the attempt. Keys are never the raw cookie (the route passes a digest). */
162
+ export function attemptAllowed(key: string, limit: number, now: number = Date.now()): boolean {
163
+ const recent = (hits.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
164
+ const allowed = recent.length < limit;
165
+ if (allowed) recent.push(now);
166
+ hits.delete(key); // re-insert at the end: Map order is last-touched order
167
+ hits.set(key, recent);
168
+ while (hits.size > MAX_TRACKED_KEYS) {
169
+ const oldest = hits.keys().next().value;
170
+ if (oldest === undefined) break;
171
+ hits.delete(oldest);
172
+ }
173
+ return allowed;
174
+ }
175
+ export function _resetAttempts(): void {
176
+ hits.clear();
177
+ }
178
+ export function _trackedKeys(): number {
179
+ return hits.size;
180
+ }
@@ -44,7 +44,7 @@ import 'server-only';
44
44
  import { createHash, randomUUID } from 'crypto';
45
45
  import { markNeverInResponse } from './never-in-response';
46
46
  import { clientSecretBody, warnIfProviderSecretRedacted } from './payez-client-secret';
47
- import { errForLog } from './log-safe';
47
+ import { errForLog, logSafeName } from './log-safe';
48
48
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
49
49
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
50
50
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -225,7 +225,7 @@ function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
225
225
  const envVar = providerSecretEnvVar(p.provider);
226
226
  const fromEnv = process.env[envVar];
227
227
  const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
228
- logSecretSource(`${p.provider} clientSecret`, source);
228
+ logSecretSource(`${logSafeName(p.provider)} clientSecret`, source);
229
229
  const clientSecret = fromEnv || p.clientSecret;
230
230
  markNeverInResponse(clientSecret);
231
231
  return { ...p, clientSecret };
@@ -28,3 +28,12 @@ export function errForLog(err: unknown): string {
28
28
  const code = closed ? ` code=${closed}` : '';
29
29
  return `error=${name}${status}${code}`;
30
30
  }
31
+
32
+ /**
33
+ * A provider name (or any other IdP-supplied identifier) for a log line: lower-cased, then anything outside [a-z0-9_-] becomes '?',
34
+ * so a name carrying a newline cannot forge a second log line (Nextpert-Scout 102543 LOW 1; the shared helper per 104626, used at
35
+ * every site that logs an IdP provider name: better-auth.ts, payez-client-secret.ts, idp-client-config.ts).
36
+ */
37
+ export function logSafeName(name: unknown): string {
38
+ return String(name ?? '').toLowerCase().replace(/[^a-z0-9_-]/g, '?');
39
+ }
@@ -5,6 +5,7 @@
5
5
  * what it sent before. The value is never logged and never leaves in a response (markNeverInResponse).
6
6
  */
7
7
  import { markNeverInResponse } from './never-in-response';
8
+ import { logSafeName } from './log-safe';
8
9
 
9
10
  export const CLIENT_SECRET_ENV = 'PAYEZ_CLIENT_SECRET';
10
11
 
@@ -34,6 +35,6 @@ export function warnIfProviderSecretRedacted(
34
35
  if (process.env[envVarFor(p.provider)]) continue;
35
36
  if (warnedProviders.has(p.provider)) continue;
36
37
  warnedProviders.add(p.provider);
37
- console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${p.provider}`);
38
+ console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${logSafeName(p.provider)}`);
38
39
  }
39
40
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * PAY-3220 (ruled Win 105653 s1, on NextPert-MB's Mac 105619): the ONE production switch for the two token-showing
3
+ * test-env pages (JwtInspectPage, RefreshTokenPage) and the route the refresh page drives (POST /api/test/force-expire).
4
+ *
5
+ * PAYEZ_TOKEN_PAGES_OFF unset or empty -> ON (every page works as today; the default everywhere)
6
+ * true / 1 / yes / on -> OFF (any letter case)
7
+ * false / 0 / no / off -> ON (any letter case), no warning
8
+ * anything else -> OFF, and ONE warning per process naming the variable
9
+ *
10
+ * Why an unrecognised value is OFF (the ruling's one change to the recommendation): nobody sets this variable except to
11
+ * turn the pages off, on Jon's word, on a production host. A mistyped attempt to turn them off must not leave
12
+ * token-showing pages on in production with only a log line to say so.
13
+ *
14
+ * Read from the server's process environment on every call, never captured at module scope (a module constant would be
15
+ * whatever the build host had). It is not NEXT_PUBLIC_, so it never reaches a client bundle, and no request input
16
+ * (cookie, header, query string, body, session) is consulted: only whoever can change the host's environment can turn it.
17
+ *
18
+ * Set ONLY on a production host, ONLY on Jon's word, on the day production is real. It is never in a tracked .env file.
19
+ * The switch is not counted as set until the production roll's served check reads both pages and force-expire back as
20
+ * 404 by request (BAPert-MB 105629 s3).
21
+ */
22
+ export const TOKEN_PAGES_OFF_ENV = 'PAYEZ_TOKEN_PAGES_OFF';
23
+
24
+ const OFF_WORDS = new Set(['true', '1', 'yes', 'on']);
25
+ const ON_WORDS = new Set(['false', '0', 'no', 'off']);
26
+ let warnedUnrecognised = false;
27
+
28
+ export function tokenPagesOff(): boolean {
29
+ const raw = process.env[TOKEN_PAGES_OFF_ENV];
30
+ if (raw === undefined) return false;
31
+ const value = raw.trim().toLowerCase();
32
+ if (value === '') return false;
33
+ if (OFF_WORDS.has(value)) return true;
34
+ if (ON_WORDS.has(value)) return false;
35
+ if (!warnedUnrecognised) {
36
+ warnedUnrecognised = true;
37
+ // The variable's NAME only. The value is not a secret, but nothing about it helps the reader of this line.
38
+ console.warn(
39
+ `[test-env] ${TOKEN_PAGES_OFF_ENV} is set to a value that is not recognised (true/1/yes/on or false/0/no/off); ` +
40
+ 'the token pages are OFF: jwt-inspect, refresh-token and POST /api/test/force-expire answer 404.',
41
+ );
42
+ }
43
+ return true;
44
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { FirstEnrolPanel } from '../../components/mfa/FirstEnrolPanel';
5
+
6
+ /**
7
+ * AUTH-045 (PAY-2938): /account-auth/first-enroll in a generated app, the page a person is sent to when the IdP says they must enrol an authenticator
8
+ * on this app and they have NO session yet. The page SHELL is public (every /account-auth/* path is public by default); the two routes behind it read
9
+ * ONLY the httpOnly enrolment cookie the credential door parked, and answer 401 without it (the panel then sends the person to sign-in).
10
+ * Not the signed-in enrolment page (/account-auth/enroll), which needs a session.
11
+ */
12
+ export default function FirstEnrollPage() {
13
+ return (
14
+ <main style={{ maxWidth: 480, margin: '48px auto', padding: '0 16px' }}>
15
+ <h1>Secure your account</h1>
16
+ <FirstEnrolPanel />
17
+ </main>
18
+ );
19
+ }