@payez/next-mvp 4.13.0 → 4.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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 +46 -0
- package/dist/api-handlers/health.d.ts +5 -0
- package/dist/api-handlers/health.js +48 -0
- package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
- package/dist/api-handlers/mfa/first-enrol.js +58 -0
- package/dist/api-handlers/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 +5 -2
- package/dist/auth/better-auth-exposure.d.ts +11 -0
- package/dist/auth/better-auth-exposure.js +73 -0
- package/dist/auth/better-auth.js +13 -1
- package/dist/auth/route-config.js +9 -0
- package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
- package/dist/components/auth/CredentialSignInForm.js +15 -1
- package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
- package/dist/components/mfa/FirstEnrolPanel.js +115 -0
- package/dist/lib/credential-sign-in-client.d.ts +4 -0
- package/dist/lib/credential-sign-in-client.js +13 -1
- package/dist/lib/first-enrol.d.ts +66 -0
- package/dist/lib/first-enrol.js +188 -0
- package/dist/lib/no-store.d.ts +13 -0
- package/dist/lib/no-store.js +35 -0
- package/dist/pages/first-enroll/page.d.ts +7 -0
- package/dist/pages/first-enroll/page.js +15 -0
- 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 +5 -2
- package/dist/routes/health.d.ts +1 -0
- package/dist/routes/health.js +16 -0
- package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
- package/dist/routes/mfa/first-enrol-confirm.js +14 -0
- package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
- package/dist/routes/mfa/first-enrol-enroll.js +14 -0
- package/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 +26 -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 +49 -0
- package/src/api-handlers/health.ts +53 -0
- package/src/api-handlers/mfa/first-enrol.ts +55 -0
- package/src/api-handlers/session/refresh-viability.ts +5 -1
- package/src/api-handlers/session/viability.ts +6 -2
- package/src/auth/better-auth-exposure.ts +69 -0
- package/src/auth/better-auth.ts +15 -1
- package/src/auth/route-config.ts +9 -0
- package/src/components/auth/CredentialSignInForm.tsx +18 -1
- package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
- package/src/lib/credential-sign-in-client.ts +13 -1
- package/src/lib/first-enrol.ts +180 -0
- package/src/lib/no-store.ts +30 -0
- package/src/pages/first-enroll/page.tsx +19 -0
- package/src/routes/auth/session.ts +5 -1
- package/src/routes/auth/viability.ts +6 -2
- package/src/routes/health.ts +14 -0
- package/src/routes/mfa/first-enrol-confirm.ts +10 -0
- package/src/routes/mfa/first-enrol-enroll.ts +10 -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
|
@@ -31,7 +31,7 @@ function resolveDestination(explicit) {
|
|
|
31
31
|
}
|
|
32
32
|
return (0, safe_callback_1.safeCallbackPath)((0, authorize_resume_1.authorizeResumeHref)(returnUrl), (0, safe_callback_1.safeCallbackPath)(raw, '/dashboard'));
|
|
33
33
|
}
|
|
34
|
-
function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }) {
|
|
34
|
+
function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref, firstEnrolHref }) {
|
|
35
35
|
const [step, setStep] = (0, react_1.useState)('credentials');
|
|
36
36
|
const [email, setEmail] = (0, react_1.useState)('');
|
|
37
37
|
const [password, setPassword] = (0, react_1.useState)('');
|
|
@@ -62,6 +62,20 @@ function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPa
|
|
|
62
62
|
window.location.href = destination;
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
+
if (r.kind === 'enrol_required') {
|
|
66
|
+
// AUTH-045: the code was accepted as contact proof; the door parked the enrolment-scoped token in an httpOnly cookie. Go and enrol: never redraw
|
|
67
|
+
// the code box (the 14 Nov walk looped there). A host that gave no enrolment page gets a sentence, not a silent loop.
|
|
68
|
+
if (firstEnrolHref) {
|
|
69
|
+
if (onSignedIn)
|
|
70
|
+
onSignedIn(firstEnrolHref);
|
|
71
|
+
else
|
|
72
|
+
window.location.href = firstEnrolHref;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setError('This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner.');
|
|
76
|
+
setErrorCode('sign_in_failed');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
65
79
|
if (r.kind === 'two_factor') {
|
|
66
80
|
setCodeRequest(r.codeRequest);
|
|
67
81
|
setDevCode(r.devCode);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969): the panel for a person the IdP told to ENROL a factor on THIS app, who has NO session.
|
|
3
|
+
* Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
|
|
4
|
+
* POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
|
|
5
|
+
* POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
|
|
6
|
+
*
|
|
7
|
+
* Rules: the secret and otpauth_uri live in component state only (never a URL, storage, console or a third-party QR service); a 401 is never a
|
|
8
|
+
* message box (the person is sent back to sign-in); a fixed sentence per refusal code, never the server's text; no session is minted here.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FIRST_ENROL_ENROLL_URL = "/api/auth/first-enrol/enroll";
|
|
11
|
+
export declare const FIRST_ENROL_CONFIRM_URL = "/api/auth/first-enrol/confirm";
|
|
12
|
+
/** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
|
|
13
|
+
export declare const PER_APP_SENTENCE = "This app has its own authenticator entry: an authenticator you set up for another app or site does not carry over, so you add one here once.";
|
|
14
|
+
export declare const DONE_SENTENCE = "Your authenticator is set. Sign in with your password and the code from your app.";
|
|
15
|
+
export declare const SIGN_IN_AGAIN_SENTENCE = "This setup timed out or the sign-in expired. Sign in again to continue.";
|
|
16
|
+
export declare function FirstEnrolPanel({ fetchImpl, signInHref, onSignInAgain, }?: {
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
signInHref?: string;
|
|
19
|
+
onSignInAgain?: (href: string) => void;
|
|
20
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export default FirstEnrolPanel;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.SIGN_IN_AGAIN_SENTENCE = exports.DONE_SENTENCE = exports.PER_APP_SENTENCE = exports.FIRST_ENROL_CONFIRM_URL = exports.FIRST_ENROL_ENROLL_URL = void 0;
|
|
5
|
+
exports.FirstEnrolPanel = FirstEnrolPanel;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const QrCode_1 = require("./QrCode");
|
|
9
|
+
/**
|
|
10
|
+
* AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969): the panel for a person the IdP told to ENROL a factor on THIS app, who has NO session.
|
|
11
|
+
* Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
|
|
12
|
+
* POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
|
|
13
|
+
* POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
|
|
14
|
+
*
|
|
15
|
+
* Rules: the secret and otpauth_uri live in component state only (never a URL, storage, console or a third-party QR service); a 401 is never a
|
|
16
|
+
* message box (the person is sent back to sign-in); a fixed sentence per refusal code, never the server's text; no session is minted here.
|
|
17
|
+
*/
|
|
18
|
+
exports.FIRST_ENROL_ENROLL_URL = '/api/auth/first-enrol/enroll';
|
|
19
|
+
exports.FIRST_ENROL_CONFIRM_URL = '/api/auth/first-enrol/confirm';
|
|
20
|
+
/** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
|
|
21
|
+
exports.PER_APP_SENTENCE = 'This app has its own authenticator entry: an authenticator you set up for another app or site does not carry over, so you add one here once.';
|
|
22
|
+
exports.DONE_SENTENCE = 'Your authenticator is set. Sign in with your password and the code from your app.';
|
|
23
|
+
exports.SIGN_IN_AGAIN_SENTENCE = 'This setup timed out or the sign-in expired. Sign in again to continue.';
|
|
24
|
+
const REFUSAL_COPY = {
|
|
25
|
+
CODE_INVALID: 'That code did not work. Check the 6 digits in your authenticator app and try again.',
|
|
26
|
+
TOO_MANY: 'Too many attempts. Wait a few minutes and sign in again.',
|
|
27
|
+
ENROLMENT_RESTART: 'This setup timed out. Reload the page to get a new code to scan.',
|
|
28
|
+
UNAVAILABLE: 'We could not reach the sign-in service. Nothing was changed. Try again in a moment.',
|
|
29
|
+
};
|
|
30
|
+
const FALLBACK_COPY = 'Setup was not completed. Try again.';
|
|
31
|
+
const readJson = async (res) => res.json().catch(() => null);
|
|
32
|
+
function FirstEnrolPanel({ fetchImpl, signInHref = '/account-auth/login', onSignInAgain, } = {}) {
|
|
33
|
+
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
34
|
+
const [stage, setStage] = (0, react_1.useState)('starting');
|
|
35
|
+
const [enroll, setEnroll] = (0, react_1.useState)(null);
|
|
36
|
+
const [code, setCode] = (0, react_1.useState)('');
|
|
37
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
38
|
+
const [codes, setCodes] = (0, react_1.useState)(null);
|
|
39
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
40
|
+
const started = (0, react_1.useRef)(false);
|
|
41
|
+
const toSignIn = () => {
|
|
42
|
+
if (onSignInAgain)
|
|
43
|
+
onSignInAgain(signInHref);
|
|
44
|
+
else
|
|
45
|
+
window.location.href = signInHref;
|
|
46
|
+
};
|
|
47
|
+
async function begin() {
|
|
48
|
+
setBusy(true);
|
|
49
|
+
setError(null);
|
|
50
|
+
try {
|
|
51
|
+
const res = await doFetch(exports.FIRST_ENROL_ENROLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ friendly_name: 'This device' }) });
|
|
52
|
+
if (res.status === 401)
|
|
53
|
+
return toSignIn(); // a 401 is never a message box
|
|
54
|
+
const body = await readJson(res);
|
|
55
|
+
if (res.ok && body?.data?.secret && body?.data?.otpauth_uri && Number.isInteger(body?.data?.enrollment_id)) {
|
|
56
|
+
setEnroll({ secret: body.data.secret, otpauth_uri: body.data.otpauth_uri, enrollment_id: body.data.enrollment_id });
|
|
57
|
+
setStage('enrolling');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
setError(REFUSAL_COPY[body?.code] ?? FALLBACK_COPY);
|
|
61
|
+
setStage('failed');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
setError(REFUSAL_COPY.UNAVAILABLE);
|
|
65
|
+
setStage('failed');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
(0, react_1.useEffect)(() => {
|
|
72
|
+
if (started.current)
|
|
73
|
+
return;
|
|
74
|
+
started.current = true;
|
|
75
|
+
void begin();
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, []);
|
|
78
|
+
async function confirm() {
|
|
79
|
+
if (!enroll || !/^\d{6}$/.test(code)) {
|
|
80
|
+
setError('Enter the 6-digit code from your app.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setBusy(true);
|
|
84
|
+
setError(null);
|
|
85
|
+
try {
|
|
86
|
+
const res = await doFetch(exports.FIRST_ENROL_CONFIRM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }) });
|
|
87
|
+
if (res.status === 401)
|
|
88
|
+
return toSignIn();
|
|
89
|
+
const body = await readJson(res);
|
|
90
|
+
if (res.ok && body?.confirmed === true) {
|
|
91
|
+
setEnroll(null);
|
|
92
|
+
setCode('');
|
|
93
|
+
setCodes(Array.isArray(body.codes) ? body.codes.filter((c) => typeof c === 'string') : null);
|
|
94
|
+
setStage('done');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setCode('');
|
|
98
|
+
setError(REFUSAL_COPY[body?.code] ?? FALLBACK_COPY);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
setError(REFUSAL_COPY.UNAVAILABLE);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
setBusy(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stage === 'done') {
|
|
108
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "first-enrol-done", role: "status", children: [(0, jsx_runtime_1.jsx)("p", { children: exports.DONE_SENTENCE }), codes && codes.length > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { children: "Save these recovery codes somewhere safe. Each one works once, and they are not shown again." }), (0, jsx_runtime_1.jsx)("ul", { "data-testid": "first-enrol-codes", children: codes.map((c) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("code", { children: c }) }, c))) })] })) : ((0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-no-codes", children: "We could not show your recovery codes. You can create new ones after you sign in." })), (0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("a", { href: signInHref, children: "Sign in" }) })] }));
|
|
109
|
+
}
|
|
110
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "first-enrol-panel", style: { display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }, children: [(0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-per-app", children: exports.PER_APP_SENTENCE }), stage === 'starting' ? (0, jsx_runtime_1.jsx)("p", { "data-testid": "first-enrol-starting", children: "Setting up\u2026" }) : null, stage === 'enrolling' && enroll ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { 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": "first-enrol-secret", style: { wordBreak: 'break-all' }, children: enroll.secret })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: (e) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
void confirm();
|
|
113
|
+
}, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "first-enrol-code", children: "Enter the 6-digit code from your app:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("input", { id: "first-enrol-code", "data-testid": "first-enrol-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": "first-enrol-confirm", disabled: busy || code.length !== 6, children: "Confirm" })] })] })) : null, error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "first-enrol-error", children: error })) : null, stage === 'failed' ? ((0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("a", { href: signInHref, children: "Sign in again" }) })) : null] }));
|
|
114
|
+
}
|
|
115
|
+
exports.default = FirstEnrolPanel;
|
|
@@ -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
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -13,15 +13,8 @@
|
|
|
13
13
|
* @since auth-ready-v2
|
|
14
14
|
*/
|
|
15
15
|
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
*
|
|
19
|
-
* Returns the current session information including:
|
|
20
|
-
* - User details
|
|
21
|
-
* - Token expiry status
|
|
22
|
-
* - Session validity
|
|
23
|
-
*/
|
|
24
|
-
export declare function GET(req: NextRequest): Promise<NextResponse<{}>>;
|
|
16
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
17
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
25
18
|
/**
|
|
26
19
|
* POST /api/auth/session - Update session METADATA
|
|
27
20
|
*
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.GET =
|
|
17
|
+
exports.GET = void 0;
|
|
18
18
|
exports.POST = POST;
|
|
19
|
+
const no_store_1 = require("../../lib/no-store");
|
|
19
20
|
const server_1 = require("next/server");
|
|
20
21
|
const auth_1 = require("../../server/auth");
|
|
21
22
|
const session_store_1 = require("../../lib/session-store");
|
|
@@ -29,7 +30,7 @@ const token_log_id_1 = require("../../lib/token-log-id");
|
|
|
29
30
|
* - Token expiry status
|
|
30
31
|
* - Session validity
|
|
31
32
|
*/
|
|
32
|
-
async function
|
|
33
|
+
async function getHandler(req) {
|
|
33
34
|
try {
|
|
34
35
|
const authSession = await (0, auth_1.getSession)(req);
|
|
35
36
|
if (!authSession) {
|
|
@@ -116,6 +117,8 @@ async function GET(req) {
|
|
|
116
117
|
}, { status: 500 });
|
|
117
118
|
}
|
|
118
119
|
}
|
|
120
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
121
|
+
exports.GET = (0, no_store_1.noStore)(getHandler);
|
|
119
122
|
/**
|
|
120
123
|
* The fields this route USED to accept from the request body and must never accept
|
|
121
124
|
* again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
|
|
@@ -13,57 +13,6 @@
|
|
|
13
13
|
* @version 2.0.0
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
|
-
import { NextRequest
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* GET /api/session/viability - Check if session is viable
|
|
20
|
-
*
|
|
21
|
-
* Returns:
|
|
22
|
-
* - viable: boolean - Whether the session can be used
|
|
23
|
-
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
24
|
-
* - expiresIn: number - Seconds until token expires
|
|
25
|
-
*/
|
|
26
|
-
export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
27
|
-
viable: boolean;
|
|
28
|
-
needsRefresh: boolean;
|
|
29
|
-
authenticated: boolean;
|
|
30
|
-
revocationStatus: RevocationStatus;
|
|
31
|
-
unavailable: boolean;
|
|
32
|
-
error: string;
|
|
33
|
-
code: string;
|
|
34
|
-
}> | NextResponse<{
|
|
35
|
-
viable: boolean;
|
|
36
|
-
needsRefresh: boolean;
|
|
37
|
-
authenticated: boolean;
|
|
38
|
-
reason: string;
|
|
39
|
-
}> | NextResponse<{
|
|
40
|
-
tenantRequiresTwoFactor: boolean;
|
|
41
|
-
userHasCompletedTenantTwoFactorRequirements: boolean;
|
|
42
|
-
userStillNeedsTwoFactor: boolean;
|
|
43
|
-
tenantAllowed2FAMethods: string[] | null;
|
|
44
|
-
nextStep: "enroll_mfa" | "mfa_verify" | null | undefined;
|
|
45
|
-
requires2FA: boolean;
|
|
46
|
-
twoFactorComplete: boolean;
|
|
47
|
-
accessTokenExpired: boolean;
|
|
48
|
-
expiresAt: string;
|
|
49
|
-
roles: string[];
|
|
50
|
-
clientId: string | null;
|
|
51
|
-
forceInvalid: boolean;
|
|
52
|
-
unavailable: boolean;
|
|
53
|
-
revoked: boolean;
|
|
54
|
-
viable: boolean;
|
|
55
|
-
needsRefresh: boolean;
|
|
56
|
-
expiresIn: number;
|
|
57
|
-
hasRefreshToken: boolean;
|
|
58
|
-
authenticated: boolean;
|
|
59
|
-
sessionTokenId: string | null;
|
|
60
|
-
revocationStatus: RevocationStatus;
|
|
61
|
-
}> | NextResponse<{
|
|
62
|
-
viable: boolean;
|
|
63
|
-
needsRefresh: boolean;
|
|
64
|
-
authenticated: boolean;
|
|
65
|
-
revocationStatus: RevocationStatus;
|
|
66
|
-
error: string;
|
|
67
|
-
code: string;
|
|
68
|
-
correlationId: string;
|
|
69
|
-
}>>;
|
|
16
|
+
import { NextRequest } from 'next/server';
|
|
17
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
18
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|