@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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2592 slice 1: the exposure guard for better-auth GHSA-965c-763c-88jm (critical, CVSS 9.1; affected >=1.4.0-beta.18 <1.7.7).
|
|
3
|
+
*
|
|
4
|
+
* "OAuth state can be used as a Magic Link to sign in as another user." It needs ALL of: the Magic Link plugin enabled, an OAuth provider, and OAuth state
|
|
5
|
+
* stored with `account.storeStateStrategy: "database"` (better-auth's default when a database OR secondaryStorage exists). This package has secondaryStorage,
|
|
6
|
+
* so what keeps it unexposed is, today:
|
|
7
|
+
* 1. `account.storeStateStrategy: 'cookie'` is pinned in better-auth.ts (the advisory: cookie-backed state blocks the path), and
|
|
8
|
+
* 2. the magic-link plugin registers ONLY when a consumer passes `opts.magicLink`.
|
|
9
|
+
* Nothing in this package fixes the flaw; the fix is better-auth >= 1.7.7 (PAY-2592, spike pending). Until then a consumer that turns the plugin on is told, loudly,
|
|
10
|
+
* once per process.
|
|
11
|
+
*
|
|
12
|
+
* test/next-mvp-pay-2592-exposure-pins.test.cjs holds both conditions and this warning.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const MAGIC_LINK_ADVISORY = 'GHSA-965c-763c-88jm';
|
|
18
|
+
export const MAGIC_LINK_FIX_VERSION = '1.7.7';
|
|
19
|
+
|
|
20
|
+
/** The installed better-auth version, read from its own package.json (the package does not export './package.json'), or null when it cannot be found. */
|
|
21
|
+
export function installedBetterAuthVersion(): string | null {
|
|
22
|
+
try {
|
|
23
|
+
let dir = dirname(require.resolve('better-auth'));
|
|
24
|
+
for (let i = 0; i < 8; i++) {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string };
|
|
27
|
+
if (pkg.name === 'better-auth' && typeof pkg.version === 'string') return pkg.version;
|
|
28
|
+
} catch {
|
|
29
|
+
/* no package.json at this level */
|
|
30
|
+
}
|
|
31
|
+
const up = dirname(dir);
|
|
32
|
+
if (up === dir) break;
|
|
33
|
+
dir = up;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
/* unresolvable */
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when `version` is strictly below `floor` (numeric major.minor.patch; a pre-release suffix is ignored). An unparseable version is NOT proven fixed, so it counts as below. */
|
|
42
|
+
export function versionBelow(version: string | null, floor: string): boolean {
|
|
43
|
+
const parse = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number) ?? null;
|
|
44
|
+
const a = version ? parse(version) : null;
|
|
45
|
+
const b = parse(floor);
|
|
46
|
+
if (!a || !b) return true;
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if (a[i] !== b[i]) return a[i] < b[i];
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Warn when the magic-link plugin is enabled on a better-auth below the fix. Returns whether it warned (so a test can assert it). `warn` and `version` are
|
|
55
|
+
* injectable for the same reason.
|
|
56
|
+
*/
|
|
57
|
+
export function warnIfMagicLinkBelowFix(
|
|
58
|
+
magicLinkEnabled: boolean,
|
|
59
|
+
warn: (msg: string) => void = (m) => console.warn(m),
|
|
60
|
+
version: string | null = installedBetterAuthVersion(),
|
|
61
|
+
): boolean {
|
|
62
|
+
if (!magicLinkEnabled) return false;
|
|
63
|
+
if (!versionBelow(version, MAGIC_LINK_FIX_VERSION)) return false;
|
|
64
|
+
warn(
|
|
65
|
+
`[BETTER_AUTH] WARNING ${MAGIC_LINK_ADVISORY} (critical): the magic-link plugin is enabled on better-auth ${version ?? '(version unknown)'}, below the fix ${MAGIC_LINK_FIX_VERSION}. ` +
|
|
66
|
+
`This package keeps account.storeStateStrategy='cookie', which blocks the attack; do NOT change that while magic link is on, and upgrade better-auth (PAY-2592).`,
|
|
67
|
+
);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -34,6 +34,11 @@ import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
|
|
|
34
34
|
import { SESSION_TTL } from '../lib/session-store';
|
|
35
35
|
import { tokenLogId } from '../lib/token-log-id';
|
|
36
36
|
import { errForLog } from '../lib/log-safe';
|
|
37
|
+
import { warnIfMagicLinkBelowFix } from './better-auth-exposure';
|
|
38
|
+
import { noStore } from '../lib/no-store';
|
|
39
|
+
|
|
40
|
+
/** PAY-2592: the magic-link advisory warning fires once per process. */
|
|
41
|
+
let magicLinkAdvisoryWarned = false;
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
44
|
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
@@ -589,6 +594,13 @@ export function createBetterAuthInstance(
|
|
|
589
594
|
assertAppSlugMatchesIdp(idpConfig.clientSlug);
|
|
590
595
|
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
591
596
|
|
|
597
|
+
// 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
|
|
598
|
+
// package unexposed is account.storeStateStrategy='cookie' below plus the plugin being opt-in; see better-auth-exposure.ts.
|
|
599
|
+
if (opts.magicLink && !magicLinkAdvisoryWarned) {
|
|
600
|
+
magicLinkAdvisoryWarned = true;
|
|
601
|
+
warnIfMagicLinkBelowFix(true);
|
|
602
|
+
}
|
|
603
|
+
|
|
592
604
|
// 185121 line 1: name the secret this instance PINNED, as a hash, never the
|
|
593
605
|
// secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
|
|
594
606
|
// caused by a divergent secret is only diagnosable if every instance
|
|
@@ -1596,7 +1608,7 @@ export function authErrorCodeFromLocation(location: string): string | null {
|
|
|
1596
1608
|
* ```
|
|
1597
1609
|
*/
|
|
1598
1610
|
export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
|
|
1599
|
-
|
|
1611
|
+
const get = async function GET(request: Request): Promise<Response> {
|
|
1600
1612
|
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
1601
1613
|
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
1602
1614
|
// hook that sees the token) and the exchange below (which forwards it).
|
|
@@ -1711,4 +1723,6 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1711
1723
|
return response;
|
|
1712
1724
|
});
|
|
1713
1725
|
};
|
|
1726
|
+
// PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
|
|
1727
|
+
return noStore(get);
|
|
1714
1728
|
}
|
package/src/auth/route-config.ts
CHANGED
|
@@ -92,6 +92,15 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
|
|
|
92
92
|
allowDuringCircuitBreakerOpen: true,
|
|
93
93
|
requiresRateLimit: false
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
// PAY-2804: the exact path. '/api/health/*' matches startsWith('/api/health/'), which the bare
|
|
97
|
+
// '/api/health' (the ALB check) does not, so without this entry it sat behind sign-in.
|
|
98
|
+
pattern: '/api/health',
|
|
99
|
+
description: 'Health check endpoint (ALB target; anonymous, state only)',
|
|
100
|
+
allowDuringCircuitBreakerOpen: true,
|
|
101
|
+
// An ALB check comes from few IPs: a rate limit here would answer 429 and get tasks pulled (NightHawk 89179).
|
|
102
|
+
requiresRateLimit: false
|
|
103
|
+
},
|
|
95
104
|
{
|
|
96
105
|
pattern: '/api/health/*',
|
|
97
106
|
description: 'Health check endpoints',
|
|
@@ -51,6 +51,11 @@ interface Props {
|
|
|
51
51
|
* default would 404 a generated app's user right after a good sign-in).
|
|
52
52
|
*/
|
|
53
53
|
enrollHref?: string;
|
|
54
|
+
/**
|
|
55
|
+
* AUTH-045: where a person is sent when the IdP says they must ENROL a factor and they have NO session (the door parked an httpOnly enrolment cookie).
|
|
56
|
+
* Distinct from enrollHref (which needs a session). NO DEFAULT: without it the form says so instead of redrawing the code box.
|
|
57
|
+
*/
|
|
58
|
+
firstEnrolHref?: string;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
const inputStyle: React.CSSProperties = {
|
|
@@ -73,7 +78,7 @@ export function resolveDestination(explicit?: string): string {
|
|
|
73
78
|
return safeCallbackPath(authorizeResumeHref(returnUrl), safeCallbackPath(raw, '/dashboard'));
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }: Props) {
|
|
81
|
+
export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref, firstEnrolHref }: Props) {
|
|
77
82
|
const [step, setStep] = useState<'credentials' | 'code'>('credentials');
|
|
78
83
|
const [email, setEmail] = useState('');
|
|
79
84
|
const [password, setPassword] = useState('');
|
|
@@ -103,6 +108,18 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
|
|
|
103
108
|
else window.location.href = destination;
|
|
104
109
|
return;
|
|
105
110
|
}
|
|
111
|
+
if (r.kind === 'enrol_required') {
|
|
112
|
+
// 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
|
|
113
|
+
// the code box (the 14 Nov walk looped there). A host that gave no enrolment page gets a sentence, not a silent loop.
|
|
114
|
+
if (firstEnrolHref) {
|
|
115
|
+
if (onSignedIn) onSignedIn(firstEnrolHref);
|
|
116
|
+
else window.location.href = firstEnrolHref;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
setError('This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner.');
|
|
120
|
+
setErrorCode('sign_in_failed');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
106
123
|
if (r.kind === 'two_factor') {
|
|
107
124
|
setCodeRequest(r.codeRequest);
|
|
108
125
|
setDevCode(r.devCode);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { QrCode } from './QrCode';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 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.
|
|
8
|
+
* Ported from the portal's EnrolRequired (PAY-2798 part 2). It talks ONLY to the two routes that read the httpOnly enrolment cookie:
|
|
9
|
+
* POST /api/auth/first-enrol/enroll (started on mount: the person already asked for this by signing in)
|
|
10
|
+
* POST /api/auth/first-enrol/confirm (the 6-digit code from the app) -> the recovery codes, then "sign in again"
|
|
11
|
+
*
|
|
12
|
+
* 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
|
|
13
|
+
* 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.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const FIRST_ENROL_ENROLL_URL = '/api/auth/first-enrol/enroll';
|
|
17
|
+
export const FIRST_ENROL_CONFIRM_URL = '/api/auth/first-enrol/confirm';
|
|
18
|
+
|
|
19
|
+
/** BAPert 100969 row 6: one sentence that this app has its own authenticator entry (per-client factor, by design). */
|
|
20
|
+
export 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.';
|
|
21
|
+
export const DONE_SENTENCE = 'Your authenticator is set. Sign in with your password and the code from your app.';
|
|
22
|
+
export const SIGN_IN_AGAIN_SENTENCE = 'This setup timed out or the sign-in expired. Sign in again to continue.';
|
|
23
|
+
|
|
24
|
+
const REFUSAL_COPY: Record<string, string> = {
|
|
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
|
+
|
|
32
|
+
type Enrollment = { secret: string; otpauth_uri: string; enrollment_id: number };
|
|
33
|
+
type Stage = 'starting' | 'enrolling' | 'done' | 'failed';
|
|
34
|
+
|
|
35
|
+
const readJson = async (res: Response): Promise<any> => res.json().catch(() => null);
|
|
36
|
+
|
|
37
|
+
export function FirstEnrolPanel({
|
|
38
|
+
fetchImpl,
|
|
39
|
+
signInHref = '/account-auth/login',
|
|
40
|
+
onSignInAgain,
|
|
41
|
+
}: { fetchImpl?: typeof fetch; signInHref?: string; onSignInAgain?: (href: string) => void } = {}) {
|
|
42
|
+
const doFetch: typeof fetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
43
|
+
const [stage, setStage] = useState<Stage>('starting');
|
|
44
|
+
const [enroll, setEnroll] = useState<Enrollment | null>(null);
|
|
45
|
+
const [code, setCode] = useState('');
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [codes, setCodes] = useState<string[] | null>(null);
|
|
48
|
+
const [busy, setBusy] = useState(false);
|
|
49
|
+
const started = useRef(false);
|
|
50
|
+
|
|
51
|
+
const toSignIn = () => {
|
|
52
|
+
if (onSignInAgain) onSignInAgain(signInHref);
|
|
53
|
+
else window.location.href = signInHref;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
async function begin() {
|
|
57
|
+
setBusy(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
try {
|
|
60
|
+
const res = await doFetch(FIRST_ENROL_ENROLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ friendly_name: 'This device' }) });
|
|
61
|
+
if (res.status === 401) return toSignIn(); // a 401 is never a message box
|
|
62
|
+
const body = await readJson(res);
|
|
63
|
+
if (res.ok && body?.data?.secret && body?.data?.otpauth_uri && Number.isInteger(body?.data?.enrollment_id)) {
|
|
64
|
+
setEnroll({ secret: body.data.secret, otpauth_uri: body.data.otpauth_uri, enrollment_id: body.data.enrollment_id });
|
|
65
|
+
setStage('enrolling');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setError(REFUSAL_COPY[body?.code as string] ?? FALLBACK_COPY);
|
|
69
|
+
setStage('failed');
|
|
70
|
+
} catch {
|
|
71
|
+
setError(REFUSAL_COPY.UNAVAILABLE);
|
|
72
|
+
setStage('failed');
|
|
73
|
+
} finally {
|
|
74
|
+
setBusy(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (started.current) return;
|
|
80
|
+
started.current = true;
|
|
81
|
+
void begin();
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
async function confirm() {
|
|
86
|
+
if (!enroll || !/^\d{6}$/.test(code)) {
|
|
87
|
+
setError('Enter the 6-digit code from your app.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setBusy(true);
|
|
91
|
+
setError(null);
|
|
92
|
+
try {
|
|
93
|
+
const res = await doFetch(FIRST_ENROL_CONFIRM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }) });
|
|
94
|
+
if (res.status === 401) return toSignIn();
|
|
95
|
+
const body = await readJson(res);
|
|
96
|
+
if (res.ok && body?.confirmed === true) {
|
|
97
|
+
setEnroll(null);
|
|
98
|
+
setCode('');
|
|
99
|
+
setCodes(Array.isArray(body.codes) ? body.codes.filter((c: unknown) => typeof c === 'string') : null);
|
|
100
|
+
setStage('done');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setCode('');
|
|
104
|
+
setError(REFUSAL_COPY[body?.code as string] ?? FALLBACK_COPY);
|
|
105
|
+
} catch {
|
|
106
|
+
setError(REFUSAL_COPY.UNAVAILABLE);
|
|
107
|
+
} finally {
|
|
108
|
+
setBusy(false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (stage === 'done') {
|
|
113
|
+
return (
|
|
114
|
+
<div data-testid="first-enrol-done" role="status">
|
|
115
|
+
<p>{DONE_SENTENCE}</p>
|
|
116
|
+
{codes && codes.length > 0 ? (
|
|
117
|
+
<>
|
|
118
|
+
<p>Save these recovery codes somewhere safe. Each one works once, and they are not shown again.</p>
|
|
119
|
+
<ul data-testid="first-enrol-codes">
|
|
120
|
+
{codes.map((c) => (
|
|
121
|
+
<li key={c}>
|
|
122
|
+
<code>{c}</code>
|
|
123
|
+
</li>
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
</>
|
|
127
|
+
) : (
|
|
128
|
+
<p data-testid="first-enrol-no-codes">We could not show your recovery codes. You can create new ones after you sign in.</p>
|
|
129
|
+
)}
|
|
130
|
+
<p>
|
|
131
|
+
<a href={signInHref}>Sign in</a>
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div data-testid="first-enrol-panel" style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
|
|
139
|
+
<p data-testid="first-enrol-per-app">{PER_APP_SENTENCE}</p>
|
|
140
|
+
{stage === 'starting' ? <p data-testid="first-enrol-starting">Setting up…</p> : null}
|
|
141
|
+
{stage === 'enrolling' && enroll ? (
|
|
142
|
+
<>
|
|
143
|
+
<p>Open your authenticator app and scan this code. A phone camera works too.</p>
|
|
144
|
+
<QrCode value={enroll.otpauth_uri} />
|
|
145
|
+
<div>
|
|
146
|
+
<div>Can't scan? Enter this code manually:</div>
|
|
147
|
+
<code data-testid="first-enrol-secret" style={{ wordBreak: 'break-all' }}>
|
|
148
|
+
{enroll.secret}
|
|
149
|
+
</code>
|
|
150
|
+
</div>
|
|
151
|
+
<form
|
|
152
|
+
onSubmit={(e) => {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
void confirm();
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<label htmlFor="first-enrol-code">Enter the 6-digit code from your app:</label>
|
|
158
|
+
<br />
|
|
159
|
+
<input
|
|
160
|
+
id="first-enrol-code"
|
|
161
|
+
data-testid="first-enrol-code"
|
|
162
|
+
inputMode="numeric"
|
|
163
|
+
autoComplete="one-time-code"
|
|
164
|
+
maxLength={6}
|
|
165
|
+
value={code}
|
|
166
|
+
disabled={busy}
|
|
167
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
168
|
+
/>{' '}
|
|
169
|
+
<button type="submit" data-testid="first-enrol-confirm" disabled={busy || code.length !== 6}>
|
|
170
|
+
Confirm
|
|
171
|
+
</button>
|
|
172
|
+
</form>
|
|
173
|
+
</>
|
|
174
|
+
) : null}
|
|
175
|
+
{error ? (
|
|
176
|
+
<p role="alert" data-testid="first-enrol-error">
|
|
177
|
+
{error}
|
|
178
|
+
</p>
|
|
179
|
+
) : null}
|
|
180
|
+
{stage === 'failed' ? (
|
|
181
|
+
<p>
|
|
182
|
+
<a href={signInHref}>Sign in again</a>
|
|
183
|
+
</p>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default FirstEnrolPanel;
|
|
@@ -24,6 +24,8 @@ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
|
|
|
24
24
|
export type CredentialSignInResult =
|
|
25
25
|
| { kind: 'success' }
|
|
26
26
|
| { kind: 'two_factor'; codeRequest: CodeRequest; devCode: string | null; nextStep: NextStep }
|
|
27
|
+
/** 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. */
|
|
28
|
+
| { kind: 'enrol_required' }
|
|
27
29
|
| { kind: 'rate_limited'; message: string; code: ErrorCode }
|
|
28
30
|
| { kind: 'error'; message: string; code: ErrorCode; offerEmailCode: boolean };
|
|
29
31
|
|
|
@@ -104,7 +106,17 @@ export async function credentialSignIn(
|
|
|
104
106
|
const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
|
|
105
107
|
return { kind: 'rate_limited', message: tooManyAttemptsMessage(body?.retryAfter, code), code };
|
|
106
108
|
}
|
|
107
|
-
if (body?.requiresTwoFactor)
|
|
109
|
+
if (body?.requiresTwoFactor) {
|
|
110
|
+
// 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
|
|
111
|
+
// 14 Nov showed the form redrawing the code box forever. The door's enrolmentRequired flag is the only signal for enrolment, and anything the
|
|
112
|
+
// client does not understand is a VISIBLE error, never a redraw.
|
|
113
|
+
const raw = body?.nextStep;
|
|
114
|
+
const unexpected: CredentialSignInResult = { kind: 'error', message: MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
|
|
115
|
+
if (body?.enrolmentRequired === true) return raw === 'enroll_mfa' ? { kind: 'enrol_required' } : unexpected;
|
|
116
|
+
if (raw !== undefined && raw !== null && raw !== 'enroll_mfa' && raw !== 'mfa_verify') return unexpected;
|
|
117
|
+
if (args.code && raw === 'enroll_mfa') return unexpected; // a CODE turn told "enrol" without the flag is an older door: never a redraw
|
|
118
|
+
return { kind: 'two_factor', codeRequest: readCodeRequest(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
|
|
119
|
+
}
|
|
108
120
|
if (!res.ok || body?.success !== true) {
|
|
109
121
|
const offerEmailCode = body?.offerEmailCode === true;
|
|
110
122
|
if (isSignInFailureCode(body?.code)) return { kind: 'error', message: signInFailureMessage(body.code) as string, code: body.code, offerEmailCode };
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
|
|
17
|
+
export const ENROL_COOKIE = 'payez_enrol';
|
|
18
|
+
export const ENROL_COOKIE_MAX_AGE = 600;
|
|
19
|
+
export type EnrolStep = 'enroll' | 'confirm';
|
|
20
|
+
export const ENROL_STEPS: readonly EnrolStep[] = ['enroll', 'confirm'];
|
|
21
|
+
|
|
22
|
+
export type Fetcher = (url: string, init: RequestInit & { headers: Record<string, string> }) => Promise<Response>;
|
|
23
|
+
export type CoreDeps = { fetchImpl: Fetcher; idp: string; clientKey: string };
|
|
24
|
+
/** `cookie`: what the route does with the enrolment cookie. */
|
|
25
|
+
export type CoreResult = { status: number; body: Record<string, unknown>; cookie: 'clear' | 'keep' };
|
|
26
|
+
|
|
27
|
+
const SENTENCE_UNAVAILABLE = 'We could not reach the sign-in service. Nothing was changed. Try again in a moment.';
|
|
28
|
+
|
|
29
|
+
export function enrolIdpPath(step: EnrolStep): string {
|
|
30
|
+
return step === 'enroll' ? '/api/Account/mfa/totp/enroll' : '/api/Account/mfa/totp/confirm';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The body each step may carry: enroll { friendly_name? }, confirm { enrollment_id, code }. null = nothing is sent. */
|
|
34
|
+
export function enrolBodyFor(step: EnrolStep, raw: unknown): Record<string, unknown> | null {
|
|
35
|
+
const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
36
|
+
if (step === 'enroll') {
|
|
37
|
+
const n = src.friendly_name;
|
|
38
|
+
return typeof n === 'string' && n.trim() !== '' && n.length <= 64 ? { friendly_name: n.trim() } : {};
|
|
39
|
+
}
|
|
40
|
+
const id = src.enrollment_id;
|
|
41
|
+
const code = src.code;
|
|
42
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) return null;
|
|
43
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) return null;
|
|
44
|
+
return { enrollment_id: id, code };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const unwrap = (b: unknown): Record<string, unknown> | null => {
|
|
48
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) return null;
|
|
49
|
+
const o = b as Record<string, unknown>;
|
|
50
|
+
return o.data && typeof o.data === 'object' && !Array.isArray(o.data) ? (o.data as Record<string, unknown>) : o;
|
|
51
|
+
};
|
|
52
|
+
const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : null);
|
|
53
|
+
|
|
54
|
+
export function parseEnrol(body: unknown): { secret: string; otpauthUri: string; enrollmentId: number } | null {
|
|
55
|
+
const d = unwrap(body);
|
|
56
|
+
const secret = str(d?.secret);
|
|
57
|
+
const uri = str(d?.otpauth_uri);
|
|
58
|
+
const id = d?.enrollment_id;
|
|
59
|
+
if (!d || !secret || !uri || !uri.startsWith('otpauth://totp/') || typeof id !== 'number' || !Number.isInteger(id)) return null;
|
|
60
|
+
return { secret, otpauthUri: uri, enrollmentId: id };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseConfirmed(body: unknown): boolean {
|
|
64
|
+
return unwrap(body)?.confirmed === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseCodes(body: unknown): string[] | null {
|
|
68
|
+
const d = unwrap(body);
|
|
69
|
+
const codes = d?.codes;
|
|
70
|
+
if (!Array.isArray(codes) || codes.length === 0 || !codes.every((c) => typeof c === 'string' && c !== '')) return null;
|
|
71
|
+
return codes as string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function codesForDisplay(codes: readonly string[]): string[] {
|
|
75
|
+
return codes.map((c) => c.trim()).filter((c) => c !== '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 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. */
|
|
79
|
+
export function enrolRefusal(step: EnrolStep, status: number, body: unknown): { status: number; code: string; text: string } | 'sign-in' {
|
|
80
|
+
if (status === 401 || status === 403) return 'sign-in';
|
|
81
|
+
const c = (() => {
|
|
82
|
+
const b = body as { code?: unknown; error?: { code?: unknown } | string } | null;
|
|
83
|
+
if (b && typeof b.code === 'string') return b.code;
|
|
84
|
+
if (b && typeof b.error === 'object' && b.error && typeof (b.error as { code?: unknown }).code === 'string') return (b.error as { code: string }).code;
|
|
85
|
+
return null;
|
|
86
|
+
})();
|
|
87
|
+
if (status === 429) return { status: 429, code: 'TOO_MANY', text: 'Too many attempts. Wait a few minutes and try again.' };
|
|
88
|
+
if (step === 'confirm' && status >= 400 && status < 500) {
|
|
89
|
+
if (c === 'enrollment_not_found') return { status: 400, code: 'ENROLMENT_RESTART', text: 'This setup timed out. Reload the page to get a new code to scan.' };
|
|
90
|
+
return { status: 400, code: 'CODE_INVALID', text: 'That code did not work. Check the 6 digits in your authenticator app and try again.' };
|
|
91
|
+
}
|
|
92
|
+
return { status: 502, code: 'UNAVAILABLE', text: SENTENCE_UNAVAILABLE };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const headers = (token: string, clientKey: string): Record<string, string> => ({
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
Accept: 'application/json',
|
|
98
|
+
Authorization: `Bearer ${token}`,
|
|
99
|
+
'X-Client-Id': clientKey,
|
|
100
|
+
});
|
|
101
|
+
const asJson = async (res: Response) => res.json().catch(() => null);
|
|
102
|
+
|
|
103
|
+
export async function runEnrolStep(deps: CoreDeps, input: { step: EnrolStep; token: string; body: unknown }): Promise<CoreResult> {
|
|
104
|
+
const body = enrolBodyFor(input.step, input.body);
|
|
105
|
+
if (body === null) return { status: 400, body: { success: false, code: 'INVALID_REQUEST' }, cookie: 'keep' };
|
|
106
|
+
|
|
107
|
+
let res: Response;
|
|
108
|
+
try {
|
|
109
|
+
res = await deps.fetchImpl(`${deps.idp}${enrolIdpPath(input.step)}`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: headers(input.token, deps.clientKey),
|
|
112
|
+
body: JSON.stringify(body),
|
|
113
|
+
cache: 'no-store',
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: SENTENCE_UNAVAILABLE }, cookie: 'keep' };
|
|
117
|
+
}
|
|
118
|
+
const answer = await asJson(res);
|
|
119
|
+
|
|
120
|
+
if (res.status < 200 || res.status >= 300) {
|
|
121
|
+
const refusal = enrolRefusal(input.step, res.status, answer);
|
|
122
|
+
if (refusal === 'sign-in') return { status: 401, body: { success: false, code: 'SIGN_IN_AGAIN' }, cookie: 'clear' };
|
|
123
|
+
// A mistyped code must not cost a whole sign-in (the attempt limit bounds it); every other refusal ends this enrolment.
|
|
124
|
+
return { status: refusal.status, body: { success: false, code: refusal.code, error: refusal.text }, cookie: refusal.code === 'CODE_INVALID' ? 'keep' : 'clear' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (input.step === 'enroll') {
|
|
128
|
+
const e = parseEnrol(answer);
|
|
129
|
+
if (!e) return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: 'We could not read the setup details. Nothing was changed. Reload the page.' }, cookie: 'clear' };
|
|
130
|
+
return { status: 200, body: { success: true, data: { secret: e.secret, otpauth_uri: e.otpauthUri, enrollment_id: e.enrollmentId } }, cookie: 'keep' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// confirm: the factor is confirmed; the answer carries the FULL pair, used here ONLY to fetch the recovery codes, then dropped.
|
|
134
|
+
if (!parseConfirmed(answer)) return { status: 502, body: { success: false, code: 'UNAVAILABLE', error: 'We could not confirm that. Try the code again.' }, cookie: 'keep' };
|
|
135
|
+
const d = unwrap(answer);
|
|
136
|
+
const full = str(d?.access_token);
|
|
137
|
+
let codes: string[] | null = null;
|
|
138
|
+
if (full) {
|
|
139
|
+
try {
|
|
140
|
+
const c = await deps.fetchImpl(`${deps.idp}/api/Account/mfa/backup-codes`, { method: 'POST', headers: headers(full, deps.clientKey), body: '{}', cache: 'no-store' });
|
|
141
|
+
const list = c.ok ? parseCodes(await asJson(c)) : null;
|
|
142
|
+
codes = list ? codesForDisplay(list) : null;
|
|
143
|
+
} catch {
|
|
144
|
+
codes = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { status: 200, body: { success: true, confirmed: true, codes }, cookie: 'clear' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── attempt limit: per enrolment cookie (in-memory, per instance), hard-capped ──
|
|
151
|
+
const WINDOW_MS = ENROL_COOKIE_MAX_AGE * 1000;
|
|
152
|
+
export const LIMIT_PER_COOKIE = 5;
|
|
153
|
+
const hits = new Map<string, number[]>();
|
|
154
|
+
/**
|
|
155
|
+
* HARD CAP (portal PAY-2913, BAPert 90048): the map never holds more than this many keys; past it the OLDEST-TOUCHED key is evicted.
|
|
156
|
+
* 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.
|
|
157
|
+
* 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).
|
|
158
|
+
*/
|
|
159
|
+
export const MAX_TRACKED_KEYS = 5000;
|
|
160
|
+
|
|
161
|
+
/** True when this key may attempt now; records the attempt. Keys are never the raw cookie (the route passes a digest). */
|
|
162
|
+
export function attemptAllowed(key: string, limit: number, now: number = Date.now()): boolean {
|
|
163
|
+
const recent = (hits.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
|
|
164
|
+
const allowed = recent.length < limit;
|
|
165
|
+
if (allowed) recent.push(now);
|
|
166
|
+
hits.delete(key); // re-insert at the end: Map order is last-touched order
|
|
167
|
+
hits.set(key, recent);
|
|
168
|
+
while (hits.size > MAX_TRACKED_KEYS) {
|
|
169
|
+
const oldest = hits.keys().next().value;
|
|
170
|
+
if (oldest === undefined) break;
|
|
171
|
+
hits.delete(oldest);
|
|
172
|
+
}
|
|
173
|
+
return allowed;
|
|
174
|
+
}
|
|
175
|
+
export function _resetAttempts(): void {
|
|
176
|
+
hits.clear();
|
|
177
|
+
}
|
|
178
|
+
export function _trackedKeys(): number {
|
|
179
|
+
return hits.size;
|
|
180
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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 const NO_STORE_VALUE = 'no-store';
|
|
12
|
+
|
|
13
|
+
export function withNoStoreHeader(res: Response): Response {
|
|
14
|
+
// Not a Response (no headers object): hand it back untouched. A Next route always returns a Response, so this only keeps a hand-made
|
|
15
|
+
// test double from being turned into an empty Response.
|
|
16
|
+
if (!res || typeof res !== 'object' || !(res as { headers?: unknown }).headers) return res;
|
|
17
|
+
try {
|
|
18
|
+
res.headers.set('Cache-Control', NO_STORE_VALUE);
|
|
19
|
+
return res;
|
|
20
|
+
} catch {
|
|
21
|
+
// Immutable headers (TypeError). Rebuild: same status, statusText, body and every header; Headers copies Set-Cookie entries intact.
|
|
22
|
+
const headers = new Headers(res.headers);
|
|
23
|
+
headers.set('Cache-Control', NO_STORE_VALUE);
|
|
24
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function noStore<A extends unknown[], R extends Response>(handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Response> {
|
|
29
|
+
return async (...args: A) => withNoStoreHeader(await handler(...args));
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { FirstEnrolPanel } from '../../components/mfa/FirstEnrolPanel';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 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
|
|
8
|
+
* 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
|
|
9
|
+
* ONLY the httpOnly enrolment cookie the credential door parked, and answer 401 without it (the panel then sends the person to sign-in).
|
|
10
|
+
* Not the signed-in enrolment page (/account-auth/enroll), which needs a session.
|
|
11
|
+
*/
|
|
12
|
+
export default function FirstEnrollPage() {
|
|
13
|
+
return (
|
|
14
|
+
<main style={{ maxWidth: 480, margin: '48px auto', padding: '0 16px' }}>
|
|
15
|
+
<h1>Secure your account</h1>
|
|
16
|
+
<FirstEnrolPanel />
|
|
17
|
+
</main>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* @since auth-ready-v2
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { noStore } from '../../lib/no-store';
|
|
16
17
|
import { NextRequest, NextResponse } from 'next/server';
|
|
17
18
|
import { getSession as getBetterAuthSession } from '../../server/auth';
|
|
18
19
|
import { getSession as getRedisSession, updateSession } from '../../lib/session-store';
|
|
@@ -27,7 +28,7 @@ import { tokenLogId } from '../../lib/token-log-id';
|
|
|
27
28
|
* - Token expiry status
|
|
28
29
|
* - Session validity
|
|
29
30
|
*/
|
|
30
|
-
|
|
31
|
+
async function getHandler(req: NextRequest) {
|
|
31
32
|
try {
|
|
32
33
|
const authSession = await getBetterAuthSession(req);
|
|
33
34
|
|
|
@@ -121,6 +122,9 @@ export async function GET(req: NextRequest) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
126
|
+
export const GET = noStore(getHandler);
|
|
127
|
+
|
|
124
128
|
/**
|
|
125
129
|
* The fields this route USED to accept from the request body and must never accept
|
|
126
130
|
* again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
|