@payez/next-mvp 4.4.6 → 4.4.8

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.
Files changed (54) hide show
  1. package/dist/api-handlers/account/change-password.js +28 -6
  2. package/dist/auth/better-auth.d.ts +62 -15
  3. package/dist/auth/better-auth.js +180 -19
  4. package/dist/client/better-auth-client.d.ts +101 -956
  5. package/dist/config/env.d.ts +0 -2
  6. package/dist/config/env.js +0 -2
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +8 -1
  9. package/dist/lib/idp-client-config.js +8 -1
  10. package/dist/lib/never-in-response.d.ts +30 -0
  11. package/dist/lib/never-in-response.js +71 -0
  12. package/dist/lib/nextauth-secret.d.ts +51 -0
  13. package/dist/lib/nextauth-secret.js +141 -0
  14. package/dist/lib/safe-error-response.d.ts +6 -6
  15. package/dist/lib/safe-error-response.js +6 -6
  16. package/dist/lib/two-factor-verify-failure.d.ts +36 -3
  17. package/dist/lib/two-factor-verify-failure.js +79 -2
  18. package/dist/middleware/create-middleware.d.ts +2 -0
  19. package/dist/middleware/create-middleware.js +36 -0
  20. package/dist/pages/login/page.js +5 -0
  21. package/dist/pages/verify-code/page.js +4 -2
  22. package/dist/routes/auth/settings.js +4 -1
  23. package/dist/server/auth.d.ts +36 -294
  24. package/dist/server/auth.js +22 -25
  25. package/dist/vibe/client.d.ts +132 -16
  26. package/dist/vibe/client.js +288 -82
  27. package/dist/vibe/document-unwrap.d.ts +50 -0
  28. package/dist/vibe/document-unwrap.js +78 -0
  29. package/dist/vibe/errors.d.ts +17 -0
  30. package/dist/vibe/errors.js +21 -1
  31. package/dist/vibe/generic.d.ts +2 -49
  32. package/dist/vibe/generic.js +17 -78
  33. package/dist/vibe/index.d.ts +1 -1
  34. package/dist/vibe/index.js +2 -1
  35. package/package.json +17 -2
  36. package/src/api-handlers/account/change-password.ts +31 -6
  37. package/src/auth/better-auth.ts +188 -21
  38. package/src/config/env.ts +0 -2
  39. package/src/index.ts +4 -0
  40. package/src/lib/idp-client-config.ts +8 -1
  41. package/src/lib/never-in-response.ts +64 -0
  42. package/src/lib/nextauth-secret.ts +168 -0
  43. package/src/lib/safe-error-response.ts +6 -6
  44. package/src/lib/two-factor-verify-failure.ts +86 -5
  45. package/src/middleware/create-middleware.ts +42 -0
  46. package/src/pages/login/page.tsx +4 -0
  47. package/src/pages/verify-code/page.tsx +4 -2
  48. package/src/routes/auth/settings.ts +5 -1
  49. package/src/server/auth.ts +23 -29
  50. package/src/vibe/client.ts +378 -91
  51. package/src/vibe/document-unwrap.ts +103 -0
  52. package/src/vibe/errors.ts +24 -0
  53. package/src/vibe/generic.ts +8 -100
  54. package/src/vibe/index.ts +1 -0
@@ -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 code = classifyTwoFactorSendFailure(result.status);
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;
@@ -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
- const errorParam = isJwtExpired ? '&error=SessionExpired' : '';
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
- const errorParam = isJwtExpired ? '&error=SessionExpired' : '';
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
- return NextResponse.json(
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
  }
@@ -6,17 +6,13 @@
6
6
  */
7
7
 
8
8
  import 'server-only';
9
- import { createBetterAuthInstance, secretFingerprint } from '../auth/better-auth';
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
- * Semantics, all asserted by that suite: concurrent callers share ONE
106
- * underlying init; a rejection reaches every caller awaiting that attempt
107
- * (failures stay loud -- getIDPClientConfig is fail-loud by design and callers
108
- * depend on it); the NEXT call after a failure starts a fresh attempt; and a
109
- * slow failure never evicts a newer in-flight attempt.
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
- if (authInstance) return authInstance;
130
- return authInit.get();
124
+ return getBetterAuthInstance();
131
125
  }
132
126
 
133
127