@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.
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/auth/first-enrol/confirm/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
8
+ * ```
9
+ */
10
+ export { POST_CONFIRM as POST } from '../../api-handlers/mfa/first-enrol';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/auth/first-enrol/enroll/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
8
+ * ```
9
+ */
10
+ export { POST_ENROLL as POST } from '../../api-handlers/mfa/first-enrol';