@payez/next-mvp 4.12.1 → 4.13.1
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 +57 -0
- package/dist/api-handlers/account/profile.d.ts +2 -39
- package/dist/api-handlers/account/profile.js +5 -2
- package/dist/api-handlers/anon/preferences.d.ts +1 -14
- package/dist/api-handlers/anon/preferences.js +5 -5
- package/dist/api-handlers/auth/credential-login.js +28 -4
- package/dist/api-handlers/health.d.ts +5 -0
- package/dist/api-handlers/health.js +48 -0
- package/dist/api-handlers/mfa/totp-confirm.js +8 -4
- package/dist/api-handlers/mfa/totp-enroll.js +8 -4
- package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
- package/dist/api-handlers/session/refresh-viability.js +5 -2
- package/dist/api-handlers/session/viability.d.ts +3 -8
- package/dist/api-handlers/session/viability.js +11 -4
- package/dist/auth/better-auth-exposure.d.ts +11 -0
- package/dist/auth/better-auth-exposure.js +73 -0
- package/dist/auth/better-auth.d.ts +6 -0
- package/dist/auth/better-auth.js +42 -3
- package/dist/auth/route-config.js +9 -0
- package/dist/auth/session-reset-link.d.ts +32 -0
- package/dist/auth/session-reset-link.js +45 -1
- package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
- package/dist/components/auth/CredentialSignInForm.js +13 -4
- package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
- package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
- package/dist/components/mfa/QrCode.d.ts +6 -0
- package/dist/components/mfa/QrCode.js +14 -0
- package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
- package/dist/components/mfa/TotpEnrollPanel.js +117 -0
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/credential-sign-in-client.d.ts +26 -1
- package/dist/lib/credential-sign-in-client.js +32 -6
- package/dist/lib/idp-client-config.js +4 -1
- package/dist/lib/login-error-message.d.ts +3 -0
- package/dist/lib/login-error-message.js +12 -1
- package/dist/lib/mfa-json-guard.d.ts +12 -0
- package/dist/lib/mfa-json-guard.js +19 -0
- package/dist/lib/nextauth-secret.js +3 -1
- package/dist/lib/no-store.d.ts +13 -0
- package/dist/lib/no-store.js +35 -0
- package/dist/lib/payez-client-secret.d.ts +14 -0
- package/dist/lib/payez-client-secret.js +41 -0
- package/dist/lib/qrcode.d.ts +22 -0
- package/dist/lib/qrcode.js +362 -0
- package/dist/lib/sign-in-failure.d.ts +35 -0
- package/dist/lib/sign-in-failure.js +85 -0
- package/dist/lib/twofa-rejection.d.ts +2 -0
- package/dist/lib/twofa-rejection.js +5 -2
- package/dist/middleware/create-middleware.d.ts +24 -0
- package/dist/middleware/create-middleware.js +56 -9
- package/dist/middleware/prefetch-guard-status.d.ts +21 -0
- package/dist/middleware/prefetch-guard-status.js +42 -0
- package/dist/pages/enroll/page.d.ts +7 -0
- package/dist/pages/enroll/page.js +15 -0
- package/dist/pages/login/page.js +1 -1
- package/dist/routes/auth/session-reset.d.ts +11 -1
- package/dist/routes/auth/session-reset.js +38 -13
- package/dist/routes/auth/session.d.ts +2 -9
- package/dist/routes/auth/session.js +5 -2
- package/dist/routes/auth/viability.d.ts +3 -54
- package/dist/routes/auth/viability.js +16 -4
- package/dist/routes/health.d.ts +1 -0
- package/dist/routes/health.js +16 -0
- package/dist/routes/mfa/methods.d.ts +2 -2
- package/dist/routes/mfa/methods.js +3 -1
- package/dist/routes/passkeys/list.d.ts +2 -10
- package/dist/routes/passkeys/list.js +4 -2
- package/dist/routes/session/refresh-viability.d.ts +2 -1
- package/dist/routes/session/refresh-viability.js +4 -2
- package/dist/utils/api.d.ts +33 -6
- package/dist/utils/api.js +35 -2
- package/package.json +21 -1
- package/src/api-handlers/account/profile.ts +5 -1
- package/src/api-handlers/anon/preferences.ts +5 -4
- package/src/api-handlers/auth/credential-login.ts +29 -4
- package/src/api-handlers/health.ts +53 -0
- package/src/api-handlers/mfa/totp-confirm.ts +7 -4
- package/src/api-handlers/mfa/totp-enroll.ts +7 -4
- package/src/api-handlers/session/refresh-viability.ts +5 -1
- package/src/api-handlers/session/viability.ts +12 -4
- package/src/auth/better-auth-exposure.ts +69 -0
- package/src/auth/better-auth.ts +42 -3
- package/src/auth/route-config.ts +9 -0
- package/src/auth/session-reset-link.ts +47 -0
- package/src/components/auth/CredentialSignInForm.tsx +21 -3
- package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
- package/src/components/mfa/QrCode.tsx +24 -0
- package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/credential-sign-in-client.ts +48 -8
- package/src/lib/idp-client-config.ts +5 -1
- package/src/lib/login-error-message.ts +13 -1
- package/src/lib/mfa-json-guard.ts +19 -0
- package/src/lib/nextauth-secret.ts +3 -1
- package/src/lib/no-store.ts +30 -0
- package/src/lib/payez-client-secret.ts +39 -0
- package/src/lib/qrcode.ts +348 -0
- package/src/lib/sign-in-failure.ts +88 -0
- package/src/lib/twofa-rejection.ts +7 -2
- package/src/middleware/create-middleware.ts +69 -9
- package/src/middleware/prefetch-guard-status.ts +56 -0
- package/src/pages/enroll/page.tsx +22 -0
- package/src/pages/login/page.tsx +2 -1
- package/src/routes/auth/session-reset.ts +46 -11
- package/src/routes/auth/session.ts +5 -1
- package/src/routes/auth/viability.ts +16 -4
- package/src/routes/health.ts +14 -0
- package/src/routes/mfa/methods.ts +3 -1
- package/src/routes/passkeys/list.ts +5 -1
- package/src/routes/session/refresh-viability.ts +5 -1
- package/src/utils/api.ts +50 -8
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* literally true (PAY-2284).
|
|
15
15
|
*/
|
|
16
16
|
import { NO_CODE_REQUEST, type CodeRequest } from './code-step-copy';
|
|
17
|
+
import { SIGN_IN_FAILURE_COPY } from './sign-in-failure';
|
|
17
18
|
export declare const CREDENTIAL_LOGIN_URL = "/api/auth/admin-login";
|
|
18
19
|
export declare const CREDENTIAL_EMAIL_CODE_URL = "/api/auth/admin-login/email-code";
|
|
19
20
|
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
@@ -23,15 +24,24 @@ export type CredentialSignInResult = {
|
|
|
23
24
|
kind: 'two_factor';
|
|
24
25
|
codeRequest: CodeRequest;
|
|
25
26
|
devCode: string | null;
|
|
27
|
+
nextStep: NextStep;
|
|
26
28
|
} | {
|
|
27
29
|
kind: 'rate_limited';
|
|
28
30
|
message: string;
|
|
31
|
+
code: ErrorCode;
|
|
29
32
|
} | {
|
|
30
33
|
kind: 'error';
|
|
31
34
|
message: string;
|
|
35
|
+
code: ErrorCode;
|
|
32
36
|
offerEmailCode: boolean;
|
|
33
37
|
};
|
|
34
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The stable machine code a rendered error carries (data-error-code). The three from the closed table in lib/sign-in-failure,
|
|
40
|
+
* plus 'policy_message' (an allowlisted SsoRequired / InvalidRequest refusal, whose detail the server may send) and
|
|
41
|
+
* 'sign_in_failed' (anything this client could not map: a 400/5xx, an unknown code, a thrown fetch).
|
|
42
|
+
*/
|
|
43
|
+
export type ErrorCode = keyof typeof SIGN_IN_FAILURE_COPY | 'policy_message' | 'sign_in_failed';
|
|
44
|
+
export declare const MSG_RATE_LIMITED: string;
|
|
35
45
|
export declare const MSG_CANNOT_SIGN_IN = "Could not sign in. Check your details and try again.";
|
|
36
46
|
export declare const MSG_UNEXPECTED = "Something went wrong signing you in. Please try again.";
|
|
37
47
|
export declare function isEmailShaped(v: string): boolean;
|
|
@@ -40,6 +50,21 @@ export declare function validateCredentials(email: string, password: string): {
|
|
|
40
50
|
email?: string;
|
|
41
51
|
password?: string;
|
|
42
52
|
} | null;
|
|
53
|
+
/**
|
|
54
|
+
* PAY-2630: what the IdP says is outstanding for this user ON THIS CLIENT. The door (api-handlers/auth/credential-login)
|
|
55
|
+
* already relays the IdP's next_step as `nextStep`; this form used to read ZERO of it. 'enroll_mfa' = the user has no usable
|
|
56
|
+
* second factor on this client, so a code prompt alone is a trap; 'mfa_verify' = a factor exists, the prompt is right.
|
|
57
|
+
* Anything else (absent, null, an unknown string, wrong case) is null = "not told", never a guess.
|
|
58
|
+
*/
|
|
59
|
+
export type NextStep = 'enroll_mfa' | 'mfa_verify' | null;
|
|
60
|
+
export declare function readNextStep(body: any): NextStep;
|
|
61
|
+
/**
|
|
62
|
+
* Where the browser goes after the code was accepted. A user the IdP told to enrol, who kept the offer ticked, goes to the
|
|
63
|
+
* enrolment page; everyone else goes where they were headed. The destination is never decided from anything but the IdP's
|
|
64
|
+
* answer and the user's own tick. With NO enrolHref (the host mounts no enrolment page) nobody is rerouted: there is no default, because
|
|
65
|
+
* a default that a host does not serve is a 404 after a successful sign-in (QAPert, PR 1687 read).
|
|
66
|
+
*/
|
|
67
|
+
export declare function postSignInDestination(destination: string, nextStep: NextStep, enrolAfter: boolean, enrolHref?: string): string;
|
|
43
68
|
/** One call to the credential door. `code` is the 2FA code on the second turn; email and password are resubmitted with it. */
|
|
44
69
|
export declare function credentialSignIn(args: {
|
|
45
70
|
email: string;
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.NO_CODE_REQUEST = exports.MSG_EMAIL_CODE_FAILED = exports.MSG_UNEXPECTED = exports.MSG_CANNOT_SIGN_IN = exports.MSG_RATE_LIMITED = exports.CREDENTIAL_EMAIL_CODE_URL = exports.CREDENTIAL_LOGIN_URL = void 0;
|
|
4
4
|
exports.isEmailShaped = isEmailShaped;
|
|
5
5
|
exports.validateCredentials = validateCredentials;
|
|
6
|
+
exports.readNextStep = readNextStep;
|
|
7
|
+
exports.postSignInDestination = postSignInDestination;
|
|
6
8
|
exports.credentialSignIn = credentialSignIn;
|
|
7
9
|
exports.requestEmailCode = requestEmailCode;
|
|
8
10
|
/**
|
|
@@ -22,9 +24,10 @@ exports.requestEmailCode = requestEmailCode;
|
|
|
22
24
|
*/
|
|
23
25
|
const code_step_copy_1 = require("./code-step-copy");
|
|
24
26
|
Object.defineProperty(exports, "NO_CODE_REQUEST", { enumerable: true, get: function () { return code_step_copy_1.NO_CODE_REQUEST; } });
|
|
27
|
+
const sign_in_failure_1 = require("./sign-in-failure");
|
|
25
28
|
exports.CREDENTIAL_LOGIN_URL = '/api/auth/admin-login';
|
|
26
29
|
exports.CREDENTIAL_EMAIL_CODE_URL = '/api/auth/admin-login/email-code';
|
|
27
|
-
exports.MSG_RATE_LIMITED =
|
|
30
|
+
exports.MSG_RATE_LIMITED = sign_in_failure_1.SIGN_IN_FAILURE_COPY.too_many_attempts;
|
|
28
31
|
exports.MSG_CANNOT_SIGN_IN = 'Could not sign in. Check your details and try again.';
|
|
29
32
|
exports.MSG_UNEXPECTED = 'Something went wrong signing you in. Please try again.';
|
|
30
33
|
const EMAIL_SHAPE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
@@ -43,6 +46,19 @@ function validateCredentials(email, password) {
|
|
|
43
46
|
errors.password = 'Enter your password.';
|
|
44
47
|
return errors.email || errors.password ? errors : null;
|
|
45
48
|
}
|
|
49
|
+
function readNextStep(body) {
|
|
50
|
+
const v = body?.nextStep;
|
|
51
|
+
return v === 'enroll_mfa' || v === 'mfa_verify' ? v : null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Where the browser goes after the code was accepted. A user the IdP told to enrol, who kept the offer ticked, goes to the
|
|
55
|
+
* enrolment page; everyone else goes where they were headed. The destination is never decided from anything but the IdP's
|
|
56
|
+
* answer and the user's own tick. With NO enrolHref (the host mounts no enrolment page) nobody is rerouted: there is no default, because
|
|
57
|
+
* a default that a host does not serve is a 404 after a successful sign-in (QAPert, PR 1687 read).
|
|
58
|
+
*/
|
|
59
|
+
function postSignInDestination(destination, nextStep, enrolAfter, enrolHref) {
|
|
60
|
+
return enrolHref && nextStep === 'enroll_mfa' && enrolAfter ? enrolHref : destination;
|
|
61
|
+
}
|
|
46
62
|
/** The dev echo: the door passes the IdP's DevVerificationCode through as `devVerificationCode`. Nothing else is ever a dev code. */
|
|
47
63
|
function readDevCode(body) {
|
|
48
64
|
return typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null;
|
|
@@ -59,17 +75,27 @@ async function credentialSignIn(args, fetchImpl = (input, init) => fetch(input,
|
|
|
59
75
|
body: JSON.stringify({ email: args.email, password: args.password, code: args.code }),
|
|
60
76
|
});
|
|
61
77
|
const body = await res.json().catch(() => null);
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
// The sentence comes from the closed table, never from the body (BAPert 85415 rule 4).
|
|
79
|
+
if (res.status === 429) {
|
|
80
|
+
const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
|
|
81
|
+
return { kind: 'rate_limited', message: (0, sign_in_failure_1.tooManyAttemptsMessage)(body?.retryAfter, code), code };
|
|
82
|
+
}
|
|
64
83
|
if (body?.requiresTwoFactor)
|
|
65
|
-
return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body), devCode: readDevCode(body) };
|
|
84
|
+
return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
|
|
66
85
|
if (!res.ok || body?.success !== true) {
|
|
67
|
-
|
|
86
|
+
const offerEmailCode = body?.offerEmailCode === true;
|
|
87
|
+
if ((0, sign_in_failure_1.isSignInFailureCode)(body?.code))
|
|
88
|
+
return { kind: 'error', message: (0, sign_in_failure_1.signInFailureMessage)(body.code), code: body.code, offerEmailCode };
|
|
89
|
+
// The one passthrough: an allowlisted policy refusal (SsoRequired / InvalidRequest), flagged by the server with this exact code.
|
|
90
|
+
const policy = body?.code === 'policy_message' ? (0, sign_in_failure_1.capPolicyDetail)(body?.error) : null;
|
|
91
|
+
if (policy)
|
|
92
|
+
return { kind: 'error', message: policy, code: 'policy_message', offerEmailCode };
|
|
93
|
+
return { kind: 'error', message: exports.MSG_CANNOT_SIGN_IN, code: 'sign_in_failed', offerEmailCode };
|
|
68
94
|
}
|
|
69
95
|
return { kind: 'success' };
|
|
70
96
|
}
|
|
71
97
|
catch {
|
|
72
|
-
return { kind: 'error', message: exports.MSG_UNEXPECTED, offerEmailCode: false };
|
|
98
|
+
return { kind: 'error', message: exports.MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
|
|
73
99
|
}
|
|
74
100
|
}
|
|
75
101
|
exports.MSG_EMAIL_CODE_FAILED = 'We could not ask for an email code just now. Try again shortly.';
|
|
@@ -82,6 +82,7 @@ exports.getEnabledProviders = getEnabledProviders;
|
|
|
82
82
|
require("server-only");
|
|
83
83
|
const crypto_1 = require("crypto");
|
|
84
84
|
const never_in_response_1 = require("./never-in-response");
|
|
85
|
+
const payez_client_secret_1 = require("./payez-client-secret");
|
|
85
86
|
const log_safe_1 = require("./log-safe");
|
|
86
87
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
87
88
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
@@ -691,6 +692,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
691
692
|
subject: clientIdStr,
|
|
692
693
|
audience: 'urn:payez:externalauth:clientconfig',
|
|
693
694
|
expires_in: 60,
|
|
695
|
+
...(0, payez_client_secret_1.clientSecretBody)(),
|
|
694
696
|
};
|
|
695
697
|
const signingResp = await fetch(signingUrl, {
|
|
696
698
|
method: 'POST',
|
|
@@ -731,7 +733,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
731
733
|
'X-Client-Id': clientIdStr,
|
|
732
734
|
'X-Correlation-Id': (0, crypto_1.randomUUID)().replace(/-/g, ''),
|
|
733
735
|
},
|
|
734
|
-
body: JSON.stringify({ client_assertion }),
|
|
736
|
+
body: JSON.stringify({ client_assertion, ...(0, payez_client_secret_1.clientSecretBody)() }),
|
|
735
737
|
cache: 'no-store'
|
|
736
738
|
});
|
|
737
739
|
if (!configResp.ok) {
|
|
@@ -839,6 +841,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
839
841
|
// silently substitutes a public constant for an empty secret.
|
|
840
842
|
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
841
843
|
}
|
|
844
|
+
(0, payez_client_secret_1.warnIfProviderSecretRedacted)(configResp.headers?.get?.('X-PayEz-Secrets-Redacted'), config.oauthProviders ?? [], providerSecretEnvVar);
|
|
842
845
|
// Success - reset failure tracking
|
|
843
846
|
consecutiveFailures = 0;
|
|
844
847
|
return config;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* text on the login page (content spoofing / phishing). Known codes get their sentence; anything else gets one generic
|
|
9
9
|
* sentence; nothing from the URL is ever shown verbatim.
|
|
10
10
|
*/
|
|
11
|
+
import { type SignInFailureCode } from './sign-in-failure';
|
|
11
12
|
export declare const GENERIC_LOGIN_ERROR = "We could not sign you in. Please try again.";
|
|
12
13
|
/**
|
|
13
14
|
* The sentence for `?error=<code>`, or null when nothing should be shown.
|
|
@@ -16,3 +17,5 @@ export declare const GENERIC_LOGIN_ERROR = "We could not sign you in. Please try
|
|
|
16
17
|
export declare function friendlyLoginError(code: string | null | undefined): string | null;
|
|
17
18
|
/** `?oauth_error=<provider>`: only a plain provider-like slug is echoed (capitalised); anything else is the generic sentence. */
|
|
18
19
|
export declare function oauthFailureMessage(provider: string | null | undefined): string | null;
|
|
20
|
+
/** The machine code (data-error-code) for a `?error=` code, or null when it has none in the closed table. Nothing from the URL is echoed. */
|
|
21
|
+
export declare function loginErrorCode(code: string | null | undefined): SignInFailureCode | null;
|
|
@@ -13,12 +13,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.GENERIC_LOGIN_ERROR = void 0;
|
|
14
14
|
exports.friendlyLoginError = friendlyLoginError;
|
|
15
15
|
exports.oauthFailureMessage = oauthFailureMessage;
|
|
16
|
+
exports.loginErrorCode = loginErrorCode;
|
|
17
|
+
const sign_in_failure_1 = require("./sign-in-failure");
|
|
16
18
|
exports.GENERIC_LOGIN_ERROR = 'We could not sign you in. Please try again.';
|
|
17
19
|
const KNOWN = {
|
|
18
20
|
TwoFactorTimeout: 'Your verification step timed out before the code was confirmed. Please sign in again.',
|
|
19
21
|
AUTHORIZE_RESUME_REFUSED: 'That sign-in request could not be continued. Please sign in again.',
|
|
22
|
+
// PAY-2279 / PAY-2005(b): the one IdP refusal code the abort path forwards (IDP_ERROR_CODES_TO_FORWARD). Wording is the card's proposal.
|
|
23
|
+
// (OAUTH_SIGN_IN_FAILED has no sentence in THIS change: PR 1606 (PAY-2554) owns that code's redirect and its sentence; once both land the map carries both.)
|
|
24
|
+
REGISTRATION_NOT_ALLOWED: 'Registration is not open for this site. Ask your administrator for an invitation.',
|
|
20
25
|
ServiceUnavailable: 'Authentication service is temporarily unavailable. Please try again in a few minutes.',
|
|
21
|
-
CredentialsSignin:
|
|
26
|
+
CredentialsSignin: sign_in_failure_1.SIGN_IN_FAILURE_COPY.invalid_credentials,
|
|
27
|
+
// PAY-2554: the neutral code the OAuth callback failure / state-error / aborted-exchange redirects carry (better-auth.ts OAUTH_SIGN_IN_FAILED_ERROR).
|
|
28
|
+
OAUTH_SIGN_IN_FAILED: "Sign-in didn't complete. Please try again, or use email.",
|
|
22
29
|
};
|
|
23
30
|
/**
|
|
24
31
|
* The sentence for `?error=<code>`, or null when nothing should be shown.
|
|
@@ -45,3 +52,7 @@ function oauthFailureMessage(provider) {
|
|
|
45
52
|
return exports.GENERIC_LOGIN_ERROR;
|
|
46
53
|
return `${provider.charAt(0).toUpperCase()}${provider.slice(1)} sign-in did not complete. Please try again.`;
|
|
47
54
|
}
|
|
55
|
+
/** The machine code (data-error-code) for a `?error=` code, or null when it has none in the closed table. Nothing from the URL is echoed. */
|
|
56
|
+
function loginErrorCode(code) {
|
|
57
|
+
return code === 'CredentialsSignin' ? 'invalid_credentials' : null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
/**
|
|
3
|
+
* PAY-2631: the TOTP enrol/confirm handlers forward the browser body verbatim, so a cross-site form post
|
|
4
|
+
* (Content-Type text/plain, application/x-www-form-urlencoded) could reach them. A browser cannot send
|
|
5
|
+
* application/json cross-site without a CORS preflight, so requiring it closes that path.
|
|
6
|
+
* The responses carry the shared secret and the otpauth URI, so they are never cached.
|
|
7
|
+
*/
|
|
8
|
+
export declare const NO_STORE: {
|
|
9
|
+
readonly 'Cache-Control': "no-store";
|
|
10
|
+
};
|
|
11
|
+
/** A 415 response when the request is not application/json, otherwise null. */
|
|
12
|
+
export declare function refuseNonJson(req: NextRequest): NextResponse | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_STORE = void 0;
|
|
4
|
+
exports.refuseNonJson = refuseNonJson;
|
|
5
|
+
const server_1 = require("next/server");
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2631: the TOTP enrol/confirm handlers forward the browser body verbatim, so a cross-site form post
|
|
8
|
+
* (Content-Type text/plain, application/x-www-form-urlencoded) could reach them. A browser cannot send
|
|
9
|
+
* application/json cross-site without a CORS preflight, so requiring it closes that path.
|
|
10
|
+
* The responses carry the shared secret and the otpauth URI, so they are never cached.
|
|
11
|
+
*/
|
|
12
|
+
exports.NO_STORE = { 'Cache-Control': 'no-store' };
|
|
13
|
+
/** A 415 response when the request is not application/json, otherwise null. */
|
|
14
|
+
function refuseNonJson(req) {
|
|
15
|
+
const ct = (req.headers.get('content-type') || '').toLowerCase();
|
|
16
|
+
if (ct.split(';')[0].trim() === 'application/json')
|
|
17
|
+
return null;
|
|
18
|
+
return server_1.NextResponse.json({ success: false, message: 'Content-Type must be application/json', error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }, { status: 415, headers: exports.NO_STORE });
|
|
19
|
+
}
|
|
@@ -45,6 +45,7 @@ exports.resolveNextAuthSecret = resolveNextAuthSecret;
|
|
|
45
45
|
require("server-only");
|
|
46
46
|
const node_crypto_1 = require("node:crypto");
|
|
47
47
|
const secret_validation_1 = require("./secret-validation");
|
|
48
|
+
const payez_client_secret_1 = require("./payez-client-secret");
|
|
48
49
|
let cachedSecret = null;
|
|
49
50
|
let lastFetchedAt = 0;
|
|
50
51
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -88,6 +89,7 @@ async function resolveNextAuthSecret() {
|
|
|
88
89
|
subject: clientIdStr,
|
|
89
90
|
audience: 'urn:payez:externalauth:nextauthsecret',
|
|
90
91
|
expires_in: 60,
|
|
92
|
+
...(0, payez_client_secret_1.clientSecretBody)(),
|
|
91
93
|
}),
|
|
92
94
|
cache: 'no-store',
|
|
93
95
|
});
|
|
@@ -115,7 +117,7 @@ async function resolveNextAuthSecret() {
|
|
|
115
117
|
'X-Client-Id': clientIdStr,
|
|
116
118
|
'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
|
|
117
119
|
},
|
|
118
|
-
body: JSON.stringify({ client_assertion: clientAssertion }),
|
|
120
|
+
body: JSON.stringify({ client_assertion: clientAssertion, ...(0, payez_client_secret_1.clientSecretBody)() }),
|
|
119
121
|
cache: 'no-store',
|
|
120
122
|
});
|
|
121
123
|
if (!proxyResp.ok) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-Control: no-store for GET routes whose answer carries a person's, a tenant's or a session's data (PAY-2994).
|
|
3
|
+
*
|
|
4
|
+
* WHY: the portal's BFF no-store ratchet (testing/qa/bff-no-store-ratchet.js) found 8 GET routes re-exported from this package that sent no
|
|
5
|
+
* Cache-Control. Their answers carry per-user data and some carry tokens, so a browser or an intermediate cache must never keep them.
|
|
6
|
+
*
|
|
7
|
+
* `noStore(handler)` wraps a route handler and sets `Cache-Control: no-store` on EVERY response it returns (errors and redirects too: nothing
|
|
8
|
+
* a session route says is worth caching). It REPLACES an existing Cache-Control, never merges, so a looser value cannot survive. A Response
|
|
9
|
+
* whose headers are immutable (a fetch() result, Response.redirect()) is rebuilt with the same status, body and headers (Set-Cookie kept).
|
|
10
|
+
*/
|
|
11
|
+
export declare const NO_STORE_VALUE = "no-store";
|
|
12
|
+
export declare function withNoStoreHeader(res: Response): Response;
|
|
13
|
+
export declare function noStore<A extends unknown[], R extends Response>(handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Response>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_STORE_VALUE = void 0;
|
|
4
|
+
exports.withNoStoreHeader = withNoStoreHeader;
|
|
5
|
+
exports.noStore = noStore;
|
|
6
|
+
/**
|
|
7
|
+
* Cache-Control: no-store for GET routes whose answer carries a person's, a tenant's or a session's data (PAY-2994).
|
|
8
|
+
*
|
|
9
|
+
* WHY: the portal's BFF no-store ratchet (testing/qa/bff-no-store-ratchet.js) found 8 GET routes re-exported from this package that sent no
|
|
10
|
+
* Cache-Control. Their answers carry per-user data and some carry tokens, so a browser or an intermediate cache must never keep them.
|
|
11
|
+
*
|
|
12
|
+
* `noStore(handler)` wraps a route handler and sets `Cache-Control: no-store` on EVERY response it returns (errors and redirects too: nothing
|
|
13
|
+
* a session route says is worth caching). It REPLACES an existing Cache-Control, never merges, so a looser value cannot survive. A Response
|
|
14
|
+
* whose headers are immutable (a fetch() result, Response.redirect()) is rebuilt with the same status, body and headers (Set-Cookie kept).
|
|
15
|
+
*/
|
|
16
|
+
exports.NO_STORE_VALUE = 'no-store';
|
|
17
|
+
function withNoStoreHeader(res) {
|
|
18
|
+
// Not a Response (no headers object): hand it back untouched. A Next route always returns a Response, so this only keeps a hand-made
|
|
19
|
+
// test double from being turned into an empty Response.
|
|
20
|
+
if (!res || typeof res !== 'object' || !res.headers)
|
|
21
|
+
return res;
|
|
22
|
+
try {
|
|
23
|
+
res.headers.set('Cache-Control', exports.NO_STORE_VALUE);
|
|
24
|
+
return res;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Immutable headers (TypeError). Rebuild: same status, statusText, body and every header; Headers copies Set-Cookie entries intact.
|
|
28
|
+
const headers = new Headers(res.headers);
|
|
29
|
+
headers.set('Cache-Control', exports.NO_STORE_VALUE);
|
|
30
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function noStore(handler) {
|
|
34
|
+
return async (...args) => withNoStoreHeader(await handler(...args));
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const CLIENT_SECRET_ENV = "PAYEZ_CLIENT_SECRET";
|
|
2
|
+
/** `{ client_secret }` when the env var is set to a non-blank value, else `{}`. Spread it into a JSON body. */
|
|
3
|
+
export declare function clientSecretBody(): {
|
|
4
|
+
client_secret?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The IdP marks a view whose secrets it withheld with the response header X-PayEz-Secrets-Redacted: true. An app with no
|
|
8
|
+
* <PROVIDER>_CLIENT_SECRET in env then boots and the provider fails at the code exchange. Say so, once per provider per
|
|
9
|
+
* process, naming the provider only.
|
|
10
|
+
*/
|
|
11
|
+
export declare function warnIfProviderSecretRedacted(redactedHeader: string | null | undefined, providers: ReadonlyArray<{
|
|
12
|
+
provider: string;
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
}>, envVarFor: (provider: string) => string): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CLIENT_SECRET_ENV = void 0;
|
|
4
|
+
exports.clientSecretBody = clientSecretBody;
|
|
5
|
+
exports.warnIfProviderSecretRedacted = warnIfProviderSecretRedacted;
|
|
6
|
+
/**
|
|
7
|
+
* PAY-1460 (BAPert 86061): the app's own client secret, so the IdP can tell this app from a caller that merely knows its
|
|
8
|
+
* client id. One env var per app, PAYEZ_CLIENT_SECRET, read at CALL time (not a module-load snapshot: the deployment may
|
|
9
|
+
* inject it late). Unset or blank: NOTHING is added to any request body, so an app that has no secret yet sends exactly
|
|
10
|
+
* what it sent before. The value is never logged and never leaves in a response (markNeverInResponse).
|
|
11
|
+
*/
|
|
12
|
+
const never_in_response_1 = require("./never-in-response");
|
|
13
|
+
exports.CLIENT_SECRET_ENV = 'PAYEZ_CLIENT_SECRET';
|
|
14
|
+
/** `{ client_secret }` when the env var is set to a non-blank value, else `{}`. Spread it into a JSON body. */
|
|
15
|
+
function clientSecretBody() {
|
|
16
|
+
const v = process.env[exports.CLIENT_SECRET_ENV];
|
|
17
|
+
if (typeof v !== 'string' || v.trim() === '')
|
|
18
|
+
return {};
|
|
19
|
+
(0, never_in_response_1.markNeverInResponse)(v);
|
|
20
|
+
return { client_secret: v };
|
|
21
|
+
}
|
|
22
|
+
const warnedProviders = new Set();
|
|
23
|
+
/**
|
|
24
|
+
* The IdP marks a view whose secrets it withheld with the response header X-PayEz-Secrets-Redacted: true. An app with no
|
|
25
|
+
* <PROVIDER>_CLIENT_SECRET in env then boots and the provider fails at the code exchange. Say so, once per provider per
|
|
26
|
+
* process, naming the provider only.
|
|
27
|
+
*/
|
|
28
|
+
function warnIfProviderSecretRedacted(redactedHeader, providers, envVarFor) {
|
|
29
|
+
if (String(redactedHeader ?? '').trim().toLowerCase() !== 'true')
|
|
30
|
+
return;
|
|
31
|
+
for (const p of providers) {
|
|
32
|
+
if (!p.enabled || !p.provider)
|
|
33
|
+
continue;
|
|
34
|
+
if (process.env[envVarFor(p.provider)])
|
|
35
|
+
continue;
|
|
36
|
+
if (warnedProviders.has(p.provider))
|
|
37
|
+
continue;
|
|
38
|
+
warnedProviders.add(p.provider);
|
|
39
|
+
console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${p.provider}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qrcode.ts — minimal QR Code generator (byte mode, ECC level M), a
|
|
3
|
+
* TypeScript port of Nayuki's qrcodegen (MIT) trimmed to what TOTP
|
|
4
|
+
* otpauth:// URIs need. Renders to an SVG path string (no library, no
|
|
5
|
+
* canvas) — D3 MFA page generates the QR client-side per contract
|
|
6
|
+
* (fe-be-contract §D3.1: "no BE QR image needed").
|
|
7
|
+
*
|
|
8
|
+
* Cross-checked bit-exact against python `qrcode` (see D3 day-end report).
|
|
9
|
+
* PAY-2631: copied verbatim from VibeID-Customer-Portal src/lib/qrcode.ts so the generated app draws the QR itself:
|
|
10
|
+
* the TOTP secret is never sent to a third-party QR service.
|
|
11
|
+
*/
|
|
12
|
+
export interface QrMatrix {
|
|
13
|
+
size: number;
|
|
14
|
+
modules: boolean[][];
|
|
15
|
+
}
|
|
16
|
+
export declare function debugFinalBytes(text: string): {
|
|
17
|
+
version: number;
|
|
18
|
+
bytes: number[];
|
|
19
|
+
};
|
|
20
|
+
export declare function encodeQr(text: string, maskOverride?: number): QrMatrix;
|
|
21
|
+
/** Render to an SVG path (dark modules as 1x1 squares). */
|
|
22
|
+
export declare function qrToSvgPath(qr: QrMatrix): string;
|