@payez/next-mvp 4.4.5 → 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/README.md +10 -3
- package/dist/api/auth-handler.js +5 -3
- package/dist/api-handlers/account/change-password.js +33 -8
- package/dist/api-handlers/account/masked-info.js +2 -1
- package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
- package/dist/api-handlers/account/recovery/forward.js +105 -0
- package/dist/api-handlers/account/recovery/initiate.js +4 -0
- package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
- package/dist/api-handlers/account/recovery/send-code.js +11 -84
- package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
- package/dist/api-handlers/account/recovery/verify-code.js +10 -84
- package/dist/api-handlers/account/reset-password.js +24 -1
- package/dist/api-handlers/account/send-code.d.ts +1 -15
- package/dist/api-handlers/account/send-code.js +3 -6
- package/dist/api-handlers/account/update-phone.js +6 -1
- package/dist/api-handlers/account/validate-password.js +3 -1
- package/dist/api-handlers/account/verify-email.d.ts +1 -17
- package/dist/api-handlers/account/verify-email.js +4 -6
- package/dist/api-handlers/account/verify-sms.d.ts +1 -17
- package/dist/api-handlers/account/verify-sms.js +4 -6
- package/dist/api-handlers/admin/analytics.js +12 -4
- package/dist/api-handlers/admin/audit.js +12 -4
- package/dist/api-handlers/admin/redis-sessions.js +16 -8
- package/dist/api-handlers/admin/sessions.js +14 -6
- package/dist/api-handlers/admin/site-logs.js +39 -16
- package/dist/api-handlers/admin/stats.js +14 -4
- package/dist/api-handlers/admin/users.js +14 -6
- package/dist/api-handlers/admin/vibe-data.js +19 -11
- package/dist/api-handlers/auth/jwks.js +2 -1
- package/dist/api-handlers/auth/login.js +13 -4
- package/dist/api-handlers/auth/passkey-signin.js +6 -0
- package/dist/api-handlers/auth/refresh.js +38 -11
- package/dist/api-handlers/auth/signout.js +2 -1
- package/dist/api-handlers/auth/status.js +4 -1
- package/dist/api-handlers/auth/update-session.d.ts +25 -4
- package/dist/api-handlers/auth/update-session.js +38 -11
- package/dist/api-handlers/auth/validate.js +4 -2
- package/dist/api-handlers/auth/verify-code.js +3 -2
- package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
- package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
- package/dist/api-handlers/mfa/backup-codes.js +2 -1
- package/dist/api-handlers/mfa/methods.js +2 -1
- package/dist/api-handlers/mfa/totp-confirm.js +2 -1
- package/dist/api-handlers/mfa/totp-enroll.js +2 -1
- package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
- package/dist/api-handlers/mfa/totp-verify.js +4 -6
- package/dist/api-handlers/passkeys/list.js +2 -1
- package/dist/api-handlers/passkeys/manage.js +2 -1
- package/dist/api-handlers/passkeys/register-options.js +2 -1
- package/dist/api-handlers/passkeys/register-verify.js +2 -1
- package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
- package/dist/api-handlers/session/refresh-viability.js +10 -7
- package/dist/api-handlers/session/viability.js +4 -3
- package/dist/api-handlers/test/force-expire.js +5 -3
- package/dist/auth/auth-decision.js +2 -1
- package/dist/auth/better-auth.d.ts +58 -14
- package/dist/auth/better-auth.js +125 -54
- package/dist/auth/utils/idp-client.js +2 -1
- package/dist/client/better-auth-client.d.ts +101 -956
- package/dist/components/admin/DataBrowserTab.js +4 -1
- package/dist/components/admin/SessionsTab.js +9 -3
- package/dist/components/recovery/RequestedStep.d.ts +11 -0
- package/dist/components/recovery/RequestedStep.js +14 -0
- package/dist/components/recovery/SetPasswordStep.js +10 -1
- 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/app-slug.d.ts +21 -0
- package/dist/lib/app-slug.js +51 -0
- package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
- package/dist/lib/ensure-fresh-access-token.js +105 -4
- package/dist/lib/idp-client-config.d.ts +20 -0
- package/dist/lib/idp-client-config.js +73 -45
- package/dist/lib/idp-fetch.js +1 -1
- package/dist/lib/never-in-response.d.ts +30 -0
- package/dist/lib/never-in-response.js +71 -0
- package/dist/lib/password-rules.d.ts +22 -0
- package/dist/lib/password-rules.js +43 -0
- package/dist/lib/safe-error-response.d.ts +67 -0
- package/dist/lib/safe-error-response.js +130 -0
- package/dist/lib/session-cookie-shape.d.ts +22 -19
- package/dist/lib/session-cookie-shape.js +14 -18
- package/dist/lib/session-store.d.ts +8 -1
- package/dist/lib/session-store.js +170 -24
- package/dist/lib/startup-init.d.ts +6 -0
- package/dist/lib/startup-init.js +30 -4
- package/dist/lib/token-lifecycle.js +2 -1
- package/dist/lib/token-log-id.d.ts +18 -0
- package/dist/lib/token-log-id.js +84 -0
- package/dist/lib/two-factor-verify-failure.d.ts +78 -0
- package/dist/lib/two-factor-verify-failure.js +220 -0
- package/dist/middleware/create-middleware.d.ts +4 -1
- package/dist/middleware/create-middleware.js +79 -8
- package/dist/pages/login/page.js +5 -0
- package/dist/pages/recovery/page.js +14 -4
- package/dist/pages/test-env/JwtInspectPage.js +7 -149
- package/dist/pages/test-env/RefreshTokenPage.js +4 -1
- package/dist/pages/test-env/redact-credentials.d.ts +6 -0
- package/dist/pages/test-env/redact-credentials.js +101 -0
- package/dist/pages/verify-code/page.js +6 -3
- package/dist/routes/auth/logout.d.ts +2 -1
- package/dist/routes/auth/logout.js +4 -2
- package/dist/routes/auth/session.d.ts +0 -3
- package/dist/routes/auth/session.js +16 -10
- package/dist/routes/auth/settings.js +4 -1
- package/dist/routes/auth/viability.d.ts +3 -2
- package/dist/routes/auth/viability.js +11 -7
- package/dist/server/auth.d.ts +36 -294
- package/dist/server/auth.js +32 -31
- package/dist/server/decode-session.js +15 -26
- package/dist/server/verified-session-token.d.ts +13 -0
- package/dist/server/verified-session-token.js +103 -0
- package/dist/stores/authStore.js +14 -3
- package/dist/types/recovery.d.ts +1 -1
- package/dist/utils/api.d.ts +18 -0
- package/dist/utils/api.js +51 -9
- 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/dist/vibe/sessions.js +2 -1
- package/package.json +13 -2
- package/scripts/verify-release-provenance.mjs +410 -0
- package/src/api/auth-handler.ts +5 -3
- package/src/api-handlers/account/change-password.ts +36 -8
- package/src/api-handlers/account/masked-info.ts +2 -1
- package/src/api-handlers/account/recovery/forward.ts +117 -0
- package/src/api-handlers/account/recovery/initiate.ts +3 -0
- package/src/api-handlers/account/recovery/send-code.ts +13 -99
- package/src/api-handlers/account/recovery/verify-code.ts +12 -99
- package/src/api-handlers/account/reset-password.ts +30 -1
- package/src/api-handlers/account/send-code.ts +3 -9
- package/src/api-handlers/account/update-phone.ts +6 -1
- package/src/api-handlers/account/validate-password.ts +3 -1
- package/src/api-handlers/account/verify-email.ts +4 -9
- package/src/api-handlers/account/verify-sms.ts +4 -9
- package/src/api-handlers/admin/analytics.ts +13 -5
- package/src/api-handlers/admin/audit.ts +13 -5
- package/src/api-handlers/admin/redis-sessions.ts +16 -8
- package/src/api-handlers/admin/sessions.ts +15 -7
- package/src/api-handlers/admin/site-logs.ts +39 -16
- package/src/api-handlers/admin/stats.ts +15 -5
- package/src/api-handlers/admin/users.ts +15 -7
- package/src/api-handlers/admin/vibe-data.ts +20 -12
- package/src/api-handlers/auth/jwks.ts +2 -1
- package/src/api-handlers/auth/login.ts +13 -4
- package/src/api-handlers/auth/passkey-signin.ts +4 -0
- package/src/api-handlers/auth/refresh.ts +38 -11
- package/src/api-handlers/auth/signout.ts +2 -1
- package/src/api-handlers/auth/status.ts +4 -1
- package/src/api-handlers/auth/update-session.ts +45 -11
- package/src/api-handlers/auth/validate.ts +4 -2
- package/src/api-handlers/auth/verify-code.ts +3 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
- package/src/api-handlers/mfa/backup-codes.ts +2 -1
- package/src/api-handlers/mfa/methods.ts +2 -1
- package/src/api-handlers/mfa/totp-confirm.ts +2 -1
- package/src/api-handlers/mfa/totp-enroll.ts +2 -1
- package/src/api-handlers/mfa/totp-verify.ts +4 -9
- package/src/api-handlers/passkeys/list.ts +2 -1
- package/src/api-handlers/passkeys/manage.ts +2 -1
- package/src/api-handlers/passkeys/register-options.ts +2 -1
- package/src/api-handlers/passkeys/register-verify.ts +2 -1
- package/src/api-handlers/session/refresh-viability.ts +10 -7
- package/src/api-handlers/session/viability.ts +4 -3
- package/src/api-handlers/test/force-expire.ts +5 -3
- package/src/auth/auth-decision.ts +2 -1
- package/src/auth/better-auth.ts +128 -61
- package/src/auth/utils/idp-client.ts +2 -1
- package/src/components/admin/DataBrowserTab.tsx +4 -1
- package/src/components/admin/SessionsTab.tsx +10 -3
- package/src/components/recovery/RequestedStep.tsx +30 -0
- package/src/components/recovery/SetPasswordStep.tsx +24 -3
- package/src/config/env.ts +0 -2
- package/src/hooks/useViabilitySession.ts +2 -1
- package/src/index.ts +4 -0
- package/src/lib/app-slug.ts +50 -0
- package/src/lib/ensure-fresh-access-token.ts +133 -4
- package/src/lib/idp-client-config.ts +98 -45
- package/src/lib/idp-fetch.ts +1 -1
- package/src/lib/never-in-response.ts +64 -0
- package/src/lib/password-rules.ts +50 -0
- package/src/lib/safe-error-response.ts +126 -0
- package/src/lib/session-cookie-shape.ts +23 -22
- package/src/lib/session-store.ts +167 -24
- package/src/lib/startup-init.ts +29 -4
- package/src/lib/token-lifecycle.ts +2 -1
- package/src/lib/token-log-id.ts +69 -0
- package/src/lib/two-factor-verify-failure.ts +248 -0
- package/src/middleware/create-middleware.ts +97 -9
- package/src/pages/login/page.tsx +4 -0
- package/src/pages/recovery/page.tsx +21 -4
- package/src/pages/test-env/JwtInspectPage.tsx +11 -233
- package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
- package/src/pages/test-env/redact-credentials.ts +96 -0
- package/src/pages/verify-code/page.tsx +6 -3
- package/src/routes/auth/logout.ts +4 -2
- package/src/routes/auth/session.ts +16 -10
- package/src/routes/auth/settings.ts +5 -1
- package/src/routes/auth/viability.ts +11 -7
- package/src/server/auth.ts +33 -35
- package/src/server/decode-session.ts +16 -26
- package/src/server/verified-session-token.ts +103 -0
- package/src/stores/authStore.ts +16 -5
- package/src/types/recovery.ts +1 -1
- package/src/utils/api.ts +47 -8
- 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
- package/src/vibe/sessions.ts +2 -1
|
@@ -74,12 +74,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
74
74
|
};
|
|
75
75
|
})();
|
|
76
76
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
77
|
+
exports.providerSecretEnvVar = providerSecretEnvVar;
|
|
77
78
|
exports.getIDPClientConfig = getIDPClientConfig;
|
|
78
79
|
exports.clearConfigCache = clearConfigCache;
|
|
79
80
|
exports.clearConfigRedisCache = clearConfigRedisCache;
|
|
80
81
|
exports.getEnabledProviders = getEnabledProviders;
|
|
81
82
|
require("server-only");
|
|
82
83
|
const crypto_1 = require("crypto");
|
|
84
|
+
const never_in_response_1 = require("./never-in-response");
|
|
83
85
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
84
86
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
85
87
|
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
@@ -124,6 +126,55 @@ async function getRedis() {
|
|
|
124
126
|
const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
|
125
127
|
const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
|
|
126
128
|
let operatorOverrideLogged = false;
|
|
129
|
+
function providerSecretEnvVar(provider) {
|
|
130
|
+
return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
|
|
131
|
+
}
|
|
132
|
+
const loggedSecretSources = new Map();
|
|
133
|
+
function logSecretSource(what, source) {
|
|
134
|
+
if (loggedSecretSources.get(what) === source)
|
|
135
|
+
return;
|
|
136
|
+
loggedSecretSources.set(what, source);
|
|
137
|
+
console.log(`[IDP_CONFIG] ${what} source: ${source}`);
|
|
138
|
+
}
|
|
139
|
+
function resolveAuthSecret(fromConfig) {
|
|
140
|
+
if (OPERATOR_AUTH_SECRET)
|
|
141
|
+
return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
|
|
142
|
+
if (OPERATOR_NEXTAUTH_SECRET)
|
|
143
|
+
return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
|
|
144
|
+
if (fromConfig)
|
|
145
|
+
return { value: fromConfig, source: 'idp-config' };
|
|
146
|
+
return { value: '', source: 'none' };
|
|
147
|
+
}
|
|
148
|
+
/** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
|
|
149
|
+
function applyEnvSecrets(config) {
|
|
150
|
+
const auth = resolveAuthSecret(config.authSecret);
|
|
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);
|
|
156
|
+
return {
|
|
157
|
+
...config,
|
|
158
|
+
authSecret: auth.value,
|
|
159
|
+
oauthProviders: (config.oauthProviders ?? []).map((p) => {
|
|
160
|
+
const envVar = providerSecretEnvVar(p.provider);
|
|
161
|
+
const fromEnv = process.env[envVar];
|
|
162
|
+
const source = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
|
|
163
|
+
logSecretSource(`${p.provider} clientSecret`, source);
|
|
164
|
+
const clientSecret = fromEnv || p.clientSecret;
|
|
165
|
+
(0, never_in_response_1.markNeverInResponse)(clientSecret);
|
|
166
|
+
return { ...p, clientSecret };
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
|
|
171
|
+
function stripEnvSecrets(config) {
|
|
172
|
+
return {
|
|
173
|
+
...config,
|
|
174
|
+
authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
|
|
175
|
+
oauthProviders: (config.oauthProviders ?? []).map((p) => process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
127
178
|
/** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
|
|
128
179
|
function publishAuthSecret(value, source) {
|
|
129
180
|
if (OPERATOR_AUTH_SECRET) {
|
|
@@ -234,7 +285,8 @@ async function setConfigInRedis(config) {
|
|
|
234
285
|
const key = getRedisConfigKey();
|
|
235
286
|
const ttlSeconds = config.configCacheTtlSeconds || 300;
|
|
236
287
|
const data = {
|
|
237
|
-
|
|
288
|
+
// PAY-1460: never persist a secret the environment supplied.
|
|
289
|
+
config: stripEnvSecrets(config),
|
|
238
290
|
expiresAt: Date.now() + (ttlSeconds * 1000)
|
|
239
291
|
};
|
|
240
292
|
// Store with TTL slightly longer than the logical expiry to allow for clock skew
|
|
@@ -289,7 +341,10 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
289
341
|
}
|
|
290
342
|
// Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
|
|
291
343
|
if (!forceRefresh) {
|
|
292
|
-
|
|
344
|
+
// PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
|
|
345
|
+
// environment is re-applied on read, BEFORE the emptiness test below.
|
|
346
|
+
const cachedRaw = await getConfigFromRedis();
|
|
347
|
+
const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
|
|
293
348
|
// A cached entry is only USABLE if it carries a secret — see the note
|
|
294
349
|
// below. `redisConfig && !authSecret` must behave like a cache MISS, so
|
|
295
350
|
// the emptiness test belongs here, in the condition that gates the
|
|
@@ -433,7 +488,7 @@ async function getIDPClientConfig(forceRefresh = false) {
|
|
|
433
488
|
// This throw is what stops that. It is also what turned the
|
|
434
489
|
// 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
|
|
435
490
|
// of a silently forgeable app.
|
|
436
|
-
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
|
|
491
|
+
throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
437
492
|
}
|
|
438
493
|
// Validated above — only now is it safe to cache or publish.
|
|
439
494
|
publishAuthSecret(config.authSecret, 'freshly-fetched IDP config');
|
|
@@ -503,7 +558,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
503
558
|
});
|
|
504
559
|
if (!resp.ok) {
|
|
505
560
|
const txt = await resp.text().catch(() => 'Unknown error');
|
|
506
|
-
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status}
|
|
561
|
+
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
|
|
507
562
|
}
|
|
508
563
|
const body = await resp.json().catch(() => null);
|
|
509
564
|
if (!body) {
|
|
@@ -514,7 +569,7 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
514
569
|
if (rawClientId === undefined || rawClientId === null) {
|
|
515
570
|
throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
|
|
516
571
|
}
|
|
517
|
-
const config = {
|
|
572
|
+
const config = applyEnvSecrets({
|
|
518
573
|
clientId: String(rawClientId),
|
|
519
574
|
clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
|
|
520
575
|
// Wire compatibility: accept new authSecret first, fall back to legacy
|
|
@@ -575,12 +630,12 @@ async function fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr) {
|
|
|
575
630
|
logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
|
|
576
631
|
},
|
|
577
632
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
578
|
-
};
|
|
633
|
+
});
|
|
579
634
|
if (!config.authSecret) {
|
|
580
635
|
// LOAD-BEARING — see the note on the external path's equivalent throw.
|
|
581
636
|
// An empty secret reaching betterAuth() becomes the PUBLIC default, not an
|
|
582
637
|
// error. Never replace this with a fallback.
|
|
583
|
-
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
|
|
638
|
+
throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
584
639
|
}
|
|
585
640
|
console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
|
|
586
641
|
consecutiveFailures = 0;
|
|
@@ -649,7 +704,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
649
704
|
});
|
|
650
705
|
if (!signingResp.ok) {
|
|
651
706
|
const txt = await signingResp.text().catch(() => 'Unknown error');
|
|
652
|
-
|
|
707
|
+
// PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
|
|
708
|
+
throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
|
|
653
709
|
}
|
|
654
710
|
const signingBody = await signingResp.json().catch(() => null);
|
|
655
711
|
if (!signingBody) {
|
|
@@ -660,7 +716,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
660
716
|
const client_assertion = (signingBody?.data?.client_assertion ??
|
|
661
717
|
signingBody?.data?.clientAssertion);
|
|
662
718
|
if (!client_assertion) {
|
|
663
|
-
|
|
719
|
+
// PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
|
|
720
|
+
console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
|
|
664
721
|
throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
|
|
665
722
|
}
|
|
666
723
|
// Step 2: Fetch client config using the assertion
|
|
@@ -673,42 +730,12 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
673
730
|
'X-Client-Id': clientIdStr,
|
|
674
731
|
'X-Correlation-Id': (0, crypto_1.randomUUID)().replace(/-/g, ''),
|
|
675
732
|
},
|
|
676
|
-
|
|
677
|
-
//
|
|
678
|
-
// This broker is the ONLY consumer of oauth_providers[].client_secret
|
|
679
|
-
// (DotNetPert enumerated it: this file feeds auth/providers/oauth.ts,
|
|
680
|
-
// which performs the server-side code exchange). Every OTHER caller of
|
|
681
|
-
// ExternalAuth/client-config - notably every fielded acp-desktop - is
|
|
682
|
-
// pushed the same secrets today and reads none of them: the desktop is
|
|
683
|
-
// PKCE and dropped clientSecret from OAuthProviderConfig entirely.
|
|
684
|
-
//
|
|
685
|
-
// The IDP IGNORES this field today, so shipping it is a no-op and needs
|
|
686
|
-
// no coordination window. Phase 2 (DotNetPert, IDP side) makes
|
|
687
|
-
// client-config emit the secrets ONLY when the opt-in is present and
|
|
688
|
-
// redact otherwise - which is why phase 1 MUST be live first, or the
|
|
689
|
-
// broker loses the field and tenant OAuth sign-in dies.
|
|
690
|
-
//
|
|
691
|
-
// SCOPE, deliberately narrow - do NOT read this as a partial migration:
|
|
692
|
-
// the sibling path fetchConfigFromInternalIDP() hits a DIFFERENT
|
|
693
|
-
// endpoint (InternalClientConfig/{id}, GET) that is already
|
|
694
|
-
// authenticated with CONTAINERS_KEY, is not reachable by a desktop, and
|
|
695
|
-
// is therefore not part of 217917's exposure. It deliberately sends no
|
|
696
|
-
// opt-in. If phase 2's redaction is implemented in shared config-
|
|
697
|
-
// assembly code rather than in ExternalAuthController.GetClientConfig,
|
|
698
|
-
// it will ALSO strip the internal route and break this broker's other
|
|
699
|
-
// path with no opt-in to save it. Phase 2 must gate on the request.
|
|
700
|
-
//
|
|
701
|
-
// NOT A FIX ON ITS OWN: an attacker can set this flag too. Phase 2 ends
|
|
702
|
-
// the AUTOMATIC delivery of every tenant's provider secrets to every
|
|
703
|
-
// user laptop; it does not close disclosure to a determined caller.
|
|
704
|
-
// That residual is card 175047 (unauthenticated mint, global partner
|
|
705
|
-
// key) and 217917 does not close until 175047 lands or Jon accepts it.
|
|
706
|
-
body: JSON.stringify({ client_assertion, include_provider_secrets: true }),
|
|
733
|
+
body: JSON.stringify({ client_assertion }),
|
|
707
734
|
cache: 'no-store'
|
|
708
735
|
});
|
|
709
736
|
if (!configResp.ok) {
|
|
710
737
|
const txt = await configResp.text().catch(() => 'Unknown error');
|
|
711
|
-
throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status}
|
|
738
|
+
throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
|
|
712
739
|
}
|
|
713
740
|
const configBody = await configResp.json().catch(() => null);
|
|
714
741
|
if (!configBody) {
|
|
@@ -717,7 +744,8 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
717
744
|
// Per PayEz API standard: response is wrapped in { success, data: {...} }
|
|
718
745
|
const configData = configBody?.data;
|
|
719
746
|
if (!configData || typeof configData !== 'object') {
|
|
720
|
-
|
|
747
|
+
// PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
|
|
748
|
+
console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
|
|
721
749
|
throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
|
|
722
750
|
}
|
|
723
751
|
// Validate required fields - handle both number and string client_id
|
|
@@ -728,7 +756,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
728
756
|
// Map response to our interface (IDP returns camelCase or snake_case).
|
|
729
757
|
// Wire compatibility: accept new authSecret first, fall back to legacy
|
|
730
758
|
// nextAuthSecret/next_auth_secret while IDP rename rolls out.
|
|
731
|
-
const config = {
|
|
759
|
+
const config = applyEnvSecrets({
|
|
732
760
|
clientId: String(rawClientId),
|
|
733
761
|
clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
|
|
734
762
|
authSecret: configData.authSecret ??
|
|
@@ -798,7 +826,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
798
826
|
logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
|
|
799
827
|
},
|
|
800
828
|
baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
|
|
801
|
-
};
|
|
829
|
+
});
|
|
802
830
|
// Debug: log what we got for baseClientUrl
|
|
803
831
|
console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
|
|
804
832
|
// Validate we got what we need
|
|
@@ -808,7 +836,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
|
|
|
808
836
|
if (!config.authSecret) {
|
|
809
837
|
// LOAD-BEARING — empty must fail here, never downstream. betterAuth()
|
|
810
838
|
// silently substitutes a public constant for an empty secret.
|
|
811
|
-
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
|
|
839
|
+
throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
|
|
812
840
|
}
|
|
813
841
|
// Success - reset failure tracking
|
|
814
842
|
consecutiveFailures = 0;
|
package/dist/lib/idp-fetch.js
CHANGED
|
@@ -77,7 +77,7 @@ async function ensureFreshAccessToken(req) {
|
|
|
77
77
|
const sessionToken = (token?.sessionToken || token?.redisSessionId);
|
|
78
78
|
console.log('[IDP_FETCH] ensureFreshAccessToken:', {
|
|
79
79
|
hasToken: !!token,
|
|
80
|
-
|
|
80
|
+
hasSessionToken: !!sessionToken, // PAY-1427 / BAPert 58829: no token material in logs, not even a stub (was a 20-char prefix)
|
|
81
81
|
pathname: req.nextUrl.pathname
|
|
82
82
|
});
|
|
83
83
|
if (!sessionToken) {
|
|
@@ -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,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1591: the one sentence that states the password rules on the recovery set-password step.
|
|
3
|
+
*
|
|
4
|
+
* Built ONLY from the policy the IdP reported (usePasswordValidation.policy). A rule that is off is not stated, and with
|
|
5
|
+
* no policy at all we state no number: telling a user "at least 8 characters" when the client is configured for 12 is
|
|
6
|
+
* the same defect as saying nothing, one step later.
|
|
7
|
+
*/
|
|
8
|
+
export interface PasswordPolicyLike {
|
|
9
|
+
min_length?: number;
|
|
10
|
+
require_uppercase?: boolean;
|
|
11
|
+
require_lowercase?: boolean;
|
|
12
|
+
require_digit?: boolean;
|
|
13
|
+
require_special?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function passwordRulesText(policy: PasswordPolicyLike | undefined | null): string;
|
|
16
|
+
/**
|
|
17
|
+
* PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
|
|
18
|
+
* portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
|
|
19
|
+
*/
|
|
20
|
+
export declare const PASSWORD_RULE_SENTENCES: Readonly<Record<string, string>>;
|
|
21
|
+
export declare function knownPasswordRules(rules: unknown): string[];
|
|
22
|
+
export declare function passwordRuleSentences(rules: string[]): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PASSWORD_RULE_SENTENCES = void 0;
|
|
4
|
+
exports.passwordRulesText = passwordRulesText;
|
|
5
|
+
exports.knownPasswordRules = knownPasswordRules;
|
|
6
|
+
exports.passwordRuleSentences = passwordRuleSentences;
|
|
7
|
+
function passwordRulesText(policy) {
|
|
8
|
+
if (!policy || typeof policy.min_length !== 'number') {
|
|
9
|
+
return "Use a password that meets this site's rules. We'll show what is missing as you type.";
|
|
10
|
+
}
|
|
11
|
+
const parts = [`at least ${policy.min_length} characters`];
|
|
12
|
+
if (policy.require_uppercase)
|
|
13
|
+
parts.push('an upper-case letter');
|
|
14
|
+
if (policy.require_lowercase)
|
|
15
|
+
parts.push('a lower-case letter');
|
|
16
|
+
if (policy.require_digit)
|
|
17
|
+
parts.push('a number');
|
|
18
|
+
if (policy.require_special)
|
|
19
|
+
parts.push('a symbol');
|
|
20
|
+
const [first, ...rest] = parts;
|
|
21
|
+
return rest.length ? `Your password needs ${first}, with ${rest.join(', ')}.` : `Your password needs ${first}.`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* PAY-1591: the IdP's reset refusal names WHICH rules failed as codes (error.details.rules). This is the closed set the
|
|
25
|
+
* portal accepts and the one plain sentence for each. Anything outside it is dropped, never displayed or forwarded.
|
|
26
|
+
*/
|
|
27
|
+
exports.PASSWORD_RULE_SENTENCES = {
|
|
28
|
+
min_length: 'Use a longer password.',
|
|
29
|
+
uppercase: 'Add an upper-case letter.',
|
|
30
|
+
lowercase: 'Add a lower-case letter.',
|
|
31
|
+
digit: 'Add a number.',
|
|
32
|
+
special: 'Add a symbol.',
|
|
33
|
+
unique_chars: 'Use more different characters.',
|
|
34
|
+
recent_password: "Choose a password you haven't used recently.",
|
|
35
|
+
};
|
|
36
|
+
function knownPasswordRules(rules) {
|
|
37
|
+
if (!Array.isArray(rules))
|
|
38
|
+
return [];
|
|
39
|
+
return rules.filter((r) => typeof r === 'string' && Object.prototype.hasOwnProperty.call(exports.PASSWORD_RULE_SENTENCES, r));
|
|
40
|
+
}
|
|
41
|
+
function passwordRuleSentences(rules) {
|
|
42
|
+
return rules.map((r) => exports.PASSWORD_RULE_SENTENCES[r]).join(' ');
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1438: what a USER-FACING handler may put in an error response.
|
|
3
|
+
*
|
|
4
|
+
* Several of these routes are reachable before authentication (session check, viability probe,
|
|
5
|
+
* logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
|
|
6
|
+
*
|
|
7
|
+
* Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
|
|
8
|
+
* JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
|
|
9
|
+
* a code and a correlationId; the log line carries the exception and the same id.
|
|
10
|
+
*
|
|
11
|
+
* Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
|
|
12
|
+
* never passed through whole. Only the display contract is projected: code, message, resolution,
|
|
13
|
+
* request_id - each only when it is a string. `details` (free-form, may carry validation internals
|
|
14
|
+
* or exception data) is dropped, except for the password-policy messages change-password's UI reads.
|
|
15
|
+
*/
|
|
16
|
+
export interface IdpErrorProjection {
|
|
17
|
+
code?: string;
|
|
18
|
+
message?: string;
|
|
19
|
+
resolution?: string;
|
|
20
|
+
request_id?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Log an exception with a fresh correlationId and return the id for the response body. */
|
|
23
|
+
export declare function logExceptionWithCorrelation(tag: string, err: unknown): string;
|
|
24
|
+
/** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
|
|
25
|
+
export declare function projectIdpError(json: unknown): IdpErrorProjection;
|
|
26
|
+
/**
|
|
27
|
+
* change-password's `details`: the security pages show `details.value[0].message` (else
|
|
28
|
+
* `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
|
|
29
|
+
* strings; drop everything else.
|
|
30
|
+
*/
|
|
31
|
+
export declare function projectPasswordPolicyDetails(details: unknown): {
|
|
32
|
+
message?: string;
|
|
33
|
+
value?: Array<{
|
|
34
|
+
message: string;
|
|
35
|
+
}>;
|
|
36
|
+
} | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
|
|
39
|
+
* reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
|
|
40
|
+
* SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
|
|
41
|
+
* whitelisted projection. The success path keeps its passthrough.
|
|
42
|
+
*/
|
|
43
|
+
export declare function idpErrorEnvelope(json: unknown, fallbackMessage?: string): {
|
|
44
|
+
success: false;
|
|
45
|
+
message: string;
|
|
46
|
+
error: {
|
|
47
|
+
code?: string;
|
|
48
|
+
message: string;
|
|
49
|
+
resolution?: string;
|
|
50
|
+
};
|
|
51
|
+
request_id?: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
|
|
55
|
+
* ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
|
|
56
|
+
* expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
|
|
57
|
+
* Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
|
|
58
|
+
* no code at all, and calling that 'link expired' loops the user.
|
|
59
|
+
*/
|
|
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
|
+
export declare function projectPasswordPolicyRules(json: unknown): string[] | undefined;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PAY-1438: what a USER-FACING handler may put in an error response.
|
|
4
|
+
*
|
|
5
|
+
* Several of these routes are reachable before authentication (session check, viability probe,
|
|
6
|
+
* logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
|
|
7
|
+
*
|
|
8
|
+
* Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
|
|
9
|
+
* JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
|
|
10
|
+
* a code and a correlationId; the log line carries the exception and the same id.
|
|
11
|
+
*
|
|
12
|
+
* Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
|
|
13
|
+
* never passed through whole. Only the display contract is projected: code, message, resolution,
|
|
14
|
+
* request_id - each only when it is a string. `details` (free-form, may carry validation internals
|
|
15
|
+
* or exception data) is dropped, except for the password-policy messages change-password's UI reads.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.logExceptionWithCorrelation = logExceptionWithCorrelation;
|
|
19
|
+
exports.projectIdpError = projectIdpError;
|
|
20
|
+
exports.projectPasswordPolicyDetails = projectPasswordPolicyDetails;
|
|
21
|
+
exports.idpErrorEnvelope = idpErrorEnvelope;
|
|
22
|
+
exports.projectResetTokenInvalid = projectResetTokenInvalid;
|
|
23
|
+
exports.projectPasswordPolicyRules = projectPasswordPolicyRules;
|
|
24
|
+
const password_rules_1 = require("./password-rules");
|
|
25
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
26
|
+
/** Log an exception with a fresh correlationId and return the id for the response body. */
|
|
27
|
+
function logExceptionWithCorrelation(tag, err) {
|
|
28
|
+
const correlationId = globalThis.crypto.randomUUID();
|
|
29
|
+
console.error(tag, { correlationId, error: err });
|
|
30
|
+
return correlationId;
|
|
31
|
+
}
|
|
32
|
+
/** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
|
|
33
|
+
function projectIdpError(json) {
|
|
34
|
+
if (!json || typeof json !== 'object')
|
|
35
|
+
return {};
|
|
36
|
+
const top = json;
|
|
37
|
+
const err = top.error && typeof top.error === 'object' ? top.error : {};
|
|
38
|
+
const support = err.support && typeof err.support === 'object' ? err.support : {};
|
|
39
|
+
const out = {};
|
|
40
|
+
const code = str(err.code) ?? str(top.code) ?? str(top.error_code);
|
|
41
|
+
const message = str(err.message) ?? str(top.message);
|
|
42
|
+
const resolution = str(err.resolution) ?? str(top.resolution);
|
|
43
|
+
const requestId = str(support.request_id) ?? str(top.request_id);
|
|
44
|
+
if (code !== undefined)
|
|
45
|
+
out.code = code;
|
|
46
|
+
if (message !== undefined)
|
|
47
|
+
out.message = message;
|
|
48
|
+
if (resolution !== undefined)
|
|
49
|
+
out.resolution = resolution;
|
|
50
|
+
if (requestId !== undefined)
|
|
51
|
+
out.request_id = requestId;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* change-password's `details`: the security pages show `details.value[0].message` (else
|
|
56
|
+
* `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
|
|
57
|
+
* strings; drop everything else.
|
|
58
|
+
*/
|
|
59
|
+
function projectPasswordPolicyDetails(details) {
|
|
60
|
+
if (!details || typeof details !== 'object')
|
|
61
|
+
return undefined;
|
|
62
|
+
const d = details;
|
|
63
|
+
const out = {};
|
|
64
|
+
if (Array.isArray(d.value)) {
|
|
65
|
+
const value = d.value
|
|
66
|
+
.map((v) => (v && typeof v === 'object' ? str(v.message) : undefined))
|
|
67
|
+
.filter((m) => m !== undefined)
|
|
68
|
+
.map((message) => ({ message }));
|
|
69
|
+
if (value.length)
|
|
70
|
+
out.value = value;
|
|
71
|
+
}
|
|
72
|
+
const message = str(d.message);
|
|
73
|
+
if (message !== undefined)
|
|
74
|
+
out.message = message;
|
|
75
|
+
return out.value || out.message !== undefined ? out : undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
|
|
79
|
+
* reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
|
|
80
|
+
* SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
|
|
81
|
+
* whitelisted projection. The success path keeps its passthrough.
|
|
82
|
+
*/
|
|
83
|
+
function idpErrorEnvelope(json, fallbackMessage = 'Request failed') {
|
|
84
|
+
const p = projectIdpError(json);
|
|
85
|
+
const message = p.message ?? fallbackMessage;
|
|
86
|
+
const error = { message };
|
|
87
|
+
if (p.code !== undefined)
|
|
88
|
+
error.code = p.code;
|
|
89
|
+
if (p.resolution !== undefined)
|
|
90
|
+
error.resolution = p.resolution;
|
|
91
|
+
const out = { success: false, message, error };
|
|
92
|
+
if (p.request_id !== undefined)
|
|
93
|
+
out.request_id = p.request_id;
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
|
|
98
|
+
* ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
|
|
99
|
+
* expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
|
|
100
|
+
* Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
|
|
101
|
+
* no code at all, and calling that 'link expired' loops the user.
|
|
102
|
+
*/
|
|
103
|
+
function projectResetTokenInvalid(json) {
|
|
104
|
+
if (!json || typeof json !== 'object')
|
|
105
|
+
return false;
|
|
106
|
+
const top = json;
|
|
107
|
+
const err = top.error && typeof top.error === 'object' ? top.error : {};
|
|
108
|
+
const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null);
|
|
109
|
+
if (!details)
|
|
110
|
+
return false;
|
|
111
|
+
if (details.code === 'token_expired' || details.code === 'token_used')
|
|
112
|
+
return true;
|
|
113
|
+
return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && v.code === 'InvalidToken');
|
|
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
|
+
function projectPasswordPolicyRules(json) {
|
|
122
|
+
if (!json || typeof json !== 'object')
|
|
123
|
+
return undefined;
|
|
124
|
+
const top = json;
|
|
125
|
+
const err = top.error && typeof top.error === 'object' ? top.error : {};
|
|
126
|
+
const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null);
|
|
127
|
+
if (!details || details.code !== 'password_policy')
|
|
128
|
+
return undefined;
|
|
129
|
+
return (0, password_rules_1.knownPasswordRules)(details.rules);
|
|
130
|
+
}
|