@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.
- package/CHANGELOG.md +33 -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/api-handlers/mfa/totp-confirm.d.ts +1 -1
- package/dist/api-handlers/mfa/totp-confirm.js +10 -6
- package/dist/api-handlers/test/force-expire.d.ts +4 -5
- package/dist/api-handlers/test/force-expire.js +14 -63
- package/dist/auth/better-auth.d.ts +16 -0
- package/dist/auth/better-auth.js +34 -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/lib/idp-client-config.js +1 -1
- package/dist/lib/log-safe.d.ts +6 -0
- package/dist/lib/log-safe.js +9 -0
- package/dist/lib/payez-client-secret.js +2 -1
- package/dist/lib/token-pages-switch.d.ts +23 -0
- package/dist/lib/token-pages-switch.js +48 -0
- package/dist/pages/first-enroll/page.d.ts +7 -0
- package/dist/pages/first-enroll/page.js +15 -0
- package/dist/pages/test-env/JwtInspectPage.d.ts +16 -14
- package/dist/pages/test-env/JwtInspectPage.js +16 -14
- package/dist/pages/test-env/RefreshTokenPage.d.ts +7 -10
- package/dist/pages/test-env/RefreshTokenPage.js +7 -10
- package/dist/pages/test-env/guarded.js +21 -57
- package/dist/pages/test-env/index.d.ts +15 -24
- package/dist/pages/test-env/index.js +15 -26
- 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 +26 -1
- package/src/api-handlers/auth/credential-login.ts +49 -0
- package/src/api-handlers/mfa/first-enrol.ts +55 -0
- package/src/api-handlers/mfa/totp-confirm.ts +13 -5
- package/src/api-handlers/test/force-expire.ts +13 -70
- package/src/auth/better-auth.ts +44 -2
- 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/lib/idp-client-config.ts +2 -2
- package/src/lib/log-safe.ts +9 -0
- package/src/lib/payez-client-secret.ts +2 -1
- package/src/lib/token-pages-switch.ts +44 -0
- package/src/pages/first-enroll/page.tsx +19 -0
- package/src/pages/test-env/JwtInspectPage.tsx +16 -14
- package/src/pages/test-env/RefreshTokenPage.tsx +7 -10
- package/src/pages/test-env/guarded.tsx +21 -55
- package/src/pages/test-env/index.ts +15 -26
- package/src/routes/mfa/first-enrol-confirm.ts +10 -0
- package/src/routes/mfa/first-enrol-enroll.ts +10 -0
|
@@ -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
|
+
}
|
|
@@ -162,7 +162,7 @@ function applyEnvSecrets(config) {
|
|
|
162
162
|
const envVar = providerSecretEnvVar(p.provider);
|
|
163
163
|
const fromEnv = process.env[envVar];
|
|
164
164
|
const source = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
|
|
165
|
-
logSecretSource(`${p.provider} clientSecret`, source);
|
|
165
|
+
logSecretSource(`${(0, log_safe_1.logSafeName)(p.provider)} clientSecret`, source);
|
|
166
166
|
const clientSecret = fromEnv || p.clientSecret;
|
|
167
167
|
(0, never_in_response_1.markNeverInResponse)(clientSecret);
|
|
168
168
|
return { ...p, clientSecret };
|
package/dist/lib/log-safe.d.ts
CHANGED
|
@@ -17,3 +17,9 @@ export declare function closedCodeForLog(code: unknown): string | null;
|
|
|
17
17
|
* `NonError`; a status outside 100-599 is dropped; a code outside the closed token shape is dropped.
|
|
18
18
|
*/
|
|
19
19
|
export declare function errForLog(err: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* A provider name (or any other IdP-supplied identifier) for a log line: lower-cased, then anything outside [a-z0-9_-] becomes '?',
|
|
22
|
+
* so a name carrying a newline cannot forge a second log line (Nextpert-Scout 102543 LOW 1; the shared helper per 104626, used at
|
|
23
|
+
* every site that logs an IdP provider name: better-auth.ts, payez-client-secret.ts, idp-client-config.ts).
|
|
24
|
+
*/
|
|
25
|
+
export declare function logSafeName(name: unknown): string;
|
package/dist/lib/log-safe.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.closedCodeForLog = closedCodeForLog;
|
|
15
15
|
exports.errForLog = errForLog;
|
|
16
|
+
exports.logSafeName = logSafeName;
|
|
16
17
|
/** An upstream `code` is loggable only if it is a closed token (UPPER_SNAKE). Anything else, including free text, becomes null. */
|
|
17
18
|
function closedCodeForLog(code) {
|
|
18
19
|
return typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code) ? code : null;
|
|
@@ -30,3 +31,11 @@ function errForLog(err) {
|
|
|
30
31
|
const code = closed ? ` code=${closed}` : '';
|
|
31
32
|
return `error=${name}${status}${code}`;
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* A provider name (or any other IdP-supplied identifier) for a log line: lower-cased, then anything outside [a-z0-9_-] becomes '?',
|
|
36
|
+
* so a name carrying a newline cannot forge a second log line (Nextpert-Scout 102543 LOW 1; the shared helper per 104626, used at
|
|
37
|
+
* every site that logs an IdP provider name: better-auth.ts, payez-client-secret.ts, idp-client-config.ts).
|
|
38
|
+
*/
|
|
39
|
+
function logSafeName(name) {
|
|
40
|
+
return String(name ?? '').toLowerCase().replace(/[^a-z0-9_-]/g, '?');
|
|
41
|
+
}
|
|
@@ -10,6 +10,7 @@ exports.warnIfProviderSecretRedacted = warnIfProviderSecretRedacted;
|
|
|
10
10
|
* what it sent before. The value is never logged and never leaves in a response (markNeverInResponse).
|
|
11
11
|
*/
|
|
12
12
|
const never_in_response_1 = require("./never-in-response");
|
|
13
|
+
const log_safe_1 = require("./log-safe");
|
|
13
14
|
exports.CLIENT_SECRET_ENV = 'PAYEZ_CLIENT_SECRET';
|
|
14
15
|
/** `{ client_secret }` when the env var is set to a non-blank value, else `{}`. Spread it into a JSON body. */
|
|
15
16
|
function clientSecretBody() {
|
|
@@ -36,6 +37,6 @@ function warnIfProviderSecretRedacted(redactedHeader, providers, envVarFor) {
|
|
|
36
37
|
if (warnedProviders.has(p.provider))
|
|
37
38
|
continue;
|
|
38
39
|
warnedProviders.add(p.provider);
|
|
39
|
-
console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${p.provider}`);
|
|
40
|
+
console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${(0, log_safe_1.logSafeName)(p.provider)}`);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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 declare const TOKEN_PAGES_OFF_ENV = "PAYEZ_TOKEN_PAGES_OFF";
|
|
23
|
+
export declare function tokenPagesOff(): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TOKEN_PAGES_OFF_ENV = void 0;
|
|
4
|
+
exports.tokenPagesOff = tokenPagesOff;
|
|
5
|
+
/**
|
|
6
|
+
* PAY-3220 (ruled Win 105653 s1, on NextPert-MB's Mac 105619): the ONE production switch for the two token-showing
|
|
7
|
+
* test-env pages (JwtInspectPage, RefreshTokenPage) and the route the refresh page drives (POST /api/test/force-expire).
|
|
8
|
+
*
|
|
9
|
+
* PAYEZ_TOKEN_PAGES_OFF unset or empty -> ON (every page works as today; the default everywhere)
|
|
10
|
+
* true / 1 / yes / on -> OFF (any letter case)
|
|
11
|
+
* false / 0 / no / off -> ON (any letter case), no warning
|
|
12
|
+
* anything else -> OFF, and ONE warning per process naming the variable
|
|
13
|
+
*
|
|
14
|
+
* Why an unrecognised value is OFF (the ruling's one change to the recommendation): nobody sets this variable except to
|
|
15
|
+
* turn the pages off, on Jon's word, on a production host. A mistyped attempt to turn them off must not leave
|
|
16
|
+
* token-showing pages on in production with only a log line to say so.
|
|
17
|
+
*
|
|
18
|
+
* Read from the server's process environment on every call, never captured at module scope (a module constant would be
|
|
19
|
+
* whatever the build host had). It is not NEXT_PUBLIC_, so it never reaches a client bundle, and no request input
|
|
20
|
+
* (cookie, header, query string, body, session) is consulted: only whoever can change the host's environment can turn it.
|
|
21
|
+
*
|
|
22
|
+
* 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.
|
|
23
|
+
* The switch is not counted as set until the production roll's served check reads both pages and force-expire back as
|
|
24
|
+
* 404 by request (BAPert-MB 105629 s3).
|
|
25
|
+
*/
|
|
26
|
+
exports.TOKEN_PAGES_OFF_ENV = 'PAYEZ_TOKEN_PAGES_OFF';
|
|
27
|
+
const OFF_WORDS = new Set(['true', '1', 'yes', 'on']);
|
|
28
|
+
const ON_WORDS = new Set(['false', '0', 'no', 'off']);
|
|
29
|
+
let warnedUnrecognised = false;
|
|
30
|
+
function tokenPagesOff() {
|
|
31
|
+
const raw = process.env[exports.TOKEN_PAGES_OFF_ENV];
|
|
32
|
+
if (raw === undefined)
|
|
33
|
+
return false;
|
|
34
|
+
const value = raw.trim().toLowerCase();
|
|
35
|
+
if (value === '')
|
|
36
|
+
return false;
|
|
37
|
+
if (OFF_WORDS.has(value))
|
|
38
|
+
return true;
|
|
39
|
+
if (ON_WORDS.has(value))
|
|
40
|
+
return false;
|
|
41
|
+
if (!warnedUnrecognised) {
|
|
42
|
+
warnedUnrecognised = true;
|
|
43
|
+
// The variable's NAME only. The value is not a secret, but nothing about it helps the reader of this line.
|
|
44
|
+
console.warn(`[test-env] ${exports.TOKEN_PAGES_OFF_ENV} is set to a value that is not recognised (true/1/yes/on or false/0/no/off); ` +
|
|
45
|
+
'the token pages are OFF: jwt-inspect, refresh-token and POST /api/test/force-expire answer 404.');
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.default = FirstEnrollPage;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const FirstEnrolPanel_1 = require("../../components/mfa/FirstEnrolPanel");
|
|
7
|
+
/**
|
|
8
|
+
* 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
|
|
9
|
+
* 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
|
|
10
|
+
* ONLY the httpOnly enrolment cookie the credential door parked, and answer 401 without it (the panel then sends the person to sign-in).
|
|
11
|
+
* Not the signed-in enrolment page (/account-auth/enroll), which needs a session.
|
|
12
|
+
*/
|
|
13
|
+
function FirstEnrollPage() {
|
|
14
|
+
return ((0, jsx_runtime_1.jsxs)("main", { style: { maxWidth: 480, margin: '48px auto', padding: '0 16px' }, children: [(0, jsx_runtime_1.jsx)("h1", { children: "Secure your account" }), (0, jsx_runtime_1.jsx)(FirstEnrolPanel_1.FirstEnrolPanel, {})] }));
|
|
15
|
+
}
|
|
@@ -3,23 +3,25 @@
|
|
|
3
3
|
* IN PRODUCTION. ⚠️
|
|
4
4
|
*
|
|
5
5
|
* Session Inspector Page. Debug page for inspecting session data from Redis:
|
|
6
|
-
* shows user info, roles, 2FA status, and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* shows user info, roles, 2FA status, and the DECODED CLAIMS of the IDP access
|
|
7
|
+
* token, which it reads from the consuming app's `/api/internal/session-data`
|
|
8
|
+
* route. Credentials themselves are shown as tokenLogId + length ONLY: the
|
|
9
|
+
* Copy-to-clipboard and Reveal controls this docstring used to describe were
|
|
10
|
+
* removed by PAY-1470 (BAPert 59022, 59040), and test-env-no-token-material
|
|
11
|
+
* holds the page to that. (PAY-3220: this sentence was stale until now.)
|
|
9
12
|
*
|
|
10
13
|
* ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000 / 248991) ──────────────────────────
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Like the force-expire handler (no flag since PAY-3216) and unlike the
|
|
15
|
+
* idp-token route (behind ENABLE_TOKEN_INSPECTOR), a mounted page is reachable
|
|
16
|
+
* exactly as far as the consuming app's own middleware allows. MOUNTING IT IS
|
|
17
|
+
* THE GATE. The one switch on it is PAYEZ_TOKEN_PAGES_OFF (PAY-3220), unset by
|
|
18
|
+
* default and set only on a production host on Jon's word.
|
|
16
19
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Treat the rendered value as a live credential, because that is what it is.
|
|
20
|
+
* WHAT IT STILL HANDS OVER IS SENSITIVE. The decoded claims say who the
|
|
21
|
+
* signed-in person is, which roles they hold and how they authenticated, and
|
|
22
|
+
* the 246007 finding reduces to "a caller who KNOWS A SESSION TOKEN can act on
|
|
23
|
+
* a session it does not own": a page that once showed that token is the reason
|
|
24
|
+
* this one is treated as a production hazard.
|
|
23
25
|
*
|
|
24
26
|
* NOTE FOR MAINTAINERS: this warning must stay on THIS docstring, the one
|
|
25
27
|
* attached to the exported declaration. An identical block placed higher in
|
|
@@ -39,23 +39,25 @@ function decodeJwtPayload(token) {
|
|
|
39
39
|
* IN PRODUCTION. ⚠️
|
|
40
40
|
*
|
|
41
41
|
* Session Inspector Page. Debug page for inspecting session data from Redis:
|
|
42
|
-
* shows user info, roles, 2FA status, and
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* shows user info, roles, 2FA status, and the DECODED CLAIMS of the IDP access
|
|
43
|
+
* token, which it reads from the consuming app's `/api/internal/session-data`
|
|
44
|
+
* route. Credentials themselves are shown as tokenLogId + length ONLY: the
|
|
45
|
+
* Copy-to-clipboard and Reveal controls this docstring used to describe were
|
|
46
|
+
* removed by PAY-1470 (BAPert 59022, 59040), and test-env-no-token-material
|
|
47
|
+
* holds the page to that. (PAY-3220: this sentence was stale until now.)
|
|
45
48
|
*
|
|
46
49
|
* ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000 / 248991) ──────────────────────────
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Like the force-expire handler (no flag since PAY-3216) and unlike the
|
|
51
|
+
* idp-token route (behind ENABLE_TOKEN_INSPECTOR), a mounted page is reachable
|
|
52
|
+
* exactly as far as the consuming app's own middleware allows. MOUNTING IT IS
|
|
53
|
+
* THE GATE. The one switch on it is PAYEZ_TOKEN_PAGES_OFF (PAY-3220), unset by
|
|
54
|
+
* default and set only on a production host on Jon's word.
|
|
52
55
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* Treat the rendered value as a live credential, because that is what it is.
|
|
56
|
+
* WHAT IT STILL HANDS OVER IS SENSITIVE. The decoded claims say who the
|
|
57
|
+
* signed-in person is, which roles they hold and how they authenticated, and
|
|
58
|
+
* the 246007 finding reduces to "a caller who KNOWS A SESSION TOKEN can act on
|
|
59
|
+
* a session it does not own": a page that once showed that token is the reason
|
|
60
|
+
* this one is treated as a production hazard.
|
|
59
61
|
*
|
|
60
62
|
* NOTE FOR MAINTAINERS: this warning must stay on THIS docstring, the one
|
|
61
63
|
* attached to the exported declaration. An identical block placed higher in
|
|
@@ -7,25 +7,22 @@
|
|
|
7
7
|
* `/api/test/force-expire`. That endpoint MUTATES SESSION STATE.
|
|
8
8
|
*
|
|
9
9
|
* ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000) ──────────────────────────
|
|
10
|
-
* The force-expire HANDLER was fixed (244877
|
|
11
|
-
*
|
|
12
|
-
* covered the ROUTE AND NOT THE PAGE THAT DRIVES IT. This page is a separate
|
|
10
|
+
* The force-expire HANDLER was fixed (244877) and its docstring corrected to
|
|
11
|
+
* say so — but that fix covered the ROUTE AND NOT THE PAGE THAT DRIVES IT. This page is a separate
|
|
13
12
|
* public export carrying its own mount instructions, so a consumer can follow
|
|
14
13
|
* these lines and never once read the handler's warning.
|
|
15
14
|
*
|
|
16
|
-
* THIS PAGE HAS NO ENV GATE OF ITS OWN
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* setting that flag anywhere this page is also reachable.
|
|
15
|
+
* THIS PAGE HAS NO ENV GATE OF ITS OWN, and since PAY-3216 neither has the
|
|
16
|
+
* handler it drives: mounting is the gate. Wherever this page and the
|
|
17
|
+
* force-expire route are both mounted, the button works.
|
|
20
18
|
*
|
|
21
19
|
* ────────────────────────── IF YOU MOUNT IT, MOUNT IT LIKE THIS ──────────────────────────
|
|
22
20
|
* ```typescript
|
|
23
21
|
* // app/test-env/refresh-token/page.tsx — TEST/DEV ENVIRONMENTS ONLY
|
|
24
22
|
* export { RefreshTokenPage as default } from '@payez/next-mvp/pages/test-env';
|
|
25
23
|
* ```
|
|
26
|
-
*
|
|
27
|
-
* do
|
|
28
|
-
* refresh flow, DO NOT MOUNT THIS AT ALL.
|
|
24
|
+
* The button needs /api/test/force-expire mounted too; there is no flag. If
|
|
25
|
+
* you do not need to exercise the refresh flow, DO NOT MOUNT THIS AT ALL.
|
|
29
26
|
*/
|
|
30
27
|
export declare function RefreshTokenPage(): import("react/jsx-runtime").JSX.Element;
|
|
31
28
|
export default RefreshTokenPage;
|
|
@@ -17,25 +17,22 @@ const redact_credentials_1 = require("./redact-credentials");
|
|
|
17
17
|
* `/api/test/force-expire`. That endpoint MUTATES SESSION STATE.
|
|
18
18
|
*
|
|
19
19
|
* ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000) ──────────────────────────
|
|
20
|
-
* The force-expire HANDLER was fixed (244877
|
|
21
|
-
*
|
|
22
|
-
* covered the ROUTE AND NOT THE PAGE THAT DRIVES IT. This page is a separate
|
|
20
|
+
* The force-expire HANDLER was fixed (244877) and its docstring corrected to
|
|
21
|
+
* say so — but that fix covered the ROUTE AND NOT THE PAGE THAT DRIVES IT. This page is a separate
|
|
23
22
|
* public export carrying its own mount instructions, so a consumer can follow
|
|
24
23
|
* these lines and never once read the handler's warning.
|
|
25
24
|
*
|
|
26
|
-
* THIS PAGE HAS NO ENV GATE OF ITS OWN
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* setting that flag anywhere this page is also reachable.
|
|
25
|
+
* THIS PAGE HAS NO ENV GATE OF ITS OWN, and since PAY-3216 neither has the
|
|
26
|
+
* handler it drives: mounting is the gate. Wherever this page and the
|
|
27
|
+
* force-expire route are both mounted, the button works.
|
|
30
28
|
*
|
|
31
29
|
* ────────────────────────── IF YOU MOUNT IT, MOUNT IT LIKE THIS ──────────────────────────
|
|
32
30
|
* ```typescript
|
|
33
31
|
* // app/test-env/refresh-token/page.tsx — TEST/DEV ENVIRONMENTS ONLY
|
|
34
32
|
* export { RefreshTokenPage as default } from '@payez/next-mvp/pages/test-env';
|
|
35
33
|
* ```
|
|
36
|
-
*
|
|
37
|
-
* do
|
|
38
|
-
* refresh flow, DO NOT MOUNT THIS AT ALL.
|
|
34
|
+
* The button needs /api/test/force-expire mounted too; there is no flag. If
|
|
35
|
+
* you do not need to exercise the refresh flow, DO NOT MOUNT THIS AT ALL.
|
|
39
36
|
*/
|
|
40
37
|
function RefreshTokenPage() {
|
|
41
38
|
const { data: session } = better_auth_client_1.authClient.useSession();
|