@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
package/dist/auth/better-auth.js
CHANGED
|
@@ -57,6 +57,7 @@ exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
|
|
|
57
57
|
exports.storeIdpTokensInSession = storeIdpTokensInSession;
|
|
58
58
|
exports.storePayezIdpSession = storePayezIdpSession;
|
|
59
59
|
exports.expireSetCookies = expireSetCookies;
|
|
60
|
+
exports.authErrorCodeFromLocation = authErrorCodeFromLocation;
|
|
60
61
|
exports.createAuthGetHandler = createAuthGetHandler;
|
|
61
62
|
require("server-only");
|
|
62
63
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
@@ -77,6 +78,10 @@ const single_flight_1 = require("../lib/single-flight");
|
|
|
77
78
|
const session_store_1 = require("../lib/session-store");
|
|
78
79
|
const token_log_id_1 = require("../lib/token-log-id");
|
|
79
80
|
const log_safe_1 = require("../lib/log-safe");
|
|
81
|
+
const better_auth_exposure_1 = require("./better-auth-exposure");
|
|
82
|
+
const no_store_1 = require("../lib/no-store");
|
|
83
|
+
/** PAY-2592: the magic-link advisory warning fires once per process. */
|
|
84
|
+
let magicLinkAdvisoryWarned = false;
|
|
80
85
|
/**
|
|
81
86
|
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
82
87
|
* to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
|
|
@@ -488,6 +493,12 @@ function buildBetterAuthProviders(config) {
|
|
|
488
493
|
function createBetterAuthInstance(idpConfig, opts = {}) {
|
|
489
494
|
(0, app_slug_1.assertAppSlugMatchesIdp)(idpConfig.clientSlug);
|
|
490
495
|
const appSlug = idpConfig.clientSlug || (0, app_slug_1.getAppSlug)();
|
|
496
|
+
// PAY-2592 (GHSA-965c-763c-88jm): a consumer that enables the magic-link plugin on a better-auth below 1.7.7 is told, once per process. What keeps the
|
|
497
|
+
// package unexposed is account.storeStateStrategy='cookie' below plus the plugin being opt-in; see better-auth-exposure.ts.
|
|
498
|
+
if (opts.magicLink && !magicLinkAdvisoryWarned) {
|
|
499
|
+
magicLinkAdvisoryWarned = true;
|
|
500
|
+
(0, better_auth_exposure_1.warnIfMagicLinkBelowFix)(true);
|
|
501
|
+
}
|
|
491
502
|
// 185121 line 1: name the secret this instance PINNED, as a hash, never the
|
|
492
503
|
// secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
|
|
493
504
|
// caused by a divergent secret is only diagnosable if every instance
|
|
@@ -1376,6 +1387,20 @@ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, lo
|
|
|
1376
1387
|
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1377
1388
|
return new Response(null, { status: 302, headers });
|
|
1378
1389
|
}
|
|
1390
|
+
/**
|
|
1391
|
+
* PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
|
|
1392
|
+
* token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
|
|
1393
|
+
* or absolute; the base is a placeholder used only for parsing.
|
|
1394
|
+
*/
|
|
1395
|
+
function authErrorCodeFromLocation(location) {
|
|
1396
|
+
try {
|
|
1397
|
+
const code = new URL(location, 'http://placeholder.invalid').searchParams.get('error');
|
|
1398
|
+
return code && /^[A-Za-z0-9_.-]{1,64}$/.test(code) ? code : null;
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1379
1404
|
/**
|
|
1380
1405
|
* Create a production-ready GET handler for the auth catch-all route.
|
|
1381
1406
|
*
|
|
@@ -1395,7 +1420,7 @@ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, lo
|
|
|
1395
1420
|
* ```
|
|
1396
1421
|
*/
|
|
1397
1422
|
function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
1398
|
-
|
|
1423
|
+
const get = async function GET(request) {
|
|
1399
1424
|
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
1400
1425
|
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
1401
1426
|
// hook that sees the token) and the exchange below (which forwards it).
|
|
@@ -1411,7 +1436,16 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1411
1436
|
if (response.status === 302) {
|
|
1412
1437
|
const location = response.headers.get('location') || '';
|
|
1413
1438
|
if (location.includes('/api/auth/error') || location.includes('please_restart')) {
|
|
1414
|
-
|
|
1439
|
+
// PAY-2568: log WHICH error. Better Auth routes every callback failure to /api/auth/error?error=<code>
|
|
1440
|
+
// (signup_disabled, state_mismatch, ...); the old fixed 'OAuth state error' sentence made distinct faults
|
|
1441
|
+
// look identical. Only the code is logged - never the Location (it can carry other params).
|
|
1442
|
+
const authErrorCode = authErrorCodeFromLocation(location);
|
|
1443
|
+
if (authErrorCode) {
|
|
1444
|
+
console.warn('[BETTER_AUTH] auth error code=' + authErrorCode + ', redirecting to login');
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
1448
|
+
}
|
|
1415
1449
|
// *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
|
|
1416
1450
|
// Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
|
|
1417
1451
|
// runtime and again on node 24:
|
|
@@ -1448,7 +1482,10 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1448
1482
|
// Works whether loginPath is relative (package default '/account-auth/login',
|
|
1449
1483
|
// and the graft passes '/sign-in') or absolute — an absolute Location is also
|
|
1450
1484
|
// legal, so a consumer passing a full URL still behaves.
|
|
1451
|
-
|
|
1485
|
+
// PAY-2554: the redirect used to carry NO code, so the login page showed nothing and looked like nothing happened.
|
|
1486
|
+
// It now carries the same neutral OAUTH_SIGN_IN_FAILED the abort path uses (the login page maps it to one sentence;
|
|
1487
|
+
// no provider text, URL or internal detail goes on the URL).
|
|
1488
|
+
return new Response(null, { status: 302, headers: { location: `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${exports.OAUTH_SIGN_IN_FAILED_ERROR}` } });
|
|
1452
1489
|
}
|
|
1453
1490
|
}
|
|
1454
1491
|
// After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
|
|
@@ -1496,4 +1533,6 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1496
1533
|
return response;
|
|
1497
1534
|
});
|
|
1498
1535
|
};
|
|
1536
|
+
// PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
|
|
1537
|
+
return (0, no_store_1.noStore)(get);
|
|
1499
1538
|
}
|
|
@@ -75,6 +75,15 @@ const defaultUnauthenticatedRoutes = [
|
|
|
75
75
|
allowDuringCircuitBreakerOpen: true,
|
|
76
76
|
requiresRateLimit: false
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
// PAY-2804: the exact path. '/api/health/*' matches startsWith('/api/health/'), which the bare
|
|
80
|
+
// '/api/health' (the ALB check) does not, so without this entry it sat behind sign-in.
|
|
81
|
+
pattern: '/api/health',
|
|
82
|
+
description: 'Health check endpoint (ALB target; anonymous, state only)',
|
|
83
|
+
allowDuringCircuitBreakerOpen: true,
|
|
84
|
+
// An ALB check comes from few IPs: a rate limit here would answer 429 and get tasks pulled (NightHawk 89179).
|
|
85
|
+
requiresRateLimit: false
|
|
86
|
+
},
|
|
78
87
|
{
|
|
79
88
|
pattern: '/api/health/*',
|
|
80
89
|
description: 'Health check endpoints',
|
|
@@ -15,6 +15,26 @@ export interface ResetParams {
|
|
|
15
15
|
}
|
|
16
16
|
/** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
|
|
17
17
|
export declare function resetSecret(): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
|
|
20
|
+
* key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
|
|
21
|
+
*/
|
|
22
|
+
export declare const RESET_COOKIE_KEY_CONTEXT = "payez:session-reset:v1:cookie-derived";
|
|
23
|
+
/**
|
|
24
|
+
* The secret the reset link is signed and verified with.
|
|
25
|
+
*
|
|
26
|
+
* 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
|
|
27
|
+
* 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
|
|
28
|
+
* Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
|
|
29
|
+
* produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
|
|
30
|
+
* the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
|
|
31
|
+
* RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
|
|
32
|
+
* session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
|
|
33
|
+
* set or shadow cookies and log the victim out, so the scheme adds no capability.
|
|
34
|
+
* 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
|
|
35
|
+
* Never log the cookie value or the returned key.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resetSigningSecret(envSecret: string | undefined, sessionCookieValue: string | undefined): Promise<string | undefined>;
|
|
18
38
|
/** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
|
|
19
39
|
export declare function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined;
|
|
20
40
|
/** Normalise to exactly what the route will honour, so what is signed is what is verified. */
|
|
@@ -32,6 +52,18 @@ export declare function loginLocation(p: ResetParams): string;
|
|
|
32
52
|
* `nowMs` is injectable for tests.
|
|
33
53
|
*/
|
|
34
54
|
export declare function signedResetHref(route: string, p: ResetParams, secret: string, sessionCookieValue: string | undefined, nowMs?: number): Promise<string>;
|
|
55
|
+
/**
|
|
56
|
+
* VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
|
|
57
|
+
* secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
|
|
58
|
+
* signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
|
|
59
|
+
* cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
|
|
60
|
+
* signed with neither key, or with a key derived from a different cookie, is refused.
|
|
61
|
+
*/
|
|
62
|
+
export declare function readResetRequestAnyKey(search: URLSearchParams, envSecret: string | undefined, sessionCookieValue: string | undefined, nowMs?: number): Promise<{
|
|
63
|
+
params: ResetParams;
|
|
64
|
+
signed: boolean;
|
|
65
|
+
via: 'env' | 'cookie' | null;
|
|
66
|
+
}>;
|
|
35
67
|
/**
|
|
36
68
|
* Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
|
|
37
69
|
* this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.RESET_ERRORS = exports.RESET_REASONS = exports.RESET_KEY_PURPOSE = exports.RESET_LINK_TTL_SECONDS = exports.DEFAULT_SESSION_RESET_ROUTE = exports.LOGIN_PAGE = void 0;
|
|
3
|
+
exports.RESET_COOKIE_KEY_CONTEXT = exports.RESET_ERRORS = exports.RESET_REASONS = exports.RESET_KEY_PURPOSE = exports.RESET_LINK_TTL_SECONDS = exports.DEFAULT_SESSION_RESET_ROUTE = exports.LOGIN_PAGE = void 0;
|
|
4
4
|
exports.resetSecret = resetSecret;
|
|
5
|
+
exports.resetSigningSecret = resetSigningSecret;
|
|
5
6
|
exports.boundSessionCookie = boundSessionCookie;
|
|
6
7
|
exports.normaliseResetParams = normaliseResetParams;
|
|
7
8
|
exports.resetPayload = resetPayload;
|
|
8
9
|
exports.loginLocation = loginLocation;
|
|
9
10
|
exports.signedResetHref = signedResetHref;
|
|
11
|
+
exports.readResetRequestAnyKey = readResetRequestAnyKey;
|
|
10
12
|
exports.readResetRequest = readResetRequest;
|
|
11
13
|
/**
|
|
12
14
|
* PAY-2218 PR 2: the hand-off between the middleware and the session-reset route.
|
|
@@ -85,6 +87,32 @@ function sameString(a, b) {
|
|
|
85
87
|
function resetSecret() {
|
|
86
88
|
return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
|
|
87
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
|
|
92
|
+
* key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
|
|
93
|
+
*/
|
|
94
|
+
exports.RESET_COOKIE_KEY_CONTEXT = 'payez:session-reset:v1:cookie-derived';
|
|
95
|
+
/**
|
|
96
|
+
* The secret the reset link is signed and verified with.
|
|
97
|
+
*
|
|
98
|
+
* 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
|
|
99
|
+
* 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
|
|
100
|
+
* Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
|
|
101
|
+
* produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
|
|
102
|
+
* the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
|
|
103
|
+
* RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
|
|
104
|
+
* session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
|
|
105
|
+
* set or shadow cookies and log the victim out, so the scheme adds no capability.
|
|
106
|
+
* 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
|
|
107
|
+
* Never log the cookie value or the returned key.
|
|
108
|
+
*/
|
|
109
|
+
async function resetSigningSecret(envSecret, sessionCookieValue) {
|
|
110
|
+
if (envSecret)
|
|
111
|
+
return envSecret;
|
|
112
|
+
if (!sessionCookieValue)
|
|
113
|
+
return undefined;
|
|
114
|
+
return b64url(await hmacBytes(encoder.encode(sessionCookieValue), exports.RESET_COOKIE_KEY_CONTEXT));
|
|
115
|
+
}
|
|
88
116
|
/** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
|
|
89
117
|
function boundSessionCookie(get, plainName, secureName) {
|
|
90
118
|
return get(secureName) || get(plainName) || undefined;
|
|
@@ -126,6 +154,22 @@ async function signedResetHref(route, p, secret, sessionCookieValue, nowMs = Dat
|
|
|
126
154
|
q.set('sig', await sign(secret, resetPayload(p, exp, await sessionBinding(sessionCookieValue))));
|
|
127
155
|
return `${route}?${q.toString()}`;
|
|
128
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
|
|
159
|
+
* secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
|
|
160
|
+
* signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
|
|
161
|
+
* cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
|
|
162
|
+
* signed with neither key, or with a key derived from a different cookie, is refused.
|
|
163
|
+
*/
|
|
164
|
+
async function readResetRequestAnyKey(search, envSecret, sessionCookieValue, nowMs = Date.now()) {
|
|
165
|
+
if (envSecret) {
|
|
166
|
+
const viaEnv = await readResetRequest(search, envSecret, sessionCookieValue, nowMs);
|
|
167
|
+
if (viaEnv.signed)
|
|
168
|
+
return { ...viaEnv, via: 'env' };
|
|
169
|
+
}
|
|
170
|
+
const viaCookie = await readResetRequest(search, await resetSigningSecret(undefined, sessionCookieValue), sessionCookieValue, nowMs);
|
|
171
|
+
return { ...viaCookie, via: viaCookie.signed ? 'cookie' : null };
|
|
172
|
+
}
|
|
129
173
|
/**
|
|
130
174
|
* Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
|
|
131
175
|
* this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
|
|
@@ -27,7 +27,13 @@ interface Props {
|
|
|
27
27
|
/** Test seam: navigation after success. */
|
|
28
28
|
onSignedIn?: (destination: string) => void;
|
|
29
29
|
showForgotPassword?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* PAY-2630: the enrolment page a user the IdP told to enrol is taken to after signing in. NO DEFAULT: the offer renders only when
|
|
32
|
+
* the host passes this, because the host is the one that serves the page (the CLI scaffold writes no /account/security, so a
|
|
33
|
+
* default would 404 a generated app's user right after a good sign-in).
|
|
34
|
+
*/
|
|
35
|
+
enrollHref?: string;
|
|
30
36
|
}
|
|
31
37
|
export declare function resolveDestination(explicit?: string): string;
|
|
32
|
-
export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword }: Props): import("react/jsx-runtime").JSX.Element;
|
|
38
|
+
export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref }: Props): import("react/jsx-runtime").JSX.Element;
|
|
33
39
|
export default CredentialSignInForm;
|
|
@@ -10,6 +10,7 @@ const safe_callback_1 = require("../../auth/safe-callback");
|
|
|
10
10
|
const authorize_resume_1 = require("../../lib/authorize-resume");
|
|
11
11
|
const code_step_copy_1 = require("../../lib/code-step-copy");
|
|
12
12
|
const DevCodeNotice_1 = require("./DevCodeNotice");
|
|
13
|
+
const EnrollAuthenticatorOffer_1 = require("./EnrollAuthenticatorOffer");
|
|
13
14
|
const credential_sign_in_client_1 = require("../../lib/credential-sign-in-client");
|
|
14
15
|
const inputStyle = {
|
|
15
16
|
borderColor: 'var(--border-default, #e5e7eb)',
|
|
@@ -30,26 +31,31 @@ function resolveDestination(explicit) {
|
|
|
30
31
|
}
|
|
31
32
|
return (0, safe_callback_1.safeCallbackPath)((0, authorize_resume_1.authorizeResumeHref)(returnUrl), (0, safe_callback_1.safeCallbackPath)(raw, '/dashboard'));
|
|
32
33
|
}
|
|
33
|
-
function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false }) {
|
|
34
|
+
function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }) {
|
|
34
35
|
const [step, setStep] = (0, react_1.useState)('credentials');
|
|
35
36
|
const [email, setEmail] = (0, react_1.useState)('');
|
|
36
37
|
const [password, setPassword] = (0, react_1.useState)('');
|
|
37
38
|
const [code, setCode] = (0, react_1.useState)('');
|
|
38
39
|
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
39
40
|
const [error, setError] = (0, react_1.useState)(null);
|
|
41
|
+
const [errorCode, setErrorCode] = (0, react_1.useState)(null);
|
|
40
42
|
const [fieldError, setFieldError] = (0, react_1.useState)(null);
|
|
41
43
|
const [codeRequest, setCodeRequest] = (0, react_1.useState)(code_step_copy_1.NO_CODE_REQUEST);
|
|
42
44
|
const [offerEmailCode, setOfferEmailCode] = (0, react_1.useState)(false);
|
|
43
45
|
const [devCode, setDevCode] = (0, react_1.useState)(null);
|
|
44
46
|
const [emailCodeMsg, setEmailCodeMsg] = (0, react_1.useState)(null);
|
|
47
|
+
// PAY-2630: what the IdP said is outstanding on this client, and whether the user kept the 'take me to enrol' offer ticked.
|
|
48
|
+
const [nextStep, setNextStep] = (0, react_1.useState)(null);
|
|
49
|
+
const [enrolAfter, setEnrolAfter] = (0, react_1.useState)(true);
|
|
45
50
|
async function submit(withCode) {
|
|
46
51
|
setBusy(true);
|
|
47
52
|
setError(null);
|
|
53
|
+
setErrorCode(null);
|
|
48
54
|
setOfferEmailCode(false);
|
|
49
55
|
try {
|
|
50
56
|
const r = await (0, credential_sign_in_client_1.credentialSignIn)({ email, password, code: withCode }, fetchImpl);
|
|
51
57
|
if (r.kind === 'success') {
|
|
52
|
-
const destination = resolveDestination(callbackUrl);
|
|
58
|
+
const destination = (0, credential_sign_in_client_1.postSignInDestination)(resolveDestination(callbackUrl), nextStep, enrolAfter, enrollHref);
|
|
53
59
|
if (onSignedIn)
|
|
54
60
|
onSignedIn(destination);
|
|
55
61
|
else
|
|
@@ -59,10 +65,12 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
|
|
|
59
65
|
if (r.kind === 'two_factor') {
|
|
60
66
|
setCodeRequest(r.codeRequest);
|
|
61
67
|
setDevCode(r.devCode);
|
|
68
|
+
setNextStep(r.nextStep);
|
|
62
69
|
setStep('code');
|
|
63
70
|
return;
|
|
64
71
|
}
|
|
65
72
|
setError(r.message);
|
|
73
|
+
setErrorCode(r.code);
|
|
66
74
|
if (r.kind === 'error')
|
|
67
75
|
setOfferEmailCode(r.offerEmailCode);
|
|
68
76
|
}
|
|
@@ -94,6 +102,7 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
|
|
|
94
102
|
setStep('credentials');
|
|
95
103
|
setCodeRequest(code_step_copy_1.NO_CODE_REQUEST);
|
|
96
104
|
setDevCode(null);
|
|
105
|
+
setNextStep(null);
|
|
97
106
|
setCode('');
|
|
98
107
|
setFieldError(null);
|
|
99
108
|
setError(null);
|
|
@@ -121,12 +130,12 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
|
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
const problem = fieldError ?? error;
|
|
124
|
-
const problemLine = problem && ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "credential-sign-in-error", className: "text-sm mt-3", style: { color: 'var(--color-error, #ef4444)' }, children: problem }));
|
|
133
|
+
const problemLine = problem && ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "credential-sign-in-error", "data-error-code": fieldError ? 'invalid_input' : (errorCode ?? undefined), className: "text-sm mt-3", style: { color: 'var(--color-error, #ef4444)' }, children: problem }));
|
|
125
134
|
if (step === 'code') {
|
|
126
135
|
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "credential-sign-in-code-step", className: "text-left", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm mb-4", style: { color: 'var(--text-primary, #111827)' }, children: (0, code_step_copy_1.codeStepInstruction)(email, codeRequest) }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: onCodeSubmit, noValidate: true, className: "space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "credential-code", className: "block text-sm font-medium mb-2", style: { color: 'var(--text-primary, #111827)' }, children: "Authentication code" }), (0, jsx_runtime_1.jsx)("input", { id: "credential-code", name: "code", type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, placeholder: "000000", value: code, disabled: busy, onChange: (e) => {
|
|
127
136
|
setCode(e.target.value);
|
|
128
137
|
setFieldError(null);
|
|
129
|
-
}, className: "w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50", style: inputStyle }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy, className: "w-full py-3 px-4 rounded-lg font-semibold focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", style: { background: 'var(--btn-primary-bg, #11B588)', color: 'var(--btn-primary-text, white)' }, children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onBack, disabled: busy, className: "text-sm underline mt-4", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }, children: "Back" }), problemLine, (offerEmailCode || emailCodeMsg) && ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "email-code-offer", className: "mt-4", children: [offerEmailCode && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void askForEmailCode(), disabled: busy, className: "text-sm font-semibold underline", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-primary, #111827)' }, children: "Email me a code instead" })), emailCodeMsg && ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "email-code-offer-msg", className: "text-sm mt-2", style: { color: 'var(--text-primary, #111827)' }, children: emailCodeMsg }))] }))] }));
|
|
138
|
+
}, className: "w-full px-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2 disabled:opacity-50", style: inputStyle }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy, className: "w-full py-3 px-4 rounded-lg font-semibold focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", style: { background: 'var(--btn-primary-bg, #11B588)', color: 'var(--btn-primary-text, white)' }, children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)(EnrollAuthenticatorOffer_1.EnrollAuthenticatorOffer, { nextStep: enrollHref ? nextStep : null, checked: enrolAfter, onChange: setEnrolAfter, disabled: busy }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onBack, disabled: busy, className: "text-sm underline mt-4", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }, children: "Back" }), problemLine, (offerEmailCode || emailCodeMsg) && ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "email-code-offer", className: "mt-4", children: [offerEmailCode && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void askForEmailCode(), disabled: busy, className: "text-sm font-semibold underline", style: { background: 'none', border: 'none', padding: 0, color: 'var(--text-primary, #111827)' }, children: "Email me a code instead" })), emailCodeMsg && ((0, jsx_runtime_1.jsx)("p", { role: "status", "data-testid": "email-code-offer-msg", className: "text-sm mt-2", style: { color: 'var(--text-primary, #111827)' }, children: emailCodeMsg }))] }))] }));
|
|
130
139
|
}
|
|
131
140
|
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "credential-sign-in", className: "text-left", children: [(0, jsx_runtime_1.jsx)(TraditionalAuthSection_1.TraditionalAuthSection, { email: email, password: password, onEmailChange: (v) => {
|
|
132
141
|
setEmail(v);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NextStep } from '../../lib/credential-sign-in-client';
|
|
2
|
+
/**
|
|
3
|
+
* PAY-2630: the code step's offer to set up an authenticator.
|
|
4
|
+
*
|
|
5
|
+
* WHY. The IdP answers the first sign-in turn with next_step 'enroll_mfa' when the user has NO usable second factor on this
|
|
6
|
+
* client (factors are per client). The step used to ignore that and show only "enter the code we emailed you", so a user of a
|
|
7
|
+
* generated app could never enrol a factor on that client. Measured on the PAY-2305 walk (client 90662, user with a TOTP on
|
|
8
|
+
* client 12 only): requiresTwoFactor true, sentVia 'email', nextStep 'enroll_mfa', and a page with Verify / Back and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT IS, AND IS NOT. It renders ONLY for 'enroll_mfa'. It names the lane the user is on (the emailed code) and offers to take
|
|
11
|
+
* them to the enrolment page right after they sign in with it. It does NOT enrol in place: the enrolment endpoints need a signed-in
|
|
12
|
+
* session and this step has none yet (the provisional bearer is parked server-side). For 'mfa_verify', null or anything else it
|
|
13
|
+
* renders nothing: a user who HAS a factor must not be told to set one up.
|
|
14
|
+
*/
|
|
15
|
+
export declare function EnrollAuthenticatorOffer({ nextStep, checked, onChange, disabled, }: {
|
|
16
|
+
nextStep: NextStep;
|
|
17
|
+
checked: boolean;
|
|
18
|
+
onChange: (next: boolean) => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
21
|
+
export default EnrollAuthenticatorOffer;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EnrollAuthenticatorOffer = EnrollAuthenticatorOffer;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
/**
|
|
6
|
+
* PAY-2630: the code step's offer to set up an authenticator.
|
|
7
|
+
*
|
|
8
|
+
* WHY. The IdP answers the first sign-in turn with next_step 'enroll_mfa' when the user has NO usable second factor on this
|
|
9
|
+
* client (factors are per client). The step used to ignore that and show only "enter the code we emailed you", so a user of a
|
|
10
|
+
* generated app could never enrol a factor on that client. Measured on the PAY-2305 walk (client 90662, user with a TOTP on
|
|
11
|
+
* client 12 only): requiresTwoFactor true, sentVia 'email', nextStep 'enroll_mfa', and a page with Verify / Back and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT IS, AND IS NOT. It renders ONLY for 'enroll_mfa'. It names the lane the user is on (the emailed code) and offers to take
|
|
14
|
+
* them to the enrolment page right after they sign in with it. It does NOT enrol in place: the enrolment endpoints need a signed-in
|
|
15
|
+
* session and this step has none yet (the provisional bearer is parked server-side). For 'mfa_verify', null or anything else it
|
|
16
|
+
* renders nothing: a user who HAS a factor must not be told to set one up.
|
|
17
|
+
*/
|
|
18
|
+
function EnrollAuthenticatorOffer({ nextStep, checked, onChange, disabled, }) {
|
|
19
|
+
if (nextStep !== 'enroll_mfa')
|
|
20
|
+
return null;
|
|
21
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "enroll-authenticator-offer", className: "mt-4 text-sm", style: { color: 'var(--text-primary, #111827)' }, children: [(0, jsx_runtime_1.jsx)("p", { "data-testid": "enroll-authenticator-lane", children: "You have no authenticator set up for this app yet, so this time you sign in with the emailed code." }), (0, jsx_runtime_1.jsxs)("label", { className: "flex items-center gap-2 mt-2", children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", "data-testid": "enroll-authenticator-checkbox", checked: checked, disabled: disabled, onChange: (e) => onChange(e.target.checked) }), (0, jsx_runtime_1.jsx)("span", { children: "Take me to set up an authenticator right after I sign in" })] })] }));
|
|
22
|
+
}
|
|
23
|
+
exports.default = EnrollAuthenticatorOffer;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** PAY-2631: client-side QR from an otpauth:// URI. Inline SVG, quiet zone of 4 modules; no network, no library. */
|
|
2
|
+
export declare function QrCode({ value, size }: {
|
|
3
|
+
value: string;
|
|
4
|
+
size?: number;
|
|
5
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export default QrCode;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.QrCode = QrCode;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const qrcode_1 = require("../../lib/qrcode");
|
|
7
|
+
/** PAY-2631: client-side QR from an otpauth:// URI. Inline SVG, quiet zone of 4 modules; no network, no library. */
|
|
8
|
+
function QrCode({ value, size = 168 }) {
|
|
9
|
+
const qr = (0, react_1.useMemo)(() => (0, qrcode_1.encodeQr)(value), [value]);
|
|
10
|
+
const path = (0, react_1.useMemo)(() => (0, qrcode_1.qrToSvgPath)(qr), [qr]);
|
|
11
|
+
const modules = qr.size;
|
|
12
|
+
return ((0, jsx_runtime_1.jsxs)("svg", { width: size, height: size, viewBox: `-4 -4 ${modules + 8} ${modules + 8}`, role: "img", "aria-label": "QR code for authenticator setup", "data-testid": "totp-qr", children: [(0, jsx_runtime_1.jsx)("rect", { x: -4, y: -4, width: modules + 8, height: modules + 8, fill: "#ffffff" }), (0, jsx_runtime_1.jsx)("path", { d: path, fill: "#212126" })] }));
|
|
13
|
+
}
|
|
14
|
+
exports.default = QrCode;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const STEP_UP_SENTENCE = "This session was signed in with an emailed or texted code only. Sign in with your current authenticator, then try again.";
|
|
2
|
+
export declare function TotpEnrollPanel({ fetchImpl, onConfirmed }?: {
|
|
3
|
+
fetchImpl?: typeof fetch;
|
|
4
|
+
onConfirmed?: () => void;
|
|
5
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export default TotpEnrollPanel;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.STEP_UP_SENTENCE = void 0;
|
|
5
|
+
exports.TotpEnrollPanel = TotpEnrollPanel;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const QrCode_1 = require("./QrCode");
|
|
9
|
+
const two_factor_verify_failure_1 = require("../../lib/two-factor-verify-failure");
|
|
10
|
+
exports.STEP_UP_SENTENCE = 'This session was signed in with an emailed or texted code only. Sign in with your current authenticator, then try again.';
|
|
11
|
+
async function readJson(res) {
|
|
12
|
+
return res.json().catch(() => null);
|
|
13
|
+
}
|
|
14
|
+
function TotpEnrollPanel({ fetchImpl, onConfirmed } = {}) {
|
|
15
|
+
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
16
|
+
const [stage, setStage] = (0, react_1.useState)('idle');
|
|
17
|
+
const [enroll, setEnroll] = (0, react_1.useState)(null);
|
|
18
|
+
const [code, setCode] = (0, react_1.useState)('');
|
|
19
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
20
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
21
|
+
async function begin() {
|
|
22
|
+
setBusy(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
try {
|
|
25
|
+
const res = await doFetch('/api/mfa/totp/enroll', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ friendly_name: 'This device' }),
|
|
29
|
+
});
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const body = (await readJson(res));
|
|
32
|
+
if (!body || !body.secret || !body.otpauth_uri || body.enrollment_id === undefined) {
|
|
33
|
+
setError('Something went wrong on our end. Try again.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setEnroll({ secret: body.secret, otpauth_uri: body.otpauth_uri, enrollment_id: body.enrollment_id });
|
|
37
|
+
setCode('');
|
|
38
|
+
setStage('enrolling');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (res.status === 401) {
|
|
42
|
+
setStage('signed-out');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const json = await readJson(res);
|
|
46
|
+
if ((0, two_factor_verify_failure_1.stepUpRefusal)(res.status, json)) {
|
|
47
|
+
setError(exports.STEP_UP_SENTENCE);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (res.status === 403) {
|
|
51
|
+
setError('This account already has a second factor set up, so an authenticator app cannot be added here. Sign in with your existing method.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setError('Something went wrong on our end. Try again.');
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
setBusy(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function confirm() {
|
|
61
|
+
if (!enroll || !/^\d{6}$/.test(code)) {
|
|
62
|
+
setError('Enter the 6-digit code from your app.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setBusy(true);
|
|
66
|
+
setError(null);
|
|
67
|
+
try {
|
|
68
|
+
const res = await doFetch('/api/mfa/totp/confirm', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }),
|
|
72
|
+
});
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
setEnroll(null);
|
|
75
|
+
setCode('');
|
|
76
|
+
setStage('done');
|
|
77
|
+
onConfirmed?.();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (res.status === 401) {
|
|
81
|
+
setStage('signed-out');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const json = await readJson(res);
|
|
85
|
+
if ((0, two_factor_verify_failure_1.stepUpRefusal)(res.status, json)) {
|
|
86
|
+
setError(exports.STEP_UP_SENTENCE);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setCode('');
|
|
90
|
+
setError(res.status === 400 || res.status === 422
|
|
91
|
+
? 'That code did not match. Check the time on your phone and try the next code.'
|
|
92
|
+
: 'Setup was not completed. Try again.');
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (stage === 'signed-out') {
|
|
99
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-signed-out", children: [(0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", children: "Sign in" }), " to set up an authenticator app."] }));
|
|
100
|
+
}
|
|
101
|
+
if (stage === 'done') {
|
|
102
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-done", role: "status", children: [(0, jsx_runtime_1.jsx)("p", { children: "Authenticator app added." }), (0, jsx_runtime_1.jsx)("p", { children: "The next time you sign in on this app, you will be asked for the 6-digit code from it." })] }));
|
|
103
|
+
}
|
|
104
|
+
if (stage === 'enrolling' && enroll) {
|
|
105
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-enrolling", style: { display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }, children: [(0, jsx_runtime_1.jsx)("p", { children: "Open your authenticator app and scan this code. A phone camera works too." }), (0, jsx_runtime_1.jsx)(QrCode_1.QrCode, { value: enroll.otpauth_uri }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { children: "Can't scan? Enter this code manually:" }), (0, jsx_runtime_1.jsx)("code", { "data-testid": "totp-secret", style: { wordBreak: 'break-all' }, children: enroll.secret })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: (e) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
void confirm();
|
|
108
|
+
}, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "totp-code", children: "Enter the 6-digit code from your app:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("input", { id: "totp-code", "data-testid": "totp-code", inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6, value: code, disabled: busy, onChange: (e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6)) }), ' ', (0, jsx_runtime_1.jsx)("button", { type: "submit", "data-testid": "totp-confirm", disabled: busy || code.length !== 6, children: "Confirm" })] }), error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "totp-error", children: error })) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", "data-testid": "totp-restart", disabled: busy, onClick: () => {
|
|
109
|
+
setEnroll(null);
|
|
110
|
+
setCode('');
|
|
111
|
+
setError(null);
|
|
112
|
+
setStage('idle');
|
|
113
|
+
}, children: "Start again" })] }));
|
|
114
|
+
}
|
|
115
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-idle", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-testid": "totp-begin", disabled: busy, onClick: () => void begin(), children: busy ? 'Starting…' : 'Add authenticator app' }), error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "totp-error", children: error })) : null] }));
|
|
116
|
+
}
|
|
117
|
+
exports.default = TotpEnrollPanel;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export { makeAuthDecision } from '../auth/auth-decision';
|
|
8
8
|
export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig, configure2FABypassRoutes, should2FABypass, get2FABypassConfig } from '../auth/route-config';
|
|
9
9
|
export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
|
|
10
|
-
export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
|
|
10
|
+
export { createMvpMiddleware, isPrefetchRequest, isKnownNonNavigationRequest } from '../middleware/create-middleware';
|
|
11
11
|
export type { MvpMiddlewareOptions, CircuitBreakerProvider, MiddlewareLogger, AuthContext, AuthAction, SessionPointer, SessionStatus } from '../middleware/create-middleware';
|
|
12
12
|
export { TwoFactorPresets, AMRValues, ACRLevels, validateAMR, validateACR, checkTwoFactorRequirements } from '../middleware/twofa-presets';
|
|
13
13
|
export type { TwoFactorRequirements, RouteConfig } from '../middleware/twofa-presets';
|
package/dist/edge/middleware.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Client-side utilities that depend on browser APIs or server-only code are excluded.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.getInternalApiUrl = exports.checkTwoFactorRequirements = exports.validateACR = exports.validateAMR = exports.ACRLevels = exports.AMRValues = exports.TwoFactorPresets = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.get2FABypassConfig = exports.should2FABypass = exports.configure2FABypassRoutes = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = void 0;
|
|
9
|
+
exports.getInternalApiUrl = exports.checkTwoFactorRequirements = exports.validateACR = exports.validateAMR = exports.ACRLevels = exports.AMRValues = exports.TwoFactorPresets = exports.isKnownNonNavigationRequest = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.get2FABypassConfig = exports.should2FABypass = exports.configure2FABypassRoutes = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = void 0;
|
|
10
10
|
// Authentication middleware and configuration (Edge Runtime compatible)
|
|
11
11
|
var auth_decision_1 = require("../auth/auth-decision");
|
|
12
12
|
Object.defineProperty(exports, "makeAuthDecision", { enumerable: true, get: function () { return auth_decision_1.makeAuthDecision; } });
|
|
@@ -20,6 +20,7 @@ Object.defineProperty(exports, "get2FABypassConfig", { enumerable: true, get: fu
|
|
|
20
20
|
var create_middleware_1 = require("../middleware/create-middleware");
|
|
21
21
|
Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
|
|
22
22
|
Object.defineProperty(exports, "isPrefetchRequest", { enumerable: true, get: function () { return create_middleware_1.isPrefetchRequest; } });
|
|
23
|
+
Object.defineProperty(exports, "isKnownNonNavigationRequest", { enumerable: true, get: function () { return create_middleware_1.isKnownNonNavigationRequest; } });
|
|
23
24
|
// Two-Factor Authentication Presets (Edge Runtime compatible)
|
|
24
25
|
var twofa_presets_1 = require("../middleware/twofa-presets");
|
|
25
26
|
Object.defineProperty(exports, "TwoFactorPresets", { enumerable: true, get: function () { return twofa_presets_1.TwoFactorPresets; } });
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type { AuthConfig } from './types/auth';
|
|
|
6
6
|
export { makeAuthDecision } from './auth/auth-decision';
|
|
7
7
|
export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from './auth/route-config';
|
|
8
8
|
export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
|
|
9
|
-
export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
|
|
9
|
+
export { createMvpMiddleware, isPrefetchRequest, isKnownNonNavigationRequest } from './middleware/create-middleware';
|
|
10
10
|
export { UserAvatarMenu, MobileNavDrawer } from './components/account';
|
|
11
11
|
export type { UserAvatarMenuProps, MobileNavDrawerProps, NavItem, NavSection } from './components/account';
|
|
12
12
|
export { ErrorMetricsCard, HealthMetricsCard, AuditLogViewer, AdminAnalyticsLayout, useErrorMetrics, useHealthMetrics, useAuditLog, useAdminAnalytics, getErrorMetrics, getHealthMetrics, writeAuditLog, queryAuditLog, } from './logging';
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// - Better Auth: import { createBetterAuthInstance } from '@payez/next-mvp/auth/better-auth'
|
|
9
9
|
// - Server auth: import { getSession } from '@payez/next-mvp/server/auth'
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
|
|
11
|
+
exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.isKnownNonNavigationRequest = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
|
|
12
12
|
// Client-safe exports only
|
|
13
13
|
// Client-side utilities
|
|
14
14
|
var fetch_with_auth_1 = require("./client/fetch-with-auth");
|
|
@@ -41,6 +41,7 @@ Object.defineProperty(exports, "DEFAULT_CALLBACK", { enumerable: true, get: func
|
|
|
41
41
|
var create_middleware_1 = require("./middleware/create-middleware");
|
|
42
42
|
Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
|
|
43
43
|
Object.defineProperty(exports, "isPrefetchRequest", { enumerable: true, get: function () { return create_middleware_1.isPrefetchRequest; } });
|
|
44
|
+
Object.defineProperty(exports, "isKnownNonNavigationRequest", { enumerable: true, get: function () { return create_middleware_1.isKnownNonNavigationRequest; } });
|
|
44
45
|
// Account Components
|
|
45
46
|
var account_1 = require("./components/account");
|
|
46
47
|
Object.defineProperty(exports, "UserAvatarMenu", { enumerable: true, get: function () { return account_1.UserAvatarMenu; } });
|