@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
@@ -19,8 +19,6 @@ export declare const API_ENDPOINTS: {
19
19
  readonly profile: "/api/account/profile";
20
20
  readonly updateProfile: "/api/account/profile";
21
21
  readonly maskedInfo: "/api/account/masked-info";
22
- readonly sendResetCode: "/api/account/send-reset-code";
23
- readonly verifyResetCode: "/api/account/verify-reset-code";
24
22
  readonly resetPassword: "/api/account/reset-password";
25
23
  readonly changePassword: "/api/account/change-password";
26
24
  readonly validatePassword: "/api/account/validate-password";
@@ -29,8 +29,6 @@ exports.API_ENDPOINTS = {
29
29
  profile: '/api/account/profile',
30
30
  updateProfile: '/api/account/profile',
31
31
  maskedInfo: '/api/account/masked-info',
32
- sendResetCode: '/api/account/send-reset-code',
33
- verifyResetCode: '/api/account/verify-reset-code',
34
32
  resetPassword: '/api/account/reset-password',
35
33
  changePassword: '/api/account/change-password',
36
34
  validatePassword: '/api/account/validate-password'
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { AuthProvider, useAuthConfig, useAuthMode, useFederatedProviders, useFed
5
5
  export type { AuthConfig } from './types/auth';
6
6
  export { makeAuthDecision } from './auth/auth-decision';
7
7
  export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from './auth/route-config';
8
+ export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
8
9
  export { createMvpMiddleware } from './middleware/create-middleware';
9
10
  export { UserAvatarMenu, MobileNavDrawer } from './components/account';
10
11
  export type { UserAvatarMenuProps, MobileNavDrawerProps, NavItem, NavSection } from './components/account';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // - Better Auth: import { createBetterAuthInstance } from '@payez/next-mvp/auth/better-auth'
9
9
  // - Server auth: import { getSession } from '@payez/next-mvp/server/auth'
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.createMvpMiddleware = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
11
+ exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
12
12
  // Client-safe exports only
13
13
  // Client-side utilities
14
14
  var fetch_with_auth_1 = require("./client/fetch-with-auth");
@@ -31,6 +31,13 @@ var route_config_1 = require("./auth/route-config");
31
31
  Object.defineProperty(exports, "isUnauthenticatedRoute", { enumerable: true, get: function () { return route_config_1.isUnauthenticatedRoute; } });
32
32
  Object.defineProperty(exports, "configurePublicRoutes", { enumerable: true, get: function () { return route_config_1.configurePublicRoutes; } });
33
33
  Object.defineProperty(exports, "getRouteConfig", { enumerable: true, get: function () { return route_config_1.getRouteConfig; } });
34
+ // The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
35
+ // copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
36
+ var safe_callback_1 = require("./auth/safe-callback");
37
+ Object.defineProperty(exports, "safeCallbackPath", { enumerable: true, get: function () { return safe_callback_1.safeCallbackPath; } });
38
+ Object.defineProperty(exports, "isSafeCallbackPath", { enumerable: true, get: function () { return safe_callback_1.isSafeCallbackPath; } });
39
+ Object.defineProperty(exports, "resolveCallbackDestination", { enumerable: true, get: function () { return safe_callback_1.resolveCallbackDestination; } });
40
+ Object.defineProperty(exports, "DEFAULT_CALLBACK", { enumerable: true, get: function () { return safe_callback_1.DEFAULT_CALLBACK; } });
34
41
  var create_middleware_1 = require("./middleware/create-middleware");
35
42
  Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
36
43
  // Account Components
@@ -81,6 +81,7 @@ exports.clearConfigRedisCache = clearConfigRedisCache;
81
81
  exports.getEnabledProviders = getEnabledProviders;
82
82
  require("server-only");
83
83
  const crypto_1 = require("crypto");
84
+ const never_in_response_1 = require("./never-in-response");
84
85
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
85
86
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
86
87
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -148,6 +149,10 @@ function resolveAuthSecret(fromConfig) {
148
149
  function applyEnvSecrets(config) {
149
150
  const auth = resolveAuthSecret(config.authSecret);
150
151
  logSecretSource('authSecret', auth.source);
152
+ // PAY-1529: every secret this module resolves is registered as never-in-response HERE, the one place
153
+ // both fetch paths and the Redis read pass through, so the runtime backstop knows the value whichever
154
+ // handler later tries to serialize it.
155
+ (0, never_in_response_1.markNeverInResponse)(auth.value);
151
156
  return {
152
157
  ...config,
153
158
  authSecret: auth.value,
@@ -156,7 +161,9 @@ function applyEnvSecrets(config) {
156
161
  const fromEnv = process.env[envVar];
157
162
  const source = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
158
163
  logSecretSource(`${p.provider} clientSecret`, source);
159
- return { ...p, clientSecret: fromEnv || p.clientSecret };
164
+ const clientSecret = fromEnv || p.clientSecret;
165
+ (0, never_in_response_1.markNeverInResponse)(clientSecret);
166
+ return { ...p, clientSecret };
160
167
  }),
161
168
  };
162
169
  }
@@ -0,0 +1,30 @@
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
+ export declare const MIN_SECRET_LENGTH = 16;
22
+ /** Record a value that must never appear in a response body. Non-strings and short strings are ignored. */
23
+ export declare function markNeverInResponse(value: unknown): void;
24
+ export declare class SecretInResponseError extends Error {
25
+ constructor(where: string);
26
+ }
27
+ /** Throws SecretInResponseError if any registered secret appears in `body` once serialized. */
28
+ export declare function assertNoServerSecret(body: unknown, where: string): void;
29
+ /** NextResponse.json, refusing any body that carries a registered secret. */
30
+ export declare function secretSafeJson<T>(body: T, init: ResponseInit | undefined, where: string): NextResponse<T>;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SecretInResponseError = exports.MIN_SECRET_LENGTH = void 0;
4
+ exports.markNeverInResponse = markNeverInResponse;
5
+ exports.assertNoServerSecret = assertNoServerSecret;
6
+ exports.secretSafeJson = secretSafeJson;
7
+ /**
8
+ * PAY-1529 runtime backstop: the signing secret and the OAuth provider client secrets must never be
9
+ * serialized into an HTTP response body.
10
+ *
11
+ * The build-time guard (test/secrets-never-in-response-source-guard.test.cjs) follows values through the
12
+ * source it can see. This is the layer for what it cannot see — a value that crosses a module boundary,
13
+ * a structuredClone / Object.assign copy, a helper in a consumer app. It tracks the secret by VALUE:
14
+ * the loader registers every resolved secret here at the moment it resolves it (applyEnvSecrets), and a
15
+ * response built with `secretSafeJson` / checked with `assertNoServerSecret` FAILS CLOSED — it throws,
16
+ * and the route answers 500 — if any registered value appears anywhere in the serialized body, under any
17
+ * key, at any depth, whatever the code that built it called it.
18
+ *
19
+ * Bounds, stated rather than implied:
20
+ * - Values shorter than MIN_SECRET_LENGTH are not registered. A short string matches innocent bodies and
21
+ * would turn this into an outage generator; real signing and provider secrets are far longer.
22
+ * - It protects responses that go through it. Adoption is per handler; the build-time guard is what
23
+ * covers the handlers that do not.
24
+ * - Nothing here ever logs, returns or throws a secret value.
25
+ */
26
+ const server_1 = require("next/server");
27
+ exports.MIN_SECRET_LENGTH = 16;
28
+ const registered = new Set();
29
+ /** Record a value that must never appear in a response body. Non-strings and short strings are ignored. */
30
+ function markNeverInResponse(value) {
31
+ if (typeof value !== 'string' || value.length < exports.MIN_SECRET_LENGTH)
32
+ return;
33
+ registered.add(value);
34
+ // JSON escapes some characters, so the serialized form of a secret containing `"` or `\` differs from
35
+ // the raw value. Register that form too, or such a secret would pass through a JSON body unseen.
36
+ const escaped = JSON.stringify(value).slice(1, -1);
37
+ if (escaped !== value)
38
+ registered.add(escaped);
39
+ }
40
+ class SecretInResponseError extends Error {
41
+ constructor(where) {
42
+ // The message names the route, never the value.
43
+ super(`[PAY-1529] Refused to serialize a server secret into the response of ${where}`);
44
+ this.name = 'SecretInResponseError';
45
+ }
46
+ }
47
+ exports.SecretInResponseError = SecretInResponseError;
48
+ /** Throws SecretInResponseError if any registered secret appears in `body` once serialized. */
49
+ function assertNoServerSecret(body, where) {
50
+ if (registered.size === 0 || body === undefined || body === null)
51
+ return;
52
+ let text;
53
+ try {
54
+ text = typeof body === 'string' ? body : JSON.stringify(body);
55
+ }
56
+ catch {
57
+ // Unserializable: NextResponse.json would throw on it anyway. Fail closed rather than guess.
58
+ throw new SecretInResponseError(where);
59
+ }
60
+ if (!text)
61
+ return;
62
+ for (const secret of registered) {
63
+ if (text.includes(secret))
64
+ throw new SecretInResponseError(where);
65
+ }
66
+ }
67
+ /** NextResponse.json, refusing any body that carries a registered secret. */
68
+ function secretSafeJson(body, init, where) {
69
+ assertNoServerSecret(body, where);
70
+ return server_1.NextResponse.json(body, init);
71
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
6
+ * ---------------------------------------------------------------------------
7
+ * The NextAuth-era broker secret resolver was DELETED in
8
+ * 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
9
+ * and it is intentionally NOT coming back as first-class API: this package is
10
+ * Better Auth now, and new apps must not resolve a NextAuth secret at all.
11
+ *
12
+ * It is restored here ONLY because ONE consumer is still mid-migration and cannot
13
+ * take the 4.4.x bump without it:
14
+ *
15
+ * CONSUMER THAT NEEDS THIS: the customer portal
16
+ * (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
17
+ * @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
18
+ * `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
19
+ * import `resolveNextAuthSecret` from here. Without this export, bumping the
20
+ * portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
21
+ * shim rather than force the portal's full NextAuth->BetterAuth migration
22
+ * tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
23
+ * THIS FILE.
24
+ *
25
+ * DO NOT add new consumers. If you are starting an app, use Better Auth's own
26
+ * secret handling; you do not need this.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
30
+ * ---------------------------------------------------------------------------
31
+ * Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
32
+ * signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
33
+ *
34
+ * DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
35
+ * `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
36
+ * renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
37
+ * `validateAuthSecret` - same predicate, current name - so there is no second
38
+ * copy of the rule. The validation semantics are unchanged.
39
+ *
40
+ * SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
41
+ */
42
+ import 'server-only';
43
+ /**
44
+ * Resolve the NextAuth secret (server-only).
45
+ *
46
+ * Priority:
47
+ * 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
48
+ * 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
49
+ * 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
50
+ */
51
+ export declare function resolveNextAuthSecret(): Promise<string>;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveNextAuthSecret = resolveNextAuthSecret;
4
+ /**
5
+ * resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
9
+ * ---------------------------------------------------------------------------
10
+ * The NextAuth-era broker secret resolver was DELETED in
11
+ * 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
12
+ * and it is intentionally NOT coming back as first-class API: this package is
13
+ * Better Auth now, and new apps must not resolve a NextAuth secret at all.
14
+ *
15
+ * It is restored here ONLY because ONE consumer is still mid-migration and cannot
16
+ * take the 4.4.x bump without it:
17
+ *
18
+ * CONSUMER THAT NEEDS THIS: the customer portal
19
+ * (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
20
+ * @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
21
+ * `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
22
+ * import `resolveNextAuthSecret` from here. Without this export, bumping the
23
+ * portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
24
+ * shim rather than force the portal's full NextAuth->BetterAuth migration
25
+ * tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
26
+ * THIS FILE.
27
+ *
28
+ * DO NOT add new consumers. If you are starting an app, use Better Auth's own
29
+ * secret handling; you do not need this.
30
+ *
31
+ * ---------------------------------------------------------------------------
32
+ * BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
33
+ * ---------------------------------------------------------------------------
34
+ * Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
35
+ * signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
36
+ *
37
+ * DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
38
+ * `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
39
+ * renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
40
+ * `validateAuthSecret` - same predicate, current name - so there is no second
41
+ * copy of the rule. The validation semantics are unchanged.
42
+ *
43
+ * SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
44
+ */
45
+ require("server-only");
46
+ const node_crypto_1 = require("node:crypto");
47
+ const secret_validation_1 = require("./secret-validation");
48
+ let cachedSecret = null;
49
+ let lastFetchedAt = 0;
50
+ const CACHE_TTL_MS = 5 * 60 * 1000;
51
+ /**
52
+ * Resolve the NextAuth secret (server-only).
53
+ *
54
+ * Priority:
55
+ * 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
56
+ * 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
57
+ * 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
58
+ */
59
+ async function resolveNextAuthSecret() {
60
+ // Check if already in environment
61
+ if (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== '') {
62
+ return process.env.NEXTAUTH_SECRET;
63
+ }
64
+ // Check if cached and fresh (within 5 minutes)
65
+ if (cachedSecret && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
66
+ return cachedSecret;
67
+ }
68
+ // Broker mode: fetch from the IDP (the IDP handles all Key Vault/signing)
69
+ const base = process.env.IDP_URL;
70
+ if (!base)
71
+ throw new Error('IDP_URL environment variable is required');
72
+ const clientIdStr = process.env.CLIENT_ID;
73
+ if (!clientIdStr || clientIdStr.trim() === '') {
74
+ throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
75
+ }
76
+ // Step 1: ask the IDP to sign a client assertion (the IDP holds the keys, not us)
77
+ const signingUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
78
+ const signingResp = await fetch(signingUrl, {
79
+ method: 'POST',
80
+ headers: {
81
+ Accept: 'application/json',
82
+ 'Content-Type': 'application/json',
83
+ 'X-Client-Id': clientIdStr,
84
+ 'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
85
+ },
86
+ body: JSON.stringify({
87
+ issuer: clientIdStr,
88
+ subject: clientIdStr,
89
+ audience: 'urn:payez:externalauth:nextauthsecret',
90
+ expires_in: 60,
91
+ }),
92
+ cache: 'no-store',
93
+ });
94
+ if (!signingResp.ok) {
95
+ const txt = await signingResp.text().catch(() => 'Unknown error');
96
+ throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
97
+ }
98
+ const signingBody = (await signingResp.json().catch(() => ({})));
99
+ const clientAssertion = signingBody?.data?.client_assertion ??
100
+ signingBody?.data?.clientAssertion ??
101
+ signingBody?.client_assertion ??
102
+ signingBody?.clientAssertion ??
103
+ signingBody?.data?.ClientAssertion ??
104
+ signingBody?.ClientAssertion;
105
+ if (!clientAssertion || typeof clientAssertion !== 'string') {
106
+ throw new Error('IDP did not return a valid signed client assertion');
107
+ }
108
+ // Step 2: use the signed assertion to fetch the NextAuth secret
109
+ const proxyUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`;
110
+ const proxyResp = await fetch(proxyUrl, {
111
+ method: 'POST',
112
+ headers: {
113
+ Accept: 'application/json',
114
+ 'Content-Type': 'application/json',
115
+ 'X-Client-Id': clientIdStr,
116
+ 'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
117
+ },
118
+ body: JSON.stringify({ client_assertion: clientAssertion }),
119
+ cache: 'no-store',
120
+ });
121
+ if (!proxyResp.ok) {
122
+ const txt = await proxyResp.text().catch(() => 'Unknown error');
123
+ throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
124
+ }
125
+ const proxyBody = (await proxyResp.json().catch(() => ({})));
126
+ const secret = proxyBody?.data?.secret ?? proxyBody?.secret;
127
+ // `configuration` is available from the IDP but deliberately not logged.
128
+ if (!secret || typeof secret !== 'string') {
129
+ throw new Error('Proxy did not return a valid NextAuth secret');
130
+ }
131
+ // Same predicate as 4.0.46's validateNextAuthSecret, under its current name.
132
+ const validation = (0, secret_validation_1.validateAuthSecret)(secret);
133
+ if (!validation.valid) {
134
+ throw new Error(`Fetched NextAuth secret failed validation: ${validation.reason}`);
135
+ }
136
+ cachedSecret = secret;
137
+ lastFetchedAt = Date.now();
138
+ process.env.NEXTAUTH_SECRET = secret;
139
+ console.log('[NEXTAUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
140
+ return secret;
141
+ }
@@ -50,12 +50,6 @@ export declare function idpErrorEnvelope(json: unknown, fallbackMessage?: string
50
50
  };
51
51
  request_id?: string;
52
52
  };
53
- /**
54
- * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
55
- * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
56
- * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
57
- * (the caller must be able to tell a refused password from a dead recovery session).
58
- */
59
53
  /**
60
54
  * True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
61
55
  * ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
@@ -64,4 +58,10 @@ export declare function idpErrorEnvelope(json: unknown, fallbackMessage?: string
64
58
  * no code at all, and calling that 'link expired' loops the user.
65
59
  */
66
60
  export declare function projectResetTokenInvalid(json: unknown): boolean;
61
+ /**
62
+ * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
63
+ * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
64
+ * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
65
+ * (the caller must be able to tell a refused password from a dead recovery session).
66
+ */
67
67
  export declare function projectPasswordPolicyRules(json: unknown): string[] | undefined;
@@ -93,12 +93,6 @@ function idpErrorEnvelope(json, fallbackMessage = 'Request failed') {
93
93
  out.request_id = p.request_id;
94
94
  return out;
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
@@ -118,6 +112,12 @@ function projectResetTokenInvalid(json) {
118
112
  return true;
119
113
  return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && v.code === 'InvalidToken');
120
114
  }
115
+ /**
116
+ * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
117
+ * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
118
+ * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
119
+ * (the caller must be able to tell a refused password from a dead recovery session).
120
+ */
121
121
  function projectPasswordPolicyRules(json) {
122
122
  if (!json || typeof json !== 'object')
123
123
  return undefined;
@@ -18,7 +18,38 @@
18
18
  */
19
19
  import { NextResponse } from 'next/server';
20
20
  export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
21
- export type TwoFactorVerifyFailureCode = 'INVALID_CODE' | 'LOCKED' | 'RATE_LIMITED' | 'SESSION_REJECTED' | 'VERIFY_UNAVAILABLE' | 'VERIFY_FAILED';
21
+ export type TwoFactorVerifyFailureCode = 'INVALID_CODE' | 'LOCKED' | 'RATE_LIMITED' | 'SESSION_REJECTED' | 'STEP_UP_FACTOR_NOT_ALLOWED' | 'VERIFY_UNAVAILABLE' | 'VERIFY_FAILED';
22
+ /**
23
+ * PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
24
+ * A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
25
+ * IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
26
+ * this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
27
+ *
28
+ * On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
29
+ * object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
30
+ * The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
31
+ *
32
+ * NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
33
+ * maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
34
+ * missing or malformed lists give EMPTY lists, never invented ones.
35
+ */
36
+ export declare const STEP_UP_REFUSAL_CODE = "STEP_UP_FACTOR_NOT_ALLOWED";
37
+ declare const STEP_UP_METHODS: readonly ["totp", "webauthn", "backup_code"];
38
+ declare const STEP_UP_ACTIONS: readonly ["use_authenticator", "reset_password", "enroll_authenticator"];
39
+ export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
40
+ export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
41
+ export type StepUpNextStep = {
42
+ action: 'use_authenticator';
43
+ methods: StepUpMethod[];
44
+ } | {
45
+ action: Exclude<StepUpAction, 'use_authenticator'>;
46
+ };
47
+ export type StepUpRefusal = {
48
+ allowed_methods: StepUpMethod[];
49
+ next_steps: StepUpNextStep[];
50
+ };
51
+ /** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
52
+ export declare function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null;
22
53
  /**
23
54
  * Every string the IDP might have put anywhere in the error envelope, unwrapped from
24
55
  * `{ message = X }` ToString form. Classification input only.
@@ -37,9 +68,11 @@ export declare function twoFactorVerifyFailure(result: {
37
68
  * so no `message` field is emitted on failure.
38
69
  */
39
70
  export type TwoFactorSendMethod = 'sms' | 'email';
40
- export type TwoFactorSendFailureCode = 'RATE_LIMITED' | 'SESSION_REJECTED' | 'SEND_UNAVAILABLE' | 'SEND_FAILED';
41
- export declare function classifyTwoFactorSendFailure(status: number): TwoFactorSendFailureCode;
71
+ export type TwoFactorSendFailureCode = 'RATE_LIMITED' | 'SESSION_REJECTED' | 'STEP_UP_FACTOR_NOT_ALLOWED' | 'SEND_UNAVAILABLE' | 'SEND_FAILED';
72
+ export declare function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode;
42
73
  export declare function twoFactorSendCodeFailure(result: {
43
74
  status: number;
44
75
  attemptedRefresh: boolean;
76
+ json?: unknown;
45
77
  }, method: TwoFactorSendMethod): NextResponse;
78
+ export {};
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STEP_UP_REFUSAL_CODE = void 0;
4
+ exports.stepUpRefusal = stepUpRefusal;
3
5
  exports.upstreamErrorTexts = upstreamErrorTexts;
4
6
  exports.classifyTwoFactorVerifyFailure = classifyTwoFactorVerifyFailure;
5
7
  exports.twoFactorVerifyFailure = twoFactorVerifyFailure;
@@ -61,6 +63,70 @@ const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
61
63
  */
62
64
  const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
63
65
  const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
66
+ /**
67
+ * PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
68
+ * A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
69
+ * IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
70
+ * this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
71
+ *
72
+ * On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
73
+ * object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
74
+ * The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
75
+ *
76
+ * NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
77
+ * maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
78
+ * missing or malformed lists give EMPTY lists, never invented ones.
79
+ */
80
+ exports.STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
81
+ const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'];
82
+ const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'];
83
+ const STEP_UP_TEXT = 'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
84
+ function allowedMethods(v) {
85
+ if (!Array.isArray(v))
86
+ return [];
87
+ const out = [];
88
+ for (const m of v) {
89
+ if (STEP_UP_METHODS.includes(m) && !out.includes(m))
90
+ out.push(m);
91
+ }
92
+ return out;
93
+ }
94
+ /** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
95
+ function stepUpRefusal(status, json) {
96
+ if (status !== 403 || !json || typeof json !== 'object')
97
+ return null;
98
+ const j = json;
99
+ const err = j.error;
100
+ let code;
101
+ let src = j;
102
+ if (err && typeof err === 'object') {
103
+ const e = err;
104
+ code = e.code;
105
+ src = e.details && typeof e.details === 'object' ? e.details : {};
106
+ }
107
+ else {
108
+ code = err;
109
+ }
110
+ if (code !== exports.STEP_UP_REFUSAL_CODE)
111
+ return null;
112
+ const next_steps = [];
113
+ const seen = new Set();
114
+ for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
115
+ if (!step || typeof step !== 'object')
116
+ continue;
117
+ const action = step.action;
118
+ if (typeof action !== 'string' || !STEP_UP_ACTIONS.includes(action) || seen.has(action))
119
+ continue;
120
+ seen.add(action);
121
+ next_steps.push(action === 'use_authenticator'
122
+ ? { action, methods: allowedMethods(step.methods) }
123
+ : { action: action });
124
+ }
125
+ return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
126
+ }
127
+ function stepUpResponse(refusal, attemptedRefresh) {
128
+ return server_1.NextResponse.json({ success: false, error: STEP_UP_TEXT, code: exports.STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } }, { status: 403 });
129
+ }
64
130
  /**
65
131
  * Every string the IDP might have put anywhere in the error envelope, unwrapped from
66
132
  * `{ message = X }` ToString form. Classification input only.
@@ -95,6 +161,9 @@ function upstreamErrorTexts(json) {
95
161
  return out;
96
162
  }
97
163
  function classifyTwoFactorVerifyFailure(status, json) {
164
+ // PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
165
+ if (stepUpRefusal(status, json))
166
+ return 'STEP_UP_FACTOR_NOT_ALLOWED';
98
167
  // PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
99
168
  // the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
100
169
  //
@@ -118,12 +187,17 @@ function classifyTwoFactorVerifyFailure(status, json) {
118
187
  return 'VERIFY_FAILED';
119
188
  }
120
189
  function twoFactorVerifyFailure(result, method) {
190
+ const refusal = stepUpRefusal(result.status, result.json);
191
+ if (refusal)
192
+ return stepUpResponse(refusal, result.attemptedRefresh);
121
193
  const code = classifyTwoFactorVerifyFailure(result.status, result.json);
122
194
  const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code];
123
195
  return server_1.NextResponse.json({ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } }, { status: result.status });
124
196
  }
125
197
  const SEND_LABEL = { sms: 'text message', email: 'email' };
126
- function classifyTwoFactorSendFailure(status) {
198
+ function classifyTwoFactorSendFailure(status, json) {
199
+ if (stepUpRefusal(status, json))
200
+ return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
127
201
  if (status === 429)
128
202
  return 'RATE_LIMITED';
129
203
  if (status === 401 || status === 403)
@@ -133,7 +207,10 @@ function classifyTwoFactorSendFailure(status) {
133
207
  return 'SEND_FAILED';
134
208
  }
135
209
  function twoFactorSendCodeFailure(result, method) {
136
- const code = classifyTwoFactorSendFailure(result.status);
210
+ const refusal = stepUpRefusal(result.status, result.json);
211
+ if (refusal)
212
+ return stepUpResponse(refusal, result.attemptedRefresh);
213
+ const code = classifyTwoFactorSendFailure(result.status, result.json);
137
214
  const label = SEND_LABEL[method];
138
215
  const error = code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
139
216
  : code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
@@ -100,4 +100,6 @@ export interface MvpMiddlewareOptions {
100
100
  /** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
101
101
  rbacExemptPaths?: string[];
102
102
  }
103
+ /** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
104
+ export declare const TWO_FACTOR_TIMEOUT_ERROR = "TwoFactorTimeout";
103
105
  export declare function createMvpMiddleware(options?: MvpMiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;