@payez/next-mvp 4.13.1 → 4.14.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 +10 -1
- package/dist/api-handlers/auth/credential-login.js +46 -0
- package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
- package/dist/api-handlers/mfa/first-enrol.js +58 -0
- package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
- package/dist/components/auth/CredentialSignInForm.js +15 -1
- package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
- package/dist/components/mfa/FirstEnrolPanel.js +115 -0
- package/dist/lib/credential-sign-in-client.d.ts +4 -0
- package/dist/lib/credential-sign-in-client.js +13 -1
- package/dist/lib/first-enrol.d.ts +66 -0
- package/dist/lib/first-enrol.js +188 -0
- package/dist/pages/first-enroll/page.d.ts +7 -0
- package/dist/pages/first-enroll/page.js +15 -0
- package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
- package/dist/routes/mfa/first-enrol-confirm.js +14 -0
- package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
- package/dist/routes/mfa/first-enrol-enroll.js +14 -0
- package/package.json +16 -1
- package/src/api-handlers/auth/credential-login.ts +49 -0
- package/src/api-handlers/mfa/first-enrol.ts +55 -0
- package/src/components/auth/CredentialSignInForm.tsx +18 -1
- package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
- package/src/lib/credential-sign-in-client.ts +13 -1
- package/src/lib/first-enrol.ts +180 -0
- package/src/pages/first-enroll/page.tsx +19 -0
- package/src/routes/mfa/first-enrol-confirm.ts +10 -0
- package/src/routes/mfa/first-enrol-enroll.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# Changelog — @payez/next-mvp
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 4.14.0 (to be published under dist-tag `next` only, after 4.13.1; `latest` stays 4.8.9 until Jon says otherwise)
|
|
4
|
+
|
|
5
|
+
A minor: additive exports, plus one behaviour change in the generated-app credential sign-in (listed first). Built from GitHub `keelsecurity/keelauth-next` main `33c188e0`; this release commit changes only this file and the version. Consumers keep their own pins.
|
|
6
|
+
|
|
7
|
+
- **CHANGE (PAY-3083 / PAY-2938, AUTH-045): a first sign-in on an app with no authenticator now reaches enrolment instead of failing.** When the second-factor step answers with an access token and no refresh token, `api-handlers/auth/credential-login` no longer answers 502. It answers 200 in both cases below, never mints a session, and never echoes or logs the token. **(1) The IdP says `next_step: 'enroll_mfa'` (the enrolment-scoped answer after a verified email code, and the default when the IdP names no step):** the answer is `{ success: false, requiresTwoFactor: true, nextStep: 'enroll_mfa', enrolmentRequired: true }`, parks the token in an httpOnly cookie `payez_enrol` (SameSite=Strict, path `/`, 600 s) that only the two new routes below read; it expires the `adm2fa` and `adm2fa_lane` cookies. **(2) The IdP says `next_step: 'mfa_verify'`:** the answer passes that step through as `nextStep: 'mfa_verify'` with NO `enrolmentRequired` flag, NO `payez_enrol` cookie, and the `adm2fa` and `adm2fa_lane` cookies are left in place (the sign-in form continues at the code step). A refresh-only or empty success is still a 502. TOTP enrolment is per client (per app), by design.
|
|
8
|
+
- **ADDITIVE: new exports** `./routes/mfa/first-enrol-enroll` and `./routes/mfa/first-enrol-confirm` (POST routes that read ONLY the `payez_enrol` cookie, accept JSON only, answer 401 `SIGN_IN_AGAIN` with no cookie, allow 5 attempts per cookie (the 6th is 429 and clears it), and on confirm return the recovery codes and clear the cookie; no session is minted), and `./dist/pages/first-enroll` (the page: QR, the key as selectable text under "Enter this code manually:", a sentence saying the authenticator is for this app only, the recovery codes, then "sign in again").
|
|
9
|
+
- **ADDITIVE: `CredentialSignInForm` takes an optional `firstEnrolHref`**, and `credential-sign-in-client` has a new result kind `enrol_required` (returned only when the answer carries `enrolmentRequired: true` with `nextStep: 'enroll_mfa'`). With the prop set the form navigates there; **without it the form shows "This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner." and does not loop.** A consumer that does not adopt it sees that sentence instead of the old silent loop. Generated apps from `@payez/cli` mount the routes and page and pass the prop (CLI release follows this one).
|
|
10
|
+
- Tests: `test/credential-login.test.cjs`, `credential-sign-in-client.test.cjs`, `first-enrol-core.test.cjs`, `first-enrol-routes.test.cjs`, `first-enrol-panel.test.cjs` (with in-memory mutants, `test/lib/compile-mutant.cjs`).
|
|
11
|
+
|
|
12
|
+
**Also in this release (additive, BAPert 88395/88454):**
|
|
4
13
|
|
|
5
14
|
- **ADDITIVE (MINOR when released): SMS-first sign-up learns the `EMAIL_CONFIRMATION_REQUIRED` step (BAPert 88395 #2, 88454 #3; NextPert 88471).** The server (door 3b) now answers `verify-sms-code` with 200 success, `next_step = EMAIL_CONFIRMATION_REQUIRED` and NO reset token when the email is still unconfirmed. Before this, `accountApi.verifyOnboardingSmsCode` returned `{ success: true, resetToken: '' }` for that answer, which a consumer reads as "verified" and then calls set-password with an EMPTY token. Now: (a) the result type is the exported `SmsCodeVerification` and gains an OPTIONAL `nextStep` (`nextStep` ALONE is read from the nested shapes AND from a flat body; `reset_token`, `email` and `client_key` are still read from the nested shapes only, as before, so a consumer behind a proxy that forwards a flat body gets no token and the helper then says `more_steps`, the safe direction; absent / null / '' = no step; any other value, including `false`, `0` and a non-string, is kept as a non-empty UNRECOGNISED string, never dropped, so a server that means "no step" must send nothing or null); (b) new pure `interpretSmsVerification(result)` returns `verified` (only with a non-empty reset token and no step), `email_confirmation_required` (the package sentence "Your phone is verified. Confirm your email to finish signing up.", no error styling), `more_steps` ("One more step is needed to finish signing up." for ANY other step, and for success with an empty reset token) or `not_verified`; `success: false` WINS over any step (`not_verified`, so a failed answer never says "Your phone is verified"), and for every other answer it tests `nextStep` BEFORE the token; the sentences are the package's own, never the server's text. **The method itself still returns the server's `success` unchanged: a consumer MUST use `interpretSmsVerification` (or test `nextStep` first) and must not treat `success` alone as "verified", because `success: true` with an empty `resetToken` is what the server now sends for the unconfirmed-email case.** No breaking type change: a consumer that ignores `nextStep` compiles unchanged (test S6), and the email variant `verifyOnboardingCode` is untouched. **A consumer must adopt `interpretSmsVerification` (or branch on `nextStep` itself) to show the new screen; until it does, it behaves as before.** There is no SMS-first page in this package: the screen lives in the consuming sites, so the deploy of the server change (PR 85) waits on every live consumer taking this release. Tests: `test/sms-next-step.test.cjs` (32), `test/sms-next-step-mutants.test.cjs` (9 hand mutants, one per branch).
|
|
6
15
|
|
|
@@ -52,6 +52,8 @@ const device_id_1 = require("../../lib/device-id");
|
|
|
52
52
|
const twofa_rejection_1 = require("../../lib/twofa-rejection");
|
|
53
53
|
const bearer_fetch_1 = require("../../lib/bearer-fetch");
|
|
54
54
|
const log_safe_1 = require("../../lib/log-safe");
|
|
55
|
+
// AUTH-045: the httpOnly cookie that carries the enrolment-scoped token from the code turn to the two enrolment routes (ONE name, defined in lib/first-enrol).
|
|
56
|
+
const first_enrol_1 = require("../../lib/first-enrol");
|
|
55
57
|
// `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
|
|
56
58
|
/**
|
|
57
59
|
* The device id to forward to the IDP for THIS caller — card 240566.
|
|
@@ -325,6 +327,50 @@ async function POST(req) {
|
|
|
325
327
|
const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
|
|
326
328
|
const vAccess = vresult?.access_token;
|
|
327
329
|
const vRefresh = vresult?.refresh_token;
|
|
330
|
+
// PAY-3083 (AUTH-045): ACCESS WITHOUT REFRESH AT THE CODE TURN IS NOT A FAILURE - IT IS ENROLMENT.
|
|
331
|
+
//
|
|
332
|
+
// The IdP withholds the refresh token for a user who has no usable second factor on THIS client.
|
|
333
|
+
// TOTP enrolment is PER CLIENT (DotNetPert 99446 sec A, client 90717 / user 1175): the password turn
|
|
334
|
+
// answered Require2FA=true, IssueRefreshToken=false, step=enroll_mfa, and this verify turn then
|
|
335
|
+
// accepted the emailed code as CONTACT PROOF and issued an ENROLMENT-SCOPED access token, again with
|
|
336
|
+
// no refresh token. That is exactly what PAY-2798 requires of the IdP - the IdP is RIGHT.
|
|
337
|
+
//
|
|
338
|
+
// This branch used to read "access, no refresh" as an incomplete response and answer 502, so the
|
|
339
|
+
// developer who had just proved their contact was told the server was broken and could never reach
|
|
340
|
+
// the enrolment page: the 502 the field saw as
|
|
341
|
+
// '[ADMIN-LOGIN] verify response missing token { hasAccess: true, hasRefresh: false }'.
|
|
342
|
+
//
|
|
343
|
+
// Send them to ENROLMENT instead, in the ONE contract the door and the form already share for it
|
|
344
|
+
// (PAY-2630): requiresTwoFactor + nextStep 'enroll_mfa'. No session is minted - an enrolment-scoped
|
|
345
|
+
// token is not a signed-in session - and the token value is NEVER logged nor echoed (BAPert 99521 #2).
|
|
346
|
+
//
|
|
347
|
+
// Guard kept: no access token AT ALL (a refresh-only or empty body) is still a broken success and
|
|
348
|
+
// still 502 below - this widening is only for a genuine access-with-no-refresh answer.
|
|
349
|
+
if (vAccess && !vRefresh) {
|
|
350
|
+
const step = vresult?.next_step === 'enroll_mfa' || vresult?.next_step === 'mfa_verify' ? vresult.next_step : 'enroll_mfa';
|
|
351
|
+
const enrolling = step === 'enroll_mfa';
|
|
352
|
+
const answer = server_1.NextResponse.json({
|
|
353
|
+
success: false,
|
|
354
|
+
requiresTwoFactor: true,
|
|
355
|
+
acceptedFactors: acceptedFactorsFor('email'),
|
|
356
|
+
sentVia: null,
|
|
357
|
+
codeRequested: false,
|
|
358
|
+
emailSent: false,
|
|
359
|
+
nextStep: step,
|
|
360
|
+
// AUTH-045 (walk 14 Nov 2026): requiresTwoFactor alone is also the password turn's "a code was sent", so the form re-drew the code box
|
|
361
|
+
// and looped. This flag is the ONLY signal that means "go enrol"; it is set on this answer and nowhere else.
|
|
362
|
+
...(enrolling ? { enrolmentRequired: true } : {}),
|
|
363
|
+
}, { status: 200 });
|
|
364
|
+
if (enrolling) {
|
|
365
|
+
// No session exists for an enrolment-scoped token, and the enrolment page's actions need one, so the token rides in this httpOnly
|
|
366
|
+
// cookie to the enrolment routes (the portal's PAY-2798 part 2 pattern): never in a body, a URL or a log; strict; 600 s.
|
|
367
|
+
answer.cookies.set(first_enrol_1.ENROL_COOKIE, vAccess, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge: first_enrol_1.ENROL_COOKIE_MAX_AGE });
|
|
368
|
+
// The provisional bearer is spent: the enrolment token replaces it.
|
|
369
|
+
answer.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
|
|
370
|
+
answer.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
|
|
371
|
+
}
|
|
372
|
+
return answer;
|
|
373
|
+
}
|
|
328
374
|
if (!vAccess || !vRefresh) {
|
|
329
375
|
console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
|
|
330
376
|
return server_1.NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { type EnrolStep } from '../../lib/first-enrol';
|
|
3
|
+
export declare function enrolStepPOST(step: EnrolStep): (req: NextRequest) => Promise<NextResponse>;
|
|
4
|
+
export declare const POST_ENROLL: (req: NextRequest) => Promise<NextResponse>;
|
|
5
|
+
export declare const POST_CONFIRM: (req: NextRequest) => Promise<NextResponse>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST_CONFIRM = exports.POST_ENROLL = void 0;
|
|
4
|
+
exports.enrolStepPOST = enrolStepPOST;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const server_1 = require("next/server");
|
|
7
|
+
const idp_client_config_1 = require("../../lib/idp-client-config");
|
|
8
|
+
const client_key_1 = require("../../lib/client-key");
|
|
9
|
+
const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
|
|
10
|
+
const first_enrol_1 = require("../../lib/first-enrol");
|
|
11
|
+
/**
|
|
12
|
+
* AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969 rows 3-5): the TWO routes a user the IdP told to enrol uses, built on lib/first-enrol.
|
|
13
|
+
*
|
|
14
|
+
* WHO MAY CALL: the browser that holds the httpOnly enrolment cookie the credential door parked (ENROL_COOKIE). This is the ONLY module besides the
|
|
15
|
+
* door that reads that cookie, and it serves ONLY these two operations; every other package route stays session-gated and answers 401 to a caller
|
|
16
|
+
* holding nothing but this cookie (a gate row holds that). No cookie -> 401 SIGN_IN_AGAIN and no IdP call.
|
|
17
|
+
*
|
|
18
|
+
* What follows a confirm (BAPert 100969 row 5): the cookie is cleared and the answer carries the recovery codes; NO session is minted here and no new
|
|
19
|
+
* way to mint one is added. The person signs in again with their password and the authenticator, through the same credential door as before.
|
|
20
|
+
*
|
|
21
|
+
* The token never leaves the server: not in a body, a URL, a log or a Referer. The IdP's prose is never relayed.
|
|
22
|
+
*/
|
|
23
|
+
const digest = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
|
|
24
|
+
const cookieOpts = (maxAge) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge });
|
|
25
|
+
function reply(status, body, clear) {
|
|
26
|
+
const res = server_1.NextResponse.json(body, { status, headers: mfa_json_guard_1.NO_STORE });
|
|
27
|
+
if (clear)
|
|
28
|
+
res.cookies.set(first_enrol_1.ENROL_COOKIE, '', cookieOpts(0));
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
function enrolStepPOST(step) {
|
|
32
|
+
return async function POST(req) {
|
|
33
|
+
const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
|
|
34
|
+
if (refused)
|
|
35
|
+
return refused;
|
|
36
|
+
const token = req.cookies.get(first_enrol_1.ENROL_COOKIE)?.value;
|
|
37
|
+
if (!token)
|
|
38
|
+
return reply(401, { success: false, code: 'SIGN_IN_AGAIN' }, false);
|
|
39
|
+
if (!(0, first_enrol_1.attemptAllowed)(`${step}|ck|${digest(token)}`, first_enrol_1.LIMIT_PER_COOKIE))
|
|
40
|
+
return reply(429, { success: false, code: 'TOO_MANY', error: 'Too many attempts. Wait a few minutes and try again.' }, true);
|
|
41
|
+
const idp = process.env.IDP_URL;
|
|
42
|
+
let clientKey = '';
|
|
43
|
+
try {
|
|
44
|
+
const config = await (0, idp_client_config_1.getIDPClientConfig)();
|
|
45
|
+
clientKey = (0, client_key_1.resolveClientKey)(config, 'first-enrol').key ?? '';
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
|
|
49
|
+
}
|
|
50
|
+
if (!idp || !clientKey)
|
|
51
|
+
return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
|
|
52
|
+
const body = await req.json().catch(() => null);
|
|
53
|
+
const r = await (0, first_enrol_1.runEnrolStep)({ fetchImpl: (u, i) => fetch(u, i), idp, clientKey }, { step, token, body });
|
|
54
|
+
return reply(r.status, r.body, r.cookie === 'clear');
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
exports.POST_ENROLL = enrolStepPOST('enroll');
|
|
58
|
+
exports.POST_CONFIRM = enrolStepPOST('confirm');
|
|
@@ -33,7 +33,12 @@ interface Props {
|
|
|
33
33
|
* default would 404 a generated app's user right after a good sign-in).
|
|
34
34
|
*/
|
|
35
35
|
enrollHref?: string;
|
|
36
|
+
/**
|
|
37
|
+
* 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).
|
|
38
|
+
* Distinct from enrollHref (which needs a session). NO DEFAULT: without it the form says so instead of redrawing the code box.
|
|
39
|
+
*/
|
|
40
|
+
firstEnrolHref?: string;
|
|
36
41
|
}
|
|
37
42
|
export declare function resolveDestination(explicit?: string): string;
|
|
38
|
-
export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref }: Props): import("react/jsx-runtime").JSX.Element;
|
|
43
|
+
export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref, firstEnrolHref }: Props): import("react/jsx-runtime").JSX.Element;
|
|
39
44
|
export default CredentialSignInForm;
|
|
@@ -31,7 +31,7 @@ function resolveDestination(explicit) {
|
|
|
31
31
|
}
|
|
32
32
|
return (0, safe_callback_1.safeCallbackPath)((0, authorize_resume_1.authorizeResumeHref)(returnUrl), (0, safe_callback_1.safeCallbackPath)(raw, '/dashboard'));
|
|
33
33
|
}
|
|
34
|
-
function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }) {
|
|
34
|
+
function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref, firstEnrolHref }) {
|
|
35
35
|
const [step, setStep] = (0, react_1.useState)('credentials');
|
|
36
36
|
const [email, setEmail] = (0, react_1.useState)('');
|
|
37
37
|
const [password, setPassword] = (0, react_1.useState)('');
|
|
@@ -62,6 +62,20 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
|
|
|
62
62
|
window.location.href = destination;
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
+
if (r.kind === 'enrol_required') {
|
|
66
|
+
// 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
|
|
67
|
+
// the code box (the 14 Nov walk looped there). A host that gave no enrolment page gets a sentence, not a silent loop.
|
|
68
|
+
if (firstEnrolHref) {
|
|
69
|
+
if (onSignedIn)
|
|
70
|
+
onSignedIn(firstEnrolHref);
|
|
71
|
+
else
|
|
72
|
+
window.location.href = firstEnrolHref;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setError('This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner.');
|
|
76
|
+
setErrorCode('sign_in_failed');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
65
79
|
if (r.kind === 'two_factor') {
|
|
66
80
|
setCodeRequest(r.codeRequest);
|
|
67
81
|
setDevCode(r.devCode);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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.
|
|
3
|
+
* Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
|
|
4
|
+
* POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
|
|
5
|
+
* POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
|
|
6
|
+
*
|
|
7
|
+
* 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
|
|
8
|
+
* 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.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FIRST_ENROL_ENROLL_URL = "/api/auth/first-enrol/enroll";
|
|
11
|
+
export declare const FIRST_ENROL_CONFIRM_URL = "/api/auth/first-enrol/confirm";
|
|
12
|
+
/** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
|
|
13
|
+
export declare 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.";
|
|
14
|
+
export declare const DONE_SENTENCE = "Your authenticator is set. Sign in with your password and the code from your app.";
|
|
15
|
+
export declare const SIGN_IN_AGAIN_SENTENCE = "This setup timed out or the sign-in expired. Sign in again to continue.";
|
|
16
|
+
export declare function FirstEnrolPanel({ fetchImpl, signInHref, onSignInAgain, }?: {
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
signInHref?: string;
|
|
19
|
+
onSignInAgain?: (href: string) => void;
|
|
20
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export default FirstEnrolPanel;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.SIGN_IN_AGAIN_SENTENCE = exports.DONE_SENTENCE = exports.PER_APP_SENTENCE = exports.FIRST_ENROL_CONFIRM_URL = exports.FIRST_ENROL_ENROLL_URL = void 0;
|
|
5
|
+
exports.FirstEnrolPanel = FirstEnrolPanel;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const QrCode_1 = require("./QrCode");
|
|
9
|
+
/**
|
|
10
|
+
* 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.
|
|
11
|
+
* Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
|
|
12
|
+
* POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
|
|
13
|
+
* POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
|
|
14
|
+
*
|
|
15
|
+
* 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
|
|
16
|
+
* 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.
|
|
17
|
+
*/
|
|
18
|
+
exports.FIRST_ENROL_ENROLL_URL = '/api/auth/first-enrol/enroll';
|
|
19
|
+
exports.FIRST_ENROL_CONFIRM_URL = '/api/auth/first-enrol/confirm';
|
|
20
|
+
/** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
|
|
21
|
+
exports.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.';
|
|
22
|
+
exports.DONE_SENTENCE = 'Your authenticator is set. Sign in with your password and the code from your app.';
|
|
23
|
+
exports.SIGN_IN_AGAIN_SENTENCE = 'This setup timed out or the sign-in expired. Sign in again to continue.';
|
|
24
|
+
const REFUSAL_COPY = {
|
|
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
|
+
const readJson = async (res) => res.json().catch(() => null);
|
|
32
|
+
function FirstEnrolPanel({ fetchImpl, signInHref = '/account-auth/login', onSignInAgain, } = {}) {
|
|
33
|
+
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
34
|
+
const [stage, setStage] = (0, react_1.useState)('starting');
|
|
35
|
+
const [enroll, setEnroll] = (0, react_1.useState)(null);
|
|
36
|
+
const [code, setCode] = (0, react_1.useState)('');
|
|
37
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
38
|
+
const [codes, setCodes] = (0, react_1.useState)(null);
|
|
39
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
40
|
+
const started = (0, react_1.useRef)(false);
|
|
41
|
+
const toSignIn = () => {
|
|
42
|
+
if (onSignInAgain)
|
|
43
|
+
onSignInAgain(signInHref);
|
|
44
|
+
else
|
|
45
|
+
window.location.href = signInHref;
|
|
46
|
+
};
|
|
47
|
+
async function begin() {
|
|
48
|
+
setBusy(true);
|
|
49
|
+
setError(null);
|
|
50
|
+
try {
|
|
51
|
+
const res = await doFetch(exports.FIRST_ENROL_ENROLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ friendly_name: 'This device' }) });
|
|
52
|
+
if (res.status === 401)
|
|
53
|
+
return toSignIn(); // a 401 is never a message box
|
|
54
|
+
const body = await readJson(res);
|
|
55
|
+
if (res.ok && body?.data?.secret && body?.data?.otpauth_uri && Number.isInteger(body?.data?.enrollment_id)) {
|
|
56
|
+
setEnroll({ secret: body.data.secret, otpauth_uri: body.data.otpauth_uri, enrollment_id: body.data.enrollment_id });
|
|
57
|
+
setStage('enrolling');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
setError(REFUSAL_COPY[body?.code] ?? FALLBACK_COPY);
|
|
61
|
+
setStage('failed');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
setError(REFUSAL_COPY.UNAVAILABLE);
|
|
65
|
+
setStage('failed');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
(0, react_1.useEffect)(() => {
|
|
72
|
+
if (started.current)
|
|
73
|
+
return;
|
|
74
|
+
started.current = true;
|
|
75
|
+
void begin();
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, []);
|
|
78
|
+
async function confirm() {
|
|
79
|
+
if (!enroll || !/^\d{6}$/.test(code)) {
|
|
80
|
+
setError('Enter the 6-digit code from your app.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setBusy(true);
|
|
84
|
+
setError(null);
|
|
85
|
+
try {
|
|
86
|
+
const res = await doFetch(exports.FIRST_ENROL_CONFIRM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }) });
|
|
87
|
+
if (res.status === 401)
|
|
88
|
+
return toSignIn();
|
|
89
|
+
const body = await readJson(res);
|
|
90
|
+
if (res.ok && body?.confirmed === true) {
|
|
91
|
+
setEnroll(null);
|
|
92
|
+
setCode('');
|
|
93
|
+
setCodes(Array.isArray(body.codes) ? body.codes.filter((c) => typeof c === 'string') : null);
|
|
94
|
+
setStage('done');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setCode('');
|
|
98
|
+
setError(REFUSAL_COPY[body?.code] ?? FALLBACK_COPY);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
setError(REFUSAL_COPY.UNAVAILABLE);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
setBusy(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stage === 'done') {
|
|
108
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "first-enrol-done", role: "status", children: [(0, jsx_runtime_1.jsx)("p", { children: exports.DONE_SENTENCE }), codes && codes.length > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { children: "Save these recovery codes somewhere safe. Each one works once, and they are not shown again." }), (0, jsx_runtime_1.jsx)("ul", { "data-testid": "first-enrol-codes", children: codes.map((c) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("code", { children: c }) }, c))) })] })) : ((0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-no-codes", children: "We could not show your recovery codes. You can create new ones after you sign in." })), (0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("a", { href: signInHref, children: "Sign in" }) })] }));
|
|
109
|
+
}
|
|
110
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "first-enrol-panel", style: { display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }, children: [(0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-per-app", children: exports.PER_APP_SENTENCE }), stage === 'starting' ? (0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-starting", children: "Setting up\u2026" }) : null, stage === 'enrolling' && enroll ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { children: "Open your authenticator app and scan this code. A phone camera works too." }), (0, jsx_runtime_1.jsx)(QrCode_1.QrCode, { value: enroll.otpauth_uri }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { children: "Can't scan? Enter this code manually:" }), (0, jsx_runtime_1.jsx)("code", { "data-testid": "first-enrol-secret", style: { wordBreak: 'break-all' }, children: enroll.secret })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: (e) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
void confirm();
|
|
113
|
+
}, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "first-enrol-code", children: "Enter the 6-digit code from your app:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("input", { id: "first-enrol-code", "data-testid": "first-enrol-code", inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6, value: code, disabled: busy, onChange: (e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6)) }), ' ', (0, jsx_runtime_1.jsx)("button", { type: "submit", "data-testid": "first-enrol-confirm", disabled: busy || code.length !== 6, children: "Confirm" })] })] })) : null, error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "first-enrol-error", children: error })) : null, stage === 'failed' ? ((0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("a", { href: signInHref, children: "Sign in again" }) })) : null] }));
|
|
114
|
+
}
|
|
115
|
+
exports.default = FirstEnrolPanel;
|
|
@@ -25,6 +25,10 @@ export type CredentialSignInResult = {
|
|
|
25
25
|
codeRequest: CodeRequest;
|
|
26
26
|
devCode: string | null;
|
|
27
27
|
nextStep: NextStep;
|
|
28
|
+
}
|
|
29
|
+
/** 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. */
|
|
30
|
+
| {
|
|
31
|
+
kind: 'enrol_required';
|
|
28
32
|
} | {
|
|
29
33
|
kind: 'rate_limited';
|
|
30
34
|
message: string;
|
|
@@ -80,8 +80,20 @@ async function credentialSignIn(args, fetchImpl = (input, init) => fetch(input,
|
|
|
80
80
|
const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
|
|
81
81
|
return { kind: 'rate_limited', message: (0, sign_in_failure_1.tooManyAttemptsMessage)(body?.retryAfter, code), code };
|
|
82
82
|
}
|
|
83
|
-
if (body?.requiresTwoFactor)
|
|
83
|
+
if (body?.requiresTwoFactor) {
|
|
84
|
+
// 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
|
|
85
|
+
// 14 Nov showed the form redrawing the code box forever. The door's enrolmentRequired flag is the only signal for enrolment, and anything the
|
|
86
|
+
// client does not understand is a VISIBLE error, never a redraw.
|
|
87
|
+
const raw = body?.nextStep;
|
|
88
|
+
const unexpected = { kind: 'error', message: exports.MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
|
|
89
|
+
if (body?.enrolmentRequired === true)
|
|
90
|
+
return raw === 'enroll_mfa' ? { kind: 'enrol_required' } : unexpected;
|
|
91
|
+
if (raw !== undefined && raw !== null && raw !== 'enroll_mfa' && raw !== 'mfa_verify')
|
|
92
|
+
return unexpected;
|
|
93
|
+
if (args.code && raw === 'enroll_mfa')
|
|
94
|
+
return unexpected; // a CODE turn told "enrol" without the flag is an older door: never a redraw
|
|
84
95
|
return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
|
|
96
|
+
}
|
|
85
97
|
if (!res.ok || body?.success !== true) {
|
|
86
98
|
const offerEmailCode = body?.offerEmailCode === true;
|
|
87
99
|
if ((0, sign_in_failure_1.isSignInFailureCode)(body?.code))
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
export declare const ENROL_COOKIE = "payez_enrol";
|
|
17
|
+
export declare const ENROL_COOKIE_MAX_AGE = 600;
|
|
18
|
+
export type EnrolStep = 'enroll' | 'confirm';
|
|
19
|
+
export declare const ENROL_STEPS: readonly EnrolStep[];
|
|
20
|
+
export type Fetcher = (url: string, init: RequestInit & {
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
}) => Promise<Response>;
|
|
23
|
+
export type CoreDeps = {
|
|
24
|
+
fetchImpl: Fetcher;
|
|
25
|
+
idp: string;
|
|
26
|
+
clientKey: string;
|
|
27
|
+
};
|
|
28
|
+
/** `cookie`: what the route does with the enrolment cookie. */
|
|
29
|
+
export type CoreResult = {
|
|
30
|
+
status: number;
|
|
31
|
+
body: Record<string, unknown>;
|
|
32
|
+
cookie: 'clear' | 'keep';
|
|
33
|
+
};
|
|
34
|
+
export declare function enrolIdpPath(step: EnrolStep): string;
|
|
35
|
+
/** The body each step may carry: enroll { friendly_name? }, confirm { enrollment_id, code }. null = nothing is sent. */
|
|
36
|
+
export declare function enrolBodyFor(step: EnrolStep, raw: unknown): Record<string, unknown> | null;
|
|
37
|
+
export declare function parseEnrol(body: unknown): {
|
|
38
|
+
secret: string;
|
|
39
|
+
otpauthUri: string;
|
|
40
|
+
enrollmentId: number;
|
|
41
|
+
} | null;
|
|
42
|
+
export declare function parseConfirmed(body: unknown): boolean;
|
|
43
|
+
export declare function parseCodes(body: unknown): string[] | null;
|
|
44
|
+
export declare function codesForDisplay(codes: readonly string[]): string[];
|
|
45
|
+
/** 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. */
|
|
46
|
+
export declare function enrolRefusal(step: EnrolStep, status: number, body: unknown): {
|
|
47
|
+
status: number;
|
|
48
|
+
code: string;
|
|
49
|
+
text: string;
|
|
50
|
+
} | 'sign-in';
|
|
51
|
+
export declare function runEnrolStep(deps: CoreDeps, input: {
|
|
52
|
+
step: EnrolStep;
|
|
53
|
+
token: string;
|
|
54
|
+
body: unknown;
|
|
55
|
+
}): Promise<CoreResult>;
|
|
56
|
+
export declare const LIMIT_PER_COOKIE = 5;
|
|
57
|
+
/**
|
|
58
|
+
* HARD CAP (portal PAY-2913, BAPert 90048): the map never holds more than this many keys; past it the OLDEST-TOUCHED key is evicted.
|
|
59
|
+
* 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.
|
|
60
|
+
* 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).
|
|
61
|
+
*/
|
|
62
|
+
export declare const MAX_TRACKED_KEYS = 5000;
|
|
63
|
+
/** True when this key may attempt now; records the attempt. Keys are never the raw cookie (the route passes a digest). */
|
|
64
|
+
export declare function attemptAllowed(key: string, limit: number, now?: number): boolean;
|
|
65
|
+
export declare function _resetAttempts(): void;
|
|
66
|
+
export declare function _trackedKeys(): number;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 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
|
|
4
|
+
* 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);
|
|
5
|
+
* the pattern is not redesigned.
|
|
6
|
+
*
|
|
7
|
+
* WHY IT EXISTS. The credential door (api-handlers/auth/credential-login) answers an enrolment-scoped token (access, NO refresh) with the enrolment
|
|
8
|
+
* 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
|
|
9
|
+
* (ENROL_COOKIE); the two routes built on this module read ONLY that cookie, and ONLY for these two operations. No other route reads it.
|
|
10
|
+
*
|
|
11
|
+
* 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
|
|
12
|
+
* 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
|
|
13
|
+
* 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).
|
|
14
|
+
*
|
|
15
|
+
* No framework imports: a test runs it with a stubbed fetch.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.MAX_TRACKED_KEYS = exports.LIMIT_PER_COOKIE = exports.ENROL_STEPS = exports.ENROL_COOKIE_MAX_AGE = exports.ENROL_COOKIE = void 0;
|
|
19
|
+
exports.enrolIdpPath = enrolIdpPath;
|
|
20
|
+
exports.enrolBodyFor = enrolBodyFor;
|
|
21
|
+
exports.parseEnrol = parseEnrol;
|
|
22
|
+
exports.parseConfirmed = parseConfirmed;
|
|
23
|
+
exports.parseCodes = parseCodes;
|
|
24
|
+
exports.codesForDisplay = codesForDisplay;
|
|
25
|
+
exports.enrolRefusal = enrolRefusal;
|
|
26
|
+
exports.runEnrolStep = runEnrolStep;
|
|
27
|
+
exports.attemptAllowed = attemptAllowed;
|
|
28
|
+
exports._resetAttempts = _resetAttempts;
|
|
29
|
+
exports._trackedKeys = _trackedKeys;
|
|
30
|
+
exports.ENROL_COOKIE = 'payez_enrol';
|
|
31
|
+
exports.ENROL_COOKIE_MAX_AGE = 600;
|
|
32
|
+
exports.ENROL_STEPS = ['enroll', 'confirm'];
|
|
33
|
+
const SENTENCE_UNAVAILABLE = 'We could not reach the sign-in service. Nothing was changed. Try again in a moment.';
|
|
34
|
+
function enrolIdpPath(step) {
|
|
35
|
+
return step === 'enroll' ? '/api/Account/mfa/totp/enroll' : '/api/Account/mfa/totp/confirm';
|
|
36
|
+
}
|
|
37
|
+
/** The body each step may carry: enroll { friendly_name? }, confirm { enrollment_id, code }. null = nothing is sent. */
|
|
38
|
+
function enrolBodyFor(step, raw) {
|
|
39
|
+
const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
40
|
+
if (step === 'enroll') {
|
|
41
|
+
const n = src.friendly_name;
|
|
42
|
+
return typeof n === 'string' && n.trim() !== '' && n.length <= 64 ? { friendly_name: n.trim() } : {};
|
|
43
|
+
}
|
|
44
|
+
const id = src.enrollment_id;
|
|
45
|
+
const code = src.code;
|
|
46
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0)
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code))
|
|
49
|
+
return null;
|
|
50
|
+
return { enrollment_id: id, code };
|
|
51
|
+
}
|
|
52
|
+
const unwrap = (b) => {
|
|
53
|
+
if (!b || typeof b !== 'object' || Array.isArray(b))
|
|
54
|
+
return null;
|
|
55
|
+
const o = b;
|
|
56
|
+
return o.data && typeof o.data === 'object' && !Array.isArray(o.data) ? o.data : o;
|
|
57
|
+
};
|
|
58
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
59
|
+
function parseEnrol(body) {
|
|
60
|
+
const d = unwrap(body);
|
|
61
|
+
const secret = str(d?.secret);
|
|
62
|
+
const uri = str(d?.otpauth_uri);
|
|
63
|
+
const id = d?.enrollment_id;
|
|
64
|
+
if (!d || !secret || !uri || !uri.startsWith('otpauth://totp/') || typeof id !== 'number' || !Number.isInteger(id))
|
|
65
|
+
return null;
|
|
66
|
+
return { secret, otpauthUri: uri, enrollmentId: id };
|
|
67
|
+
}
|
|
68
|
+
function parseConfirmed(body) {
|
|
69
|
+
return unwrap(body)?.confirmed === true;
|
|
70
|
+
}
|
|
71
|
+
function parseCodes(body) {
|
|
72
|
+
const d = unwrap(body);
|
|
73
|
+
const codes = d?.codes;
|
|
74
|
+
if (!Array.isArray(codes) || codes.length === 0 || !codes.every((c) => typeof c === 'string' && c !== ''))
|
|
75
|
+
return null;
|
|
76
|
+
return codes;
|
|
77
|
+
}
|
|
78
|
+
function codesForDisplay(codes) {
|
|
79
|
+
return codes.map((c) => c.trim()).filter((c) => c !== '');
|
|
80
|
+
}
|
|
81
|
+
/** 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. */
|
|
82
|
+
function enrolRefusal(step, status, body) {
|
|
83
|
+
if (status === 401 || status === 403)
|
|
84
|
+
return 'sign-in';
|
|
85
|
+
const c = (() => {
|
|
86
|
+
const b = body;
|
|
87
|
+
if (b && typeof b.code === 'string')
|
|
88
|
+
return b.code;
|
|
89
|
+
if (b && typeof b.error === 'object' && b.error && typeof b.error.code === 'string')
|
|
90
|
+
return b.error.code;
|
|
91
|
+
return null;
|
|
92
|
+
})();
|
|
93
|
+
if (status === 429)
|
|
94
|
+
return { status: 429, code: 'TOO_MANY', text: 'Too many attempts. Wait a few minutes and try again.' };
|
|
95
|
+
if (step === 'confirm' && status >= 400 && status < 500) {
|
|
96
|
+
if (c === 'enrollment_not_found')
|
|
97
|
+
return { status: 400, code: 'ENROLMENT_RESTART', text: 'This setup timed out. Reload the page to get a new code to scan.' };
|
|
98
|
+
return { status: 400, code: 'CODE_INVALID', text: 'That code did not work. Check the 6 digits in your authenticator app and try again.' };
|
|
99
|
+
}
|
|
100
|
+
return { status: 502, code: 'UNAVAILABLE', text: SENTENCE_UNAVAILABLE };
|
|
101
|
+
}
|
|
102
|
+
const headers = (token, clientKey) => ({
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
Accept: 'application/json',
|
|
105
|
+
Authorization: `Bearer ${token}`,
|
|
106
|
+
'X-Client-Id': clientKey,
|
|
107
|
+
});
|
|
108
|
+
const asJson = async (res) => res.json().catch(() => null);
|
|
109
|
+
async function runEnrolStep(deps, input) {
|
|
110
|
+
const body = enrolBodyFor(input.step, input.body);
|
|
111
|
+
if (body === null)
|
|
112
|
+
return { status: 400, body: { success: false, code: 'INVALID_REQUEST' }, cookie: 'keep' };
|
|
113
|
+
let res;
|
|
114
|
+
try {
|
|
115
|
+
res = await deps.fetchImpl(`${deps.idp}${enrolIdpPath(input.step)}`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: headers(input.token, deps.clientKey),
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
cache: 'no-store',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: SENTENCE_UNAVAILABLE }, cookie: 'keep' };
|
|
124
|
+
}
|
|
125
|
+
const answer = await asJson(res);
|
|
126
|
+
if (res.status < 200 || res.status >= 300) {
|
|
127
|
+
const refusal = enrolRefusal(input.step, res.status, answer);
|
|
128
|
+
if (refusal === 'sign-in')
|
|
129
|
+
return { status: 401, body: { success: false, code: 'SIGN_IN_AGAIN' }, cookie: 'clear' };
|
|
130
|
+
// A mistyped code must not cost a whole sign-in (the attempt limit bounds it); every other refusal ends this enrolment.
|
|
131
|
+
return { status: refusal.status, body: { success: false, code: refusal.code, error: refusal.text }, cookie: refusal.code === 'CODE_INVALID' ? 'keep' : 'clear' };
|
|
132
|
+
}
|
|
133
|
+
if (input.step === 'enroll') {
|
|
134
|
+
const e = parseEnrol(answer);
|
|
135
|
+
if (!e)
|
|
136
|
+
return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: 'We could not read the setup details. Nothing was changed. Reload the page.' }, cookie: 'clear' };
|
|
137
|
+
return { status: 200, body: { success: true, data: { secret: e.secret, otpauth_uri: e.otpauthUri, enrollment_id: e.enrollmentId } }, cookie: 'keep' };
|
|
138
|
+
}
|
|
139
|
+
// confirm: the factor is confirmed; the answer carries the FULL pair, used here ONLY to fetch the recovery codes, then dropped.
|
|
140
|
+
if (!parseConfirmed(answer))
|
|
141
|
+
return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: 'We could not confirm that. Try the code again.' }, cookie: 'keep' };
|
|
142
|
+
const d = unwrap(answer);
|
|
143
|
+
const full = str(d?.access_token);
|
|
144
|
+
let codes = null;
|
|
145
|
+
if (full) {
|
|
146
|
+
try {
|
|
147
|
+
const c = await deps.fetchImpl(`${deps.idp}/api/Account/mfa/backup-codes`, { method: 'POST', headers: headers(full, deps.clientKey), body: '{}', cache: 'no-store' });
|
|
148
|
+
const list = c.ok ? parseCodes(await asJson(c)) : null;
|
|
149
|
+
codes = list ? codesForDisplay(list) : null;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
codes = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { status: 200, body: { success: true, confirmed: true, codes }, cookie: 'clear' };
|
|
156
|
+
}
|
|
157
|
+
// ── attempt limit: per enrolment cookie (in-memory, per instance), hard-capped ──
|
|
158
|
+
const WINDOW_MS = exports.ENROL_COOKIE_MAX_AGE * 1000;
|
|
159
|
+
exports.LIMIT_PER_COOKIE = 5;
|
|
160
|
+
const hits = new Map();
|
|
161
|
+
/**
|
|
162
|
+
* HARD CAP (portal PAY-2913, BAPert 90048): the map never holds more than this many keys; past it the OLDEST-TOUCHED key is evicted.
|
|
163
|
+
* 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.
|
|
164
|
+
* 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).
|
|
165
|
+
*/
|
|
166
|
+
exports.MAX_TRACKED_KEYS = 5000;
|
|
167
|
+
/** True when this key may attempt now; records the attempt. Keys are never the raw cookie (the route passes a digest). */
|
|
168
|
+
function attemptAllowed(key, limit, now = Date.now()) {
|
|
169
|
+
const recent = (hits.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
|
|
170
|
+
const allowed = recent.length < limit;
|
|
171
|
+
if (allowed)
|
|
172
|
+
recent.push(now);
|
|
173
|
+
hits.delete(key); // re-insert at the end: Map order is last-touched order
|
|
174
|
+
hits.set(key, recent);
|
|
175
|
+
while (hits.size > exports.MAX_TRACKED_KEYS) {
|
|
176
|
+
const oldest = hits.keys().next().value;
|
|
177
|
+
if (oldest === undefined)
|
|
178
|
+
break;
|
|
179
|
+
hits.delete(oldest);
|
|
180
|
+
}
|
|
181
|
+
return allowed;
|
|
182
|
+
}
|
|
183
|
+
function _resetAttempts() {
|
|
184
|
+
hits.clear();
|
|
185
|
+
}
|
|
186
|
+
function _trackedKeys() {
|
|
187
|
+
return hits.size;
|
|
188
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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
|
|
3
|
+
* 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
|
|
4
|
+
* ONLY the httpOnly enrolment cookie the credential door parked, and answer 401 without it (the panel then sends the person to sign-in).
|
|
5
|
+
* Not the signed-in enrolment page (/account-auth/enroll), which needs a session.
|
|
6
|
+
*/
|
|
7
|
+
export default function FirstEnrollPage(): import("react/jsx-runtime").JSX.Element;
|