@payez/next-mvp 4.4.6 → 4.4.7
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/dist/api-handlers/account/change-password.js +28 -6
- package/dist/auth/better-auth.d.ts +44 -14
- package/dist/auth/better-auth.js +92 -7
- package/dist/client/better-auth-client.d.ts +101 -956
- package/dist/config/env.d.ts +0 -2
- package/dist/config/env.js +0 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -1
- package/dist/lib/idp-client-config.js +8 -1
- package/dist/lib/never-in-response.d.ts +30 -0
- package/dist/lib/never-in-response.js +71 -0
- package/dist/lib/safe-error-response.d.ts +6 -6
- package/dist/lib/safe-error-response.js +6 -6
- package/dist/lib/two-factor-verify-failure.d.ts +36 -3
- package/dist/lib/two-factor-verify-failure.js +79 -2
- package/dist/middleware/create-middleware.d.ts +2 -0
- package/dist/middleware/create-middleware.js +36 -0
- package/dist/pages/login/page.js +5 -0
- package/dist/pages/verify-code/page.js +4 -2
- package/dist/routes/auth/settings.js +4 -1
- package/dist/server/auth.d.ts +36 -294
- package/dist/server/auth.js +22 -25
- package/dist/vibe/client.d.ts +132 -16
- package/dist/vibe/client.js +288 -82
- package/dist/vibe/document-unwrap.d.ts +50 -0
- package/dist/vibe/document-unwrap.js +78 -0
- package/dist/vibe/errors.d.ts +17 -0
- package/dist/vibe/errors.js +21 -1
- package/dist/vibe/generic.d.ts +2 -49
- package/dist/vibe/generic.js +17 -78
- package/dist/vibe/index.d.ts +1 -1
- package/dist/vibe/index.js +2 -1
- package/package.json +12 -2
- package/src/api-handlers/account/change-password.ts +31 -6
- package/src/auth/better-auth.ts +93 -8
- package/src/config/env.ts +0 -2
- package/src/index.ts +4 -0
- package/src/lib/idp-client-config.ts +8 -1
- package/src/lib/never-in-response.ts +64 -0
- package/src/lib/safe-error-response.ts +6 -6
- package/src/lib/two-factor-verify-failure.ts +86 -5
- package/src/middleware/create-middleware.ts +42 -0
- package/src/pages/login/page.tsx +4 -0
- package/src/pages/verify-code/page.tsx +4 -2
- package/src/routes/auth/settings.ts +5 -1
- package/src/server/auth.ts +23 -29
- package/src/vibe/client.ts +378 -91
- package/src/vibe/document-unwrap.ts +103 -0
- package/src/vibe/errors.ts +24 -0
- package/src/vibe/generic.ts +8 -100
- package/src/vibe/index.ts +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1529 runtime backstop: the signing secret and the OAuth provider client secrets must never be
|
|
3
|
+
* serialized into an HTTP response body.
|
|
4
|
+
*
|
|
5
|
+
* The build-time guard (test/secrets-never-in-response-source-guard.test.cjs) follows values through the
|
|
6
|
+
* source it can see. This is the layer for what it cannot see — a value that crosses a module boundary,
|
|
7
|
+
* a structuredClone / Object.assign copy, a helper in a consumer app. It tracks the secret by VALUE:
|
|
8
|
+
* the loader registers every resolved secret here at the moment it resolves it (applyEnvSecrets), and a
|
|
9
|
+
* response built with `secretSafeJson` / checked with `assertNoServerSecret` FAILS CLOSED — it throws,
|
|
10
|
+
* and the route answers 500 — if any registered value appears anywhere in the serialized body, under any
|
|
11
|
+
* key, at any depth, whatever the code that built it called it.
|
|
12
|
+
*
|
|
13
|
+
* Bounds, stated rather than implied:
|
|
14
|
+
* - Values shorter than MIN_SECRET_LENGTH are not registered. A short string matches innocent bodies and
|
|
15
|
+
* would turn this into an outage generator; real signing and provider secrets are far longer.
|
|
16
|
+
* - It protects responses that go through it. Adoption is per handler; the build-time guard is what
|
|
17
|
+
* covers the handlers that do not.
|
|
18
|
+
* - Nothing here ever logs, returns or throws a secret value.
|
|
19
|
+
*/
|
|
20
|
+
import { NextResponse } from 'next/server';
|
|
21
|
+
|
|
22
|
+
export const MIN_SECRET_LENGTH = 16;
|
|
23
|
+
|
|
24
|
+
const registered = new Set<string>();
|
|
25
|
+
|
|
26
|
+
/** Record a value that must never appear in a response body. Non-strings and short strings are ignored. */
|
|
27
|
+
export function markNeverInResponse(value: unknown): void {
|
|
28
|
+
if (typeof value !== 'string' || value.length < MIN_SECRET_LENGTH) return;
|
|
29
|
+
registered.add(value);
|
|
30
|
+
// JSON escapes some characters, so the serialized form of a secret containing `"` or `\` differs from
|
|
31
|
+
// the raw value. Register that form too, or such a secret would pass through a JSON body unseen.
|
|
32
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
33
|
+
if (escaped !== value) registered.add(escaped);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SecretInResponseError extends Error {
|
|
37
|
+
constructor(where: string) {
|
|
38
|
+
// The message names the route, never the value.
|
|
39
|
+
super(`[PAY-1529] Refused to serialize a server secret into the response of ${where}`);
|
|
40
|
+
this.name = 'SecretInResponseError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Throws SecretInResponseError if any registered secret appears in `body` once serialized. */
|
|
45
|
+
export function assertNoServerSecret(body: unknown, where: string): void {
|
|
46
|
+
if (registered.size === 0 || body === undefined || body === null) return;
|
|
47
|
+
let text: string;
|
|
48
|
+
try {
|
|
49
|
+
text = typeof body === 'string' ? body : JSON.stringify(body);
|
|
50
|
+
} catch {
|
|
51
|
+
// Unserializable: NextResponse.json would throw on it anyway. Fail closed rather than guess.
|
|
52
|
+
throw new SecretInResponseError(where);
|
|
53
|
+
}
|
|
54
|
+
if (!text) return;
|
|
55
|
+
for (const secret of registered) {
|
|
56
|
+
if (text.includes(secret)) throw new SecretInResponseError(where);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** NextResponse.json, refusing any body that carries a registered secret. */
|
|
61
|
+
export function secretSafeJson<T>(body: T, init: ResponseInit | undefined, where: string): NextResponse<T> {
|
|
62
|
+
assertNoServerSecret(body, where);
|
|
63
|
+
return NextResponse.json(body, init);
|
|
64
|
+
}
|
|
@@ -93,12 +93,6 @@ export function idpErrorEnvelope(json: unknown, fallbackMessage = 'Request faile
|
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
* PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
|
|
98
|
-
* [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
|
|
99
|
-
* unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
|
|
100
|
-
* (the caller must be able to tell a refused password from a dead recovery session).
|
|
101
|
-
*/
|
|
102
96
|
/**
|
|
103
97
|
* True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
|
|
104
98
|
* ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
|
|
@@ -116,6 +110,12 @@ export function projectResetTokenInvalid(json: unknown): boolean {
|
|
|
116
110
|
return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && (v as Record<string, unknown>).code === 'InvalidToken');
|
|
117
111
|
}
|
|
118
112
|
|
|
113
|
+
/**
|
|
114
|
+
* PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
|
|
115
|
+
* [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
|
|
116
|
+
* unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
|
|
117
|
+
* (the caller must be able to tell a refused password from a dead recovery session).
|
|
118
|
+
*/
|
|
119
119
|
export function projectPasswordPolicyRules(json: unknown): string[] | undefined {
|
|
120
120
|
if (!json || typeof json !== 'object') return undefined;
|
|
121
121
|
const top = json as Record<string, unknown>;
|
|
@@ -25,6 +25,7 @@ export type TwoFactorVerifyFailureCode =
|
|
|
25
25
|
| 'LOCKED'
|
|
26
26
|
| 'RATE_LIMITED'
|
|
27
27
|
| 'SESSION_REJECTED'
|
|
28
|
+
| 'STEP_UP_FACTOR_NOT_ALLOWED'
|
|
28
29
|
| 'VERIFY_UNAVAILABLE'
|
|
29
30
|
| 'VERIFY_FAILED';
|
|
30
31
|
|
|
@@ -35,7 +36,7 @@ const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
|
|
|
35
36
|
backup_code: 'Invalid backup code. Please try again.',
|
|
36
37
|
};
|
|
37
38
|
|
|
38
|
-
const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE'>, string> = {
|
|
39
|
+
const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE' | 'STEP_UP_FACTOR_NOT_ALLOWED'>, string> = {
|
|
39
40
|
LOCKED: 'Too many incorrect codes. Verification is locked for 15 minutes - wait before entering another code.',
|
|
40
41
|
RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
|
|
41
42
|
SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
|
|
@@ -70,6 +71,78 @@ const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:se
|
|
|
70
71
|
|
|
71
72
|
const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
|
|
76
|
+
* A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
|
|
77
|
+
* IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
|
|
78
|
+
* this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
|
|
79
|
+
*
|
|
80
|
+
* On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
|
|
81
|
+
* object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
|
|
82
|
+
* The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
|
|
83
|
+
*
|
|
84
|
+
* NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
|
|
85
|
+
* maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
|
|
86
|
+
* missing or malformed lists give EMPTY lists, never invented ones.
|
|
87
|
+
*/
|
|
88
|
+
export const STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
|
|
89
|
+
const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'] as const;
|
|
90
|
+
const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'] as const;
|
|
91
|
+
export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
|
|
92
|
+
export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
|
|
93
|
+
export type StepUpNextStep = { action: 'use_authenticator'; methods: StepUpMethod[] } | { action: Exclude<StepUpAction, 'use_authenticator'> };
|
|
94
|
+
export type StepUpRefusal = { allowed_methods: StepUpMethod[]; next_steps: StepUpNextStep[] };
|
|
95
|
+
|
|
96
|
+
const STEP_UP_TEXT =
|
|
97
|
+
'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
|
|
98
|
+
|
|
99
|
+
function allowedMethods(v: unknown): StepUpMethod[] {
|
|
100
|
+
if (!Array.isArray(v)) return [];
|
|
101
|
+
const out: StepUpMethod[] = [];
|
|
102
|
+
for (const m of v) {
|
|
103
|
+
if ((STEP_UP_METHODS as readonly unknown[]).includes(m) && !out.includes(m as StepUpMethod)) out.push(m as StepUpMethod);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
|
|
109
|
+
export function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null {
|
|
110
|
+
if (status !== 403 || !json || typeof json !== 'object') return null;
|
|
111
|
+
const j = json as Record<string, unknown>;
|
|
112
|
+
const err = j.error;
|
|
113
|
+
let code: unknown;
|
|
114
|
+
let src: Record<string, unknown> = j;
|
|
115
|
+
if (err && typeof err === 'object') {
|
|
116
|
+
const e = err as Record<string, unknown>;
|
|
117
|
+
code = e.code;
|
|
118
|
+
src = e.details && typeof e.details === 'object' ? (e.details as Record<string, unknown>) : {};
|
|
119
|
+
} else {
|
|
120
|
+
code = err;
|
|
121
|
+
}
|
|
122
|
+
if (code !== STEP_UP_REFUSAL_CODE) return null;
|
|
123
|
+
const next_steps: StepUpNextStep[] = [];
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
|
|
126
|
+
if (!step || typeof step !== 'object') continue;
|
|
127
|
+
const action = (step as Record<string, unknown>).action;
|
|
128
|
+
if (typeof action !== 'string' || !(STEP_UP_ACTIONS as readonly string[]).includes(action) || seen.has(action)) continue;
|
|
129
|
+
seen.add(action);
|
|
130
|
+
next_steps.push(
|
|
131
|
+
action === 'use_authenticator'
|
|
132
|
+
? { action, methods: allowedMethods((step as Record<string, unknown>).methods) }
|
|
133
|
+
: { action: action as Exclude<StepUpAction, 'use_authenticator'> },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stepUpResponse(refusal: StepUpRefusal, attemptedRefresh: boolean): NextResponse {
|
|
140
|
+
return NextResponse.json(
|
|
141
|
+
{ success: false, error: STEP_UP_TEXT, code: STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } },
|
|
142
|
+
{ status: 403 },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
73
146
|
/**
|
|
74
147
|
* Every string the IDP might have put anywhere in the error envelope, unwrapped from
|
|
75
148
|
* `{ message = X }` ToString form. Classification input only.
|
|
@@ -97,6 +170,8 @@ export function upstreamErrorTexts(json: unknown): string[] {
|
|
|
97
170
|
}
|
|
98
171
|
|
|
99
172
|
export function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode {
|
|
173
|
+
// PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
|
|
174
|
+
if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED';
|
|
100
175
|
// PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
|
|
101
176
|
// the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
|
|
102
177
|
//
|
|
@@ -118,8 +193,10 @@ export function twoFactorVerifyFailure(
|
|
|
118
193
|
result: { status: number; json: unknown; attemptedRefresh: boolean },
|
|
119
194
|
method: TwoFactorVerifyMethod,
|
|
120
195
|
): NextResponse {
|
|
196
|
+
const refusal = stepUpRefusal(result.status, result.json);
|
|
197
|
+
if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
|
|
121
198
|
const code = classifyTwoFactorVerifyFailure(result.status, result.json);
|
|
122
|
-
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code];
|
|
199
|
+
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code as keyof typeof FIXED_TEXT];
|
|
123
200
|
return NextResponse.json(
|
|
124
201
|
{ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
|
|
125
202
|
{ status: result.status },
|
|
@@ -137,12 +214,14 @@ export type TwoFactorSendMethod = 'sms' | 'email';
|
|
|
137
214
|
export type TwoFactorSendFailureCode =
|
|
138
215
|
| 'RATE_LIMITED'
|
|
139
216
|
| 'SESSION_REJECTED'
|
|
217
|
+
| 'STEP_UP_FACTOR_NOT_ALLOWED'
|
|
140
218
|
| 'SEND_UNAVAILABLE'
|
|
141
219
|
| 'SEND_FAILED';
|
|
142
220
|
|
|
143
221
|
const SEND_LABEL: Record<TwoFactorSendMethod, string> = { sms: 'text message', email: 'email' };
|
|
144
222
|
|
|
145
|
-
export function classifyTwoFactorSendFailure(status: number): TwoFactorSendFailureCode {
|
|
223
|
+
export function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode {
|
|
224
|
+
if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
|
|
146
225
|
if (status === 429) return 'RATE_LIMITED';
|
|
147
226
|
if (status === 401 || status === 403) return 'SESSION_REJECTED';
|
|
148
227
|
if (status >= 500 || status === 0) return 'SEND_UNAVAILABLE';
|
|
@@ -150,10 +229,12 @@ export function classifyTwoFactorSendFailure(status: number): TwoFactorSendFailu
|
|
|
150
229
|
}
|
|
151
230
|
|
|
152
231
|
export function twoFactorSendCodeFailure(
|
|
153
|
-
result: { status: number; attemptedRefresh: boolean },
|
|
232
|
+
result: { status: number; attemptedRefresh: boolean; json?: unknown },
|
|
154
233
|
method: TwoFactorSendMethod,
|
|
155
234
|
): NextResponse {
|
|
156
|
-
const
|
|
235
|
+
const refusal = stepUpRefusal(result.status, result.json);
|
|
236
|
+
if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
|
|
237
|
+
const code = classifyTwoFactorSendFailure(result.status, result.json);
|
|
157
238
|
const label = SEND_LABEL[method];
|
|
158
239
|
const error =
|
|
159
240
|
code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
|
|
@@ -256,6 +256,29 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
|
|
|
256
256
|
const { pathname, isPublicRoute, isLoginPage: onLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = ctx;
|
|
257
257
|
const safeCallback = getSafeCallbackUrl(pathname);
|
|
258
258
|
|
|
259
|
+
// --- PAY-884: a provisional (pre-2FA) session whose token has expired can never finish 2FA ---
|
|
260
|
+
// The IdP issues no refresh token until 2FA completes, and it validates token lifetime, so
|
|
261
|
+
// once the provisional access token expires neither refresh nor code verification can
|
|
262
|
+
// succeed. Sending the user to the verify-code page (or to error=SessionExpired, which the
|
|
263
|
+
// login page deliberately shows as NO message) strands them mid-2FA with no explanation.
|
|
264
|
+
// Say what happened instead, and clear the dead session so the next sign-in starts clean.
|
|
265
|
+
// Scoped to the verify-code page and protected pages: /api/ keeps its 401-JSON carve-out
|
|
266
|
+
// (307290), the login page stays reachable, and public pages are left to the existing arms.
|
|
267
|
+
if (
|
|
268
|
+
isExpiredProvisionalSession(sessionPointer, sessionStatus) &&
|
|
269
|
+
!pathname.startsWith('/api/') &&
|
|
270
|
+
!onLoginPage &&
|
|
271
|
+
(pathname === VERIFY_CODE_PAGE || !isPublicRoute)
|
|
272
|
+
) {
|
|
273
|
+
const callback = pathname === VERIFY_CODE_PAGE ? getSafeCallbackUrl(pathname, searchParams) : safeCallback;
|
|
274
|
+
return {
|
|
275
|
+
type: 'redirect',
|
|
276
|
+
location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(callback)}&error=${TWO_FACTOR_TIMEOUT_ERROR}`,
|
|
277
|
+
reason: 'pre_2fa_token_expired',
|
|
278
|
+
clearCookies: true,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
259
282
|
// --- Public routes: Allow, but enforce 2FA for authenticated users ---
|
|
260
283
|
if (isPublicRoute && !onLoginPage) {
|
|
261
284
|
if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
|
|
@@ -364,6 +387,25 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
|
|
|
364
387
|
return { type: 'allow' };
|
|
365
388
|
}
|
|
366
389
|
|
|
390
|
+
/** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
|
|
391
|
+
export const TWO_FACTOR_TIMEOUT_ERROR = 'TwoFactorTimeout';
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* PAY-884: 2FA outstanding, access token expired, and no refresh token to renew it.
|
|
395
|
+
* Only a viability answer that positively says the session exists qualifies (null = check failed).
|
|
396
|
+
*/
|
|
397
|
+
function isExpiredProvisionalSession(session: SessionPointer, status: SessionStatus): boolean {
|
|
398
|
+
return (
|
|
399
|
+
session.exists &&
|
|
400
|
+
status.exists === true &&
|
|
401
|
+
status.forceInvalid === false &&
|
|
402
|
+
status.requires2FA &&
|
|
403
|
+
!status.twoFactorComplete &&
|
|
404
|
+
session.expired === true &&
|
|
405
|
+
!session.hasRefreshToken
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
367
409
|
/** Check if user needs 2FA redirect */
|
|
368
410
|
function needsTwoFactorRedirect(pathname: string, session: SessionPointer, status: SessionStatus): boolean {
|
|
369
411
|
if (!session.exists || !status.exists) return false;
|
package/src/pages/login/page.tsx
CHANGED
|
@@ -141,6 +141,10 @@ function LoginForm({ altAuthSlot }: { altAuthSlot?: React.ReactNode }) {
|
|
|
141
141
|
// Session expiration is normal - don't show as error
|
|
142
142
|
console.log('[LOGIN] SessionExpired detected - normal behavior');
|
|
143
143
|
friendlyError = null;
|
|
144
|
+
} else if (urlError === 'TwoFactorTimeout') {
|
|
145
|
+
// PAY-884: the sign-in expired while the verification code was outstanding. Unlike
|
|
146
|
+
// SessionExpired this is NOT silent - the user was mid-2FA and must know why they are here.
|
|
147
|
+
friendlyError = 'Your verification step timed out before the code was confirmed. Please sign in again.';
|
|
144
148
|
} else if (urlError === 'ServiceUnavailable') {
|
|
145
149
|
friendlyError = 'Authentication service is temporarily unavailable. Please try again in a few minutes.';
|
|
146
150
|
} else if (urlError.startsWith("Unexpected token")) {
|
|
@@ -336,7 +336,8 @@ function VerifyCodeForm({ enrollHref }: { enrollHref: string }) {
|
|
|
336
336
|
sessionStorage.removeItem(VERIFY_IN_PROGRESS_KEY);
|
|
337
337
|
}
|
|
338
338
|
const safeCallback = callbackUrl.startsWith('/account-auth/') ? '/dashboard' : callbackUrl;
|
|
339
|
-
|
|
339
|
+
// PAY-884: an expired JWT here is the provisional pre-2FA token - say so on the login page.
|
|
340
|
+
const errorParam = isJwtExpired ? '&error=TwoFactorTimeout' : '';
|
|
340
341
|
router.push(`/account-auth/login?callbackUrl=${encodeURIComponent(safeCallback)}${errorParam}`);
|
|
341
342
|
}, 1500);
|
|
342
343
|
return;
|
|
@@ -411,7 +412,8 @@ function VerifyCodeForm({ enrollHref }: { enrollHref: string }) {
|
|
|
411
412
|
sessionStorage.removeItem(VERIFY_IN_PROGRESS_KEY);
|
|
412
413
|
}
|
|
413
414
|
const safeCallback = callbackUrl.startsWith('/account-auth/') ? '/dashboard' : callbackUrl;
|
|
414
|
-
|
|
415
|
+
// PAY-884: an expired JWT here is the provisional pre-2FA token - say so on the login page.
|
|
416
|
+
const errorParam = isJwtExpired ? '&error=TwoFactorTimeout' : '';
|
|
415
417
|
router.push(`/account-auth/login?callbackUrl=${encodeURIComponent(safeCallback)}${errorParam}`);
|
|
416
418
|
}, 1500);
|
|
417
419
|
return;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { NextResponse } from 'next/server';
|
|
31
31
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
32
|
+
import { secretSafeJson } from '../../lib/never-in-response';
|
|
32
33
|
|
|
33
34
|
export interface PublicAuthSettings {
|
|
34
35
|
// OAuth providers enabled for this client
|
|
@@ -170,8 +171,11 @@ export async function GET() {
|
|
|
170
171
|
allowed2FAMethods: authSettings.allowed2FAMethods,
|
|
171
172
|
};
|
|
172
173
|
|
|
173
|
-
|
|
174
|
+
// PAY-1529: the public policy is built from an allowlist above; secretSafeJson is the runtime backstop
|
|
175
|
+
// that answers 500 instead of serving it if a registered secret ever reaches this body anyway.
|
|
176
|
+
return secretSafeJson(
|
|
174
177
|
{ success: true, data: settings },
|
|
175
178
|
{ headers: { 'Cache-Control': 'no-store' } },
|
|
179
|
+
'GET /api/auth/settings',
|
|
176
180
|
);
|
|
177
181
|
}
|
package/src/server/auth.ts
CHANGED
|
@@ -6,17 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import 'server-only';
|
|
9
|
-
import {
|
|
10
|
-
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
9
|
+
import { getBetterAuthInstance } from '../auth/better-auth';
|
|
11
10
|
import {
|
|
12
11
|
getSession as getRedisSession,
|
|
13
12
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
14
13
|
type SessionData,
|
|
15
14
|
} from '../lib/session-store';
|
|
16
15
|
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
17
|
-
import { createSingleFlight } from '../lib/single-flight';
|
|
18
|
-
|
|
19
|
-
let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
|
|
20
16
|
|
|
21
17
|
export type IdpTokenResult =
|
|
22
18
|
| { success: true; accessToken: string; sessionData: SessionData }
|
|
@@ -102,32 +98,30 @@ function attachSessionData(session: any, sessionData: SessionData | null, sessio
|
|
|
102
98
|
* test/single-flight-retry.test.cjs, which imports dist/ and therefore
|
|
103
99
|
* exercises the artefact that actually ships.
|
|
104
100
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
101
|
+
* PAY-1623 (package half, BAPert 62449): THIS FUNNEL NO LONGER BUILDS ITS OWN
|
|
102
|
+
* INSTANCE. It used to call `createBetterAuthInstance(config)` with no opts, so
|
|
103
|
+
* it carried NONE of the app-supplied plugins registered by the host app at
|
|
104
|
+
* startup (`configureBetterAuth()` -> `state.configuredOpts`). `samlAcs()` and
|
|
105
|
+
* `passkeySignIn()` arrive only that way, so `getAuthInstance()` returned an
|
|
106
|
+
* instance on which `auth.api.samlAcs` was `undefined` — the exact package twin
|
|
107
|
+
* of the portal ACS crash `t.api.samlAcs is not a function` (Jon's walk,
|
|
108
|
+
* 2026-09-21; Portal PR 517 fixed the portal by importing the CONFIGURED
|
|
109
|
+
* instance, `getBetterAuthInstance`, which is why this file must now resolve the
|
|
110
|
+
* same one). It also silently created a SECOND, divergent instance beside the
|
|
111
|
+
* one every /api/auth/* route uses — two instances, two secrets, one process.
|
|
112
|
+
*
|
|
113
|
+
* It now DELEGATES to that configured funnel, which is the single source of
|
|
114
|
+
* truth for the process's Better Auth instance:
|
|
115
|
+
* - it carries `state.configuredOpts` (better-auth.ts:930), so the app
|
|
116
|
+
* plugins are present;
|
|
117
|
+
* - its init is already single-flighted with the retry semantics described in
|
|
118
|
+
* lib/single-flight.ts (see getBetterAuthInstance, card 244549/338215), so
|
|
119
|
+
* this function keeps the same no-latch recovery contract;
|
|
120
|
+
* - there is now ONE instance, so the double-init / divergent-secret hazard
|
|
121
|
+
* this path used to create is gone by construction.
|
|
110
122
|
*/
|
|
111
|
-
const authInit = createSingleFlight(async () => {
|
|
112
|
-
const config = await getIDPClientConfig(true);
|
|
113
|
-
authInstance = createBetterAuthInstance(config);
|
|
114
|
-
// 185121 line 1, path 2: this module-scope instance had NO creation log
|
|
115
|
-
// line — first-called during a config transition window it pinned a
|
|
116
|
-
// divergent secret INVISIBLY. Name the path and the secret fingerprint
|
|
117
|
-
// (the createBetterAuthInstance line already logged the same hash;
|
|
118
|
-
// this line is what attributes it to THIS init path).
|
|
119
|
-
// Guard matches createBetterAuthInstance's: getIDPClientConfig currently
|
|
120
|
-
// throws FATAL on a missing authSecret (QAPert 27328 verified), but that
|
|
121
|
-
// guarantee is one refactor away — log ABSENT rather than ever throw here.
|
|
122
|
-
console.log(
|
|
123
|
-
`[AUTH] getAuthInstance: created BA instance via server/auth.ts — authSecret ${config.authSecret ? `sha256:${secretFingerprint(config.authSecret as string)}` : 'ABSENT'}`
|
|
124
|
-
);
|
|
125
|
-
return authInstance;
|
|
126
|
-
});
|
|
127
|
-
|
|
128
123
|
export async function getAuthInstance() {
|
|
129
|
-
|
|
130
|
-
return authInit.get();
|
|
124
|
+
return getBetterAuthInstance();
|
|
131
125
|
}
|
|
132
126
|
|
|
133
127
|
|