@payez/next-mvp 4.13.1 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/api-handlers/auth/credential-login.js +46 -0
  3. package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
  4. package/dist/api-handlers/mfa/first-enrol.js +58 -0
  5. package/dist/api-handlers/mfa/totp-confirm.d.ts +1 -1
  6. package/dist/api-handlers/mfa/totp-confirm.js +10 -6
  7. package/dist/api-handlers/test/force-expire.d.ts +4 -5
  8. package/dist/api-handlers/test/force-expire.js +14 -63
  9. package/dist/auth/better-auth.d.ts +16 -0
  10. package/dist/auth/better-auth.js +34 -0
  11. package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
  12. package/dist/components/auth/CredentialSignInForm.js +15 -1
  13. package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
  14. package/dist/components/mfa/FirstEnrolPanel.js +115 -0
  15. package/dist/lib/credential-sign-in-client.d.ts +4 -0
  16. package/dist/lib/credential-sign-in-client.js +13 -1
  17. package/dist/lib/first-enrol.d.ts +66 -0
  18. package/dist/lib/first-enrol.js +188 -0
  19. package/dist/lib/idp-client-config.js +1 -1
  20. package/dist/lib/log-safe.d.ts +6 -0
  21. package/dist/lib/log-safe.js +9 -0
  22. package/dist/lib/payez-client-secret.js +2 -1
  23. package/dist/lib/token-pages-switch.d.ts +23 -0
  24. package/dist/lib/token-pages-switch.js +48 -0
  25. package/dist/pages/first-enroll/page.d.ts +7 -0
  26. package/dist/pages/first-enroll/page.js +15 -0
  27. package/dist/pages/test-env/JwtInspectPage.d.ts +16 -14
  28. package/dist/pages/test-env/JwtInspectPage.js +16 -14
  29. package/dist/pages/test-env/RefreshTokenPage.d.ts +7 -10
  30. package/dist/pages/test-env/RefreshTokenPage.js +7 -10
  31. package/dist/pages/test-env/guarded.js +21 -57
  32. package/dist/pages/test-env/index.d.ts +15 -24
  33. package/dist/pages/test-env/index.js +15 -26
  34. package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
  35. package/dist/routes/mfa/first-enrol-confirm.js +14 -0
  36. package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
  37. package/dist/routes/mfa/first-enrol-enroll.js +14 -0
  38. package/package.json +26 -1
  39. package/src/api-handlers/auth/credential-login.ts +49 -0
  40. package/src/api-handlers/mfa/first-enrol.ts +55 -0
  41. package/src/api-handlers/mfa/totp-confirm.ts +13 -5
  42. package/src/api-handlers/test/force-expire.ts +13 -70
  43. package/src/auth/better-auth.ts +44 -2
  44. package/src/components/auth/CredentialSignInForm.tsx +18 -1
  45. package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
  46. package/src/lib/credential-sign-in-client.ts +13 -1
  47. package/src/lib/first-enrol.ts +180 -0
  48. package/src/lib/idp-client-config.ts +2 -2
  49. package/src/lib/log-safe.ts +9 -0
  50. package/src/lib/payez-client-secret.ts +2 -1
  51. package/src/lib/token-pages-switch.ts +44 -0
  52. package/src/pages/first-enroll/page.tsx +19 -0
  53. package/src/pages/test-env/JwtInspectPage.tsx +16 -14
  54. package/src/pages/test-env/RefreshTokenPage.tsx +7 -10
  55. package/src/pages/test-env/guarded.tsx +21 -55
  56. package/src/pages/test-env/index.ts +15 -26
  57. package/src/routes/mfa/first-enrol-confirm.ts +10 -0
  58. package/src/routes/mfa/first-enrol-enroll.ts +10 -0
@@ -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 };
@@ -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;
@@ -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 TOKENS. It offers
7
- * COPY-TO-CLIPBOARD buttons for the live SESSION TOKEN and ACCESS TOKEN, and
8
- * reads them from the consuming app's `/api/internal/session-data` route.
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
- * THIS PAGE HAS NO ENV GATE OF ITS OWN. Unlike the force-expire handler
12
- * (default-denied behind ENABLE_TEST_ROUTES) or the idp-token route (behind
13
- * ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as the
14
- * consuming app's own middleware allows. MOUNTING IT IS THE GATE. There is no
15
- * second line of defence behind it.
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
- * AND THE CREDENTIAL IT HANDS OVER IS THE PRECONDITION FOR A REAL ATTACK ON
18
- * THIS SAME PACKAGE. The 246007 finding reduces to "a caller who KNOWS A
19
- * SESSION TOKEN can act on a session it does not own". The purpose of this
20
- * page is to put a live session token on the clipboard of whoever loads it. A
21
- * screen-share, a shoulder-surfed screenshot or a support call is enough.
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 TOKENS. It offers
43
- * COPY-TO-CLIPBOARD buttons for the live SESSION TOKEN and ACCESS TOKEN, and
44
- * reads them from the consuming app's `/api/internal/session-data` route.
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
- * THIS PAGE HAS NO ENV GATE OF ITS OWN. Unlike the force-expire handler
48
- * (default-denied behind ENABLE_TEST_ROUTES) or the idp-token route (behind
49
- * ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as the
50
- * consuming app's own middleware allows. MOUNTING IT IS THE GATE. There is no
51
- * second line of defence behind it.
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
- * AND THE CREDENTIAL IT HANDS OVER IS THE PRECONDITION FOR A REAL ATTACK ON
54
- * THIS SAME PACKAGE. The 246007 finding reduces to "a caller who KNOWS A
55
- * SESSION TOKEN can act on a session it does not own". The purpose of this
56
- * page is to put a live session token on the clipboard of whoever loads it. A
57
- * screen-share, a shoulder-surfed screenshot or a support call is enough.
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: default-deny behind
11
- * ENABLE_TEST_ROUTES) and its docstring corrected to say so — but that fix
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. Mounting it is the gate. The two are
17
- * independent: mounting this page while ENABLE_TEST_ROUTES is unset gives you
18
- * a button that 404s — confusing, but safe. The unsafe configuration is
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
- * Requires ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY for the button to
27
- * do anything. Never set it in production. If you do not need to exercise the
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: default-deny behind
21
- * ENABLE_TEST_ROUTES) and its docstring corrected to say so — but that fix
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. Mounting it is the gate. The two are
27
- * independent: mounting this page while ENABLE_TEST_ROUTES is unset gives you
28
- * a button that 404s — confusing, but safe. The unsafe configuration is
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
- * Requires ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY for the button to
37
- * do anything. Never set it in production. If you do not need to exercise the
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();