@payez/next-mvp 4.4.5 → 4.4.6
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 +6 -3
- 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 +14 -0
- package/dist/auth/better-auth.js +35 -49
- package/dist/auth/utils/idp-client.js +2 -1
- 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/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 +66 -45
- package/dist/lib/idp-fetch.js +1 -1
- 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 +45 -0
- package/dist/lib/two-factor-verify-failure.js +143 -0
- package/dist/middleware/create-middleware.d.ts +2 -1
- package/dist/middleware/create-middleware.js +43 -8
- 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 +2 -1
- 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/viability.d.ts +3 -2
- package/dist/routes/auth/viability.js +11 -7
- package/dist/server/auth.js +10 -6
- 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/sessions.js +2 -1
- package/package.json +2 -1
- package/scripts/verify-release-provenance.mjs +410 -0
- package/src/api/auth-handler.ts +5 -3
- package/src/api-handlers/account/change-password.ts +6 -3
- 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 +37 -55
- 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/hooks/useViabilitySession.ts +2 -1
- 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 +91 -45
- package/src/lib/idp-fetch.ts +1 -1
- 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 +167 -0
- package/src/middleware/create-middleware.ts +55 -9
- 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 +2 -1
- package/src/routes/auth/logout.ts +4 -2
- package/src/routes/auth/session.ts +16 -10
- package/src/routes/auth/viability.ts +11 -7
- package/src/server/auth.ts +10 -6
- 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/sessions.ts +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact-credentials - what the test-env debug pages may show of a credential: a stable hash and a length, never
|
|
3
|
+
* characters of the value (PAY-1470, BAPert rulings 59022 and 59040).
|
|
4
|
+
*
|
|
5
|
+
* PAY-1481 item 2 (QAPert, PR 186 review 59083): this was a DENYLIST (keys ending in token/secret/password/...).
|
|
6
|
+
* Opaque credentials under `tokens.access`, `redisSessionId`, `sid`, `authorization`, `cookie`, `backupCodes[]` and
|
|
7
|
+
* `code` passed unmasked. It is now an ALLOWLIST: a string is shown only when its key is a known DISPLAYABLE field
|
|
8
|
+
* (identity, roles, timestamps, provider and flag metadata, standard JWT claims). Every other string - an unknown
|
|
9
|
+
* key, an array item under an unknown key, a top-level string - becomes maskToken. A new field is hidden until
|
|
10
|
+
* someone decides it is displayable, which is the safe direction for a page reachable in production builds.
|
|
11
|
+
*
|
|
12
|
+
* Kept from the denylist era, and applied EVEN under a displayable key, because a credential can sit under an
|
|
13
|
+
* innocent name: a JWT (three base64url segments, length floors so "4.1.42" is not one), a PEM armour band, and the
|
|
14
|
+
* password in scheme://user:password@host. Numbers, booleans and null are never credentials and stay readable.
|
|
15
|
+
*
|
|
16
|
+
* There is no Reveal and no copy. "Never renders token material" includes click-to-reveal (59040).
|
|
17
|
+
*/
|
|
18
|
+
import { tokenLogId } from '../../lib/token-log-id';
|
|
19
|
+
|
|
20
|
+
/** A stable, non-reversible stand-in for a credential: `h:<12 hex> (<n> chars, hidden)`. */
|
|
21
|
+
export function maskToken(value: string): string {
|
|
22
|
+
return `${tokenLogId(value)} (${value.length} chars, hidden)`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keys whose string values are displayable. Exact names (case-insensitive), plus timestamp / flag suffix patterns.
|
|
27
|
+
* An exact listed key wins (tokenType is metadata); otherwise a key naming a credential (CREDENTIAL_NAME) is refused.
|
|
28
|
+
*/
|
|
29
|
+
const DISPLAYABLE_KEYS = new Set(
|
|
30
|
+
[
|
|
31
|
+
// identity
|
|
32
|
+
'id', 'userId', 'user_id', 'sub', 'email', 'name', 'given_name', 'family_name', 'preferred_username', 'image',
|
|
33
|
+
'picture', 'locale', 'phone_number_verified',
|
|
34
|
+
// authorisation metadata
|
|
35
|
+
'role', 'roles', 'scope', 'scopes', 'permissions', 'authenticationMethods', 'authenticationLevel', 'amr', 'acr',
|
|
36
|
+
'oauthProvider', 'provider', 'providerId', 'tokenType', 'token_type', 'type', 'typ', 'alg', 'kid', 'iss', 'aud',
|
|
37
|
+
'azp', 'jti',
|
|
38
|
+
// client / tenant
|
|
39
|
+
'clientId', 'client_id', 'idpClientId', 'clientSlug', 'slug', 'tenant', 'tenantId', 'merchantId', 'appSlug',
|
|
40
|
+
// result / status fields of the test-env routes
|
|
41
|
+
'status', 'statusText', 'success', 'action', 'message', 'error', 'reason', 'outcome', 'source', 'shape', 'mode',
|
|
42
|
+
].map((k) => k.toLowerCase()),
|
|
43
|
+
);
|
|
44
|
+
/** Timestamps, durations and booleans-as-strings by suffix: expires, accessTokenExpires, mfaCompletedAt, iat... */
|
|
45
|
+
const DISPLAYABLE_SUFFIX = /(expires|_at|iat|exp|nbf|hours|seconds|verified|required|enabled|count|version)$/i;
|
|
46
|
+
/** camelCase timestamps (createdAt, mfaCompletedAt): tested on the ORIGINAL key so `format` / `chat` do not match. */
|
|
47
|
+
const CAMEL_AT = /[a-z0-9]At$/;
|
|
48
|
+
/** A key that names a credential is never displayable, whatever suffix it has (e.g. refreshTokenCreatedAt stays OK
|
|
49
|
+
* because it names a TIME; `authorization`, `cookie`, `sid`, `code` are credentials). */
|
|
50
|
+
const CREDENTIAL_NAME = /(token|secret|password|passwd|apikey|api_key|credential|authorization|cookie|^sid$|session_?id$|^code$|codes?$|otp|pin$|signature|private)/i;
|
|
51
|
+
|
|
52
|
+
const URL_WITH_USERINFO = /^([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/i;
|
|
53
|
+
const PEM_BLOCK = /-----BEGIN [A-Z0-9][A-Z0-9 ]*-----/;
|
|
54
|
+
|
|
55
|
+
function looksLikeJwt(v: string): boolean {
|
|
56
|
+
if (v.length < 40) return false;
|
|
57
|
+
const parts = v.split('.');
|
|
58
|
+
return parts.length === 3 && parts.every((p) => p.length >= 8 && /^[A-Za-z0-9_-]+$/.test(p));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Is a string under this key displayable? Credential names lose even when a timestamp suffix matches. */
|
|
62
|
+
export function isDisplayableKey(keyName: string): boolean {
|
|
63
|
+
if (!keyName) return false;
|
|
64
|
+
const k = keyName.toLowerCase();
|
|
65
|
+
// 1. An exact, deliberately listed field (tokenType is metadata even though it says "token").
|
|
66
|
+
if (DISPLAYABLE_KEYS.has(k)) return true;
|
|
67
|
+
// 2. A key naming a credential is refused - unless what it names is a TIME about it (accessTokenExpires).
|
|
68
|
+
const namesTime = /(expires|_at|iat|exp|nbf|hours|seconds)$/i.test(k) || CAMEL_AT.test(keyName);
|
|
69
|
+
if (CREDENTIAL_NAME.test(k) && !namesTime) return false;
|
|
70
|
+
// 3. Metadata by suffix: timestamps, durations, flags, counts, versions, URLs (userinfo is still masked).
|
|
71
|
+
return DISPLAYABLE_SUFFIX.test(k) || CAMEL_AT.test(keyName) || /url$/i.test(k);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A deep copy of `value` in which every string not under a displayable key is replaced by maskToken. */
|
|
75
|
+
export function redactCredentials(value: unknown, keyName = ''): unknown {
|
|
76
|
+
// Array items inherit the array's key: backupCodes[] items are credentials, roles[] items are not.
|
|
77
|
+
if (Array.isArray(value)) return value.map((v) => redactCredentials(v, keyName));
|
|
78
|
+
if (value && typeof value === 'object') {
|
|
79
|
+
return Object.fromEntries(
|
|
80
|
+
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactCredentials(v, k)]),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
// NightHawk (PR 204 review): a numeric OTP / code under a credential-named key is a credential too.
|
|
84
|
+
if (typeof value === 'number' && keyName && !isDisplayableKey(keyName) && CREDENTIAL_NAME.test(keyName)) {
|
|
85
|
+
return maskToken(String(value));
|
|
86
|
+
}
|
|
87
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
88
|
+
if (looksLikeJwt(value)) return maskToken(value);
|
|
89
|
+
if (PEM_BLOCK.test(value)) return '••• PEM block redacted (' + value.length + ' chars) •••';
|
|
90
|
+
if (!isDisplayableKey(keyName)) return maskToken(value);
|
|
91
|
+
let shown = value;
|
|
92
|
+
if (URL_WITH_USERINFO.test(shown)) shown = shown.replace(URL_WITH_USERINFO, '$1$2:•••••@');
|
|
93
|
+
// NightHawk (PR 204 review): a URL's query and fragment carry tokens (?token=, #access_token=). Names stay, values go.
|
|
94
|
+
shown = shown.replace(/([?&#][^=&#\s]+=)[^&#\s]*/g, '$1•••');
|
|
95
|
+
return shown;
|
|
96
|
+
}
|
|
@@ -458,7 +458,8 @@ function VerifyCodeForm({ enrollHref }: { enrollHref: string }) {
|
|
|
458
458
|
if (!updateRes.ok) {
|
|
459
459
|
console.warn('[2FA] update-session returned non-OK status:', updateRes.status);
|
|
460
460
|
const errorData = await updateRes.json();
|
|
461
|
-
|
|
461
|
+
// PAY-1427: the request carried access_token/refresh_token; log the error envelope's shape, never its contents.
|
|
462
|
+
console.warn('[2FA] update-session error:', { keys: errorData && typeof errorData === 'object' ? Object.keys(errorData) : typeof errorData, code: errorData?.error?.code ?? errorData?.code });
|
|
462
463
|
} else {
|
|
463
464
|
console.log('[2FA] Session updated successfully with MFA tokens');
|
|
464
465
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
getCallbackUrlCookieName,
|
|
26
26
|
} from '../../lib/app-slug';
|
|
27
27
|
import { siteEvents, getClientIp } from '../../lib/site-logger';
|
|
28
|
+
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* POST /api/auth/logout - Sign out and clean up session
|
|
@@ -101,10 +102,11 @@ export async function POST(req: NextRequest) {
|
|
|
101
102
|
|
|
102
103
|
return response;
|
|
103
104
|
} catch (error) {
|
|
104
|
-
|
|
105
|
+
const correlationId = logExceptionWithCorrelation('[LOGOUT_ROUTE] Error during logout:', error);
|
|
105
106
|
return NextResponse.json({
|
|
106
107
|
error: 'Failed to logout',
|
|
107
|
-
|
|
108
|
+
code: 'INTERNAL_ERROR',
|
|
109
|
+
correlationId,
|
|
108
110
|
}, { status: 500 });
|
|
109
111
|
}
|
|
110
112
|
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import { NextRequest, NextResponse } from 'next/server';
|
|
17
17
|
import { getSession as getBetterAuthSession } from '../../server/auth';
|
|
18
18
|
import { getSession as getRedisSession, updateSession } from '../../lib/session-store';
|
|
19
|
+
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
20
|
+
import { tokenLogId } from '../../lib/token-log-id';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* GET /api/auth/session - Check current session status
|
|
@@ -42,7 +44,7 @@ export async function GET(req: NextRequest) {
|
|
|
42
44
|
userId: authSession.user?.id,
|
|
43
45
|
email: authSession.user?.email,
|
|
44
46
|
name: authSession.user?.name,
|
|
45
|
-
redisSessionId: redisSessionId ? redisSessionId
|
|
47
|
+
redisSessionId: redisSessionId ? tokenLogId(redisSessionId) : 'MISSING',
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
// Fetch full session data from Redis
|
|
@@ -70,7 +72,7 @@ export async function GET(req: NextRequest) {
|
|
|
70
72
|
if (!session) {
|
|
71
73
|
console.error('[SESSION_ROUTE] Redis session MISS — refusing to report authenticated', {
|
|
72
74
|
userId: authSession.user?.id,
|
|
73
|
-
redisSessionId: redisSessionId ? redisSessionId
|
|
75
|
+
redisSessionId: redisSessionId ? tokenLogId(redisSessionId) : 'MISSING',
|
|
74
76
|
reason: redisSessionId ? 'session expired or evicted' : 'no session token on auth session',
|
|
75
77
|
});
|
|
76
78
|
return NextResponse.json({});
|
|
@@ -98,20 +100,23 @@ export async function GET(req: NextRequest) {
|
|
|
98
100
|
idpClientId: session?.idpClientId,
|
|
99
101
|
merchantId: session?.merchantId,
|
|
100
102
|
},
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// PAY-1481 item 1: NO token material. This body is read by browser JS, so returning the session token and the
|
|
104
|
+
// IdP access/refresh tokens handed them to any script on the page (the refresh token outlives the session).
|
|
105
|
+
// Server routes read tokens from the session store; the UI only needs presence and expiry.
|
|
106
|
+
hasAccessToken: !!session?.idpAccessToken,
|
|
107
|
+
hasRefreshToken: !!session?.idpRefreshToken,
|
|
105
108
|
accessTokenExpires: session?.idpAccessTokenExpires,
|
|
106
109
|
expires: authSession.session?.expiresAt
|
|
107
110
|
? new Date(authSession.session.expiresAt).toISOString()
|
|
108
111
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
109
112
|
});
|
|
110
113
|
} catch (error) {
|
|
111
|
-
|
|
114
|
+
// PAY-1438: pre-auth route - the exception text stays in the log, keyed by correlationId.
|
|
115
|
+
const correlationId = logExceptionWithCorrelation('[SESSION_ROUTE] Error checking session:', error);
|
|
112
116
|
return NextResponse.json({
|
|
113
117
|
error: 'Failed to check session',
|
|
114
|
-
|
|
118
|
+
code: 'INTERNAL_ERROR',
|
|
119
|
+
correlationId,
|
|
115
120
|
}, { status: 500 });
|
|
116
121
|
}
|
|
117
122
|
}
|
|
@@ -247,10 +252,11 @@ export async function POST(req: NextRequest) {
|
|
|
247
252
|
message: 'Session updated successfully'
|
|
248
253
|
});
|
|
249
254
|
} catch (error) {
|
|
250
|
-
|
|
255
|
+
const correlationId = logExceptionWithCorrelation('[SESSION_ROUTE] Error updating session:', error);
|
|
251
256
|
return NextResponse.json({
|
|
252
257
|
error: 'Failed to update session',
|
|
253
|
-
|
|
258
|
+
code: 'INTERNAL_ERROR',
|
|
259
|
+
correlationId,
|
|
254
260
|
}, { status: 500 });
|
|
255
261
|
}
|
|
256
262
|
}
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
22
22
|
} from '../../lib/session-store';
|
|
23
23
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
24
|
+
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
25
|
+
import { tokenLogId } from '../../lib/token-log-id';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Get tenant-wide 2FA requirement from cached client config (from broker handshake)
|
|
@@ -175,7 +177,7 @@ export async function GET(req: NextRequest) {
|
|
|
175
177
|
viable: false,
|
|
176
178
|
needsRefresh: false,
|
|
177
179
|
authenticated: false,
|
|
178
|
-
sessionToken, //
|
|
180
|
+
sessionTokenId: tokenLogId(sessionToken), // PAY-1481: a correlation id, never the token; middleware only needs presence to clear the stale cookie
|
|
179
181
|
reason: 'Stale session - cookie exists but session not found in Redis'
|
|
180
182
|
});
|
|
181
183
|
}
|
|
@@ -309,7 +311,7 @@ export async function GET(req: NextRequest) {
|
|
|
309
311
|
viable: true,
|
|
310
312
|
needsRefresh: true,
|
|
311
313
|
authenticated: true,
|
|
312
|
-
sessionToken,
|
|
314
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
313
315
|
// Clear names for middleware decision-making
|
|
314
316
|
tenantRequiresTwoFactor,
|
|
315
317
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -351,7 +353,7 @@ export async function GET(req: NextRequest) {
|
|
|
351
353
|
|
|
352
354
|
// DEBUG: Log what we're reading from the session
|
|
353
355
|
console.log('[VIABILITY] Session 2FA state:', {
|
|
354
|
-
sessionToken: sessionToken
|
|
356
|
+
sessionToken: tokenLogId(sessionToken),
|
|
355
357
|
'session.mfaVerified': session?.mfaVerified,
|
|
356
358
|
'session.twoFactorComplete': (session as any)?.twoFactorComplete,
|
|
357
359
|
mfaVerifiedInSession,
|
|
@@ -379,7 +381,7 @@ export async function GET(req: NextRequest) {
|
|
|
379
381
|
expiresIn: 0,
|
|
380
382
|
hasRefreshToken,
|
|
381
383
|
authenticated: true,
|
|
382
|
-
sessionToken,
|
|
384
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
383
385
|
// Clear names
|
|
384
386
|
tenantRequiresTwoFactor,
|
|
385
387
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -403,7 +405,7 @@ export async function GET(req: NextRequest) {
|
|
|
403
405
|
expiresIn,
|
|
404
406
|
hasRefreshToken,
|
|
405
407
|
authenticated: true,
|
|
406
|
-
sessionToken,
|
|
408
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
407
409
|
// Clear names
|
|
408
410
|
tenantRequiresTwoFactor,
|
|
409
411
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -420,13 +422,15 @@ export async function GET(req: NextRequest) {
|
|
|
420
422
|
clientId: resolveClientId(session?.idpClientId),
|
|
421
423
|
});
|
|
422
424
|
} catch (error) {
|
|
423
|
-
|
|
425
|
+
// PAY-1438: pre-auth probe - the exception text stays in the log, keyed by correlationId.
|
|
426
|
+
const correlationId = logExceptionWithCorrelation('[VIABILITY_ROUTE] Error checking session viability:', error);
|
|
424
427
|
return NextResponse.json({
|
|
425
428
|
viable: false,
|
|
426
429
|
needsRefresh: false,
|
|
427
430
|
authenticated: false,
|
|
428
431
|
error: 'Failed to check session',
|
|
429
|
-
|
|
432
|
+
code: 'INTERNAL_ERROR',
|
|
433
|
+
correlationId
|
|
430
434
|
}, { status: 500 });
|
|
431
435
|
}
|
|
432
436
|
}
|
package/src/server/auth.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
14
14
|
type SessionData,
|
|
15
15
|
} from '../lib/session-store';
|
|
16
|
-
import {
|
|
16
|
+
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
17
17
|
import { createSingleFlight } from '../lib/single-flight';
|
|
18
18
|
|
|
19
19
|
let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
|
|
@@ -172,14 +172,18 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
172
172
|
return attachSessionData(betterAuthSession, sessionData, sessionToken);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
// Path B — Better Auth said null/incomplete. Fall back to
|
|
175
|
+
// Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
|
|
176
176
|
// decode + Redis-canonical liveness check. This catches cases where the
|
|
177
177
|
// canonical app session at `{slug}:{token}` is still alive but Better
|
|
178
178
|
// Auth's `ba:{slug}:{token}` secondary record was evicted or the
|
|
179
|
-
// in-memory cookie cache returned stale-null.
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
179
|
+
// in-memory cookie cache returned stale-null.
|
|
180
|
+
//
|
|
181
|
+
// PAY-1476: this path used to take the token with NO signature check, on the
|
|
182
|
+
// theory that "Redis lookup is the validation". It is not: a leaked token has
|
|
183
|
+
// a live record, so the token string alone authenticated, with any garbage in
|
|
184
|
+
// the signature position, and a forged JWS too. The token now comes only from
|
|
185
|
+
// the shared verified resolver (HMAC or JWS signature checked first).
|
|
186
|
+
const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
|
|
183
187
|
if (!sessionToken) return null;
|
|
184
188
|
|
|
185
189
|
let sessionData: SessionData | null = null;
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import 'server-only';
|
|
9
9
|
import { cookies } from 'next/headers';
|
|
10
|
-
import {
|
|
10
|
+
import { type JWTPayload } from 'jose';
|
|
11
11
|
import { getSession, type SessionData } from '../lib/session-store';
|
|
12
12
|
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
13
13
|
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
14
14
|
import { ensureInitialized } from '../lib/startup-init';
|
|
15
15
|
import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
|
|
16
|
+
import { resolveVerifiedSessionCookie } from './verified-session-token';
|
|
17
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
16
18
|
|
|
17
19
|
export interface DecodedSession {
|
|
18
20
|
sessionData: SessionData;
|
|
@@ -137,7 +139,7 @@ async function tryBetterAuthSession(
|
|
|
137
139
|
if (!idpTokens?.roles?.length) {
|
|
138
140
|
console.warn(
|
|
139
141
|
'[DECODE-SESSION] Leg 1 declining: idpTokens unusable (no roles) for session',
|
|
140
|
-
result.session.token
|
|
142
|
+
tokenLogId(result.session.token),
|
|
141
143
|
'— falling through to the sess: leg'
|
|
142
144
|
);
|
|
143
145
|
return null;
|
|
@@ -252,32 +254,20 @@ export async function decodeSession(
|
|
|
252
254
|
// death, because the ONLY fallback was structurally blind to the only
|
|
253
255
|
// shape it would ever be handed.
|
|
254
256
|
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
257
|
+
// PAY-1476: the shape still decides HOW to verify, but BOTH shapes are now
|
|
258
|
+
// verified, by the shared resolver. This branch used to take a BA-hmac
|
|
259
|
+
// cookie's token with no signature check ("Redis presence is the
|
|
260
|
+
// authority"), so `{leaked token}.{anything}` authenticated here - including
|
|
261
|
+
// behind the portal's admin API gate. The resolver checks the HMAC with
|
|
262
|
+
// timingSafeEqual (ba-hmac) or runs jwtVerify (jws) against the same
|
|
263
|
+
// env-first auth secret, and returns null on any failure.
|
|
260
264
|
const shaped = extractSessionTokenFromCookieValue(cookieValue);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (shaped?.shape === 'ba-hmac') {
|
|
266
|
-
sessionToken = shaped.token;
|
|
267
|
-
} else {
|
|
268
|
-
const secretKey = new TextEncoder().encode(secret);
|
|
269
|
-
try {
|
|
270
|
-
const result = await jwtVerify(cookieValue, secretKey);
|
|
271
|
-
payload = result.payload;
|
|
272
|
-
} catch (jwtError) {
|
|
273
|
-
console.warn('[DECODE-SESSION] JWT verification failed:', jwtError instanceof Error ? jwtError.message : String(jwtError));
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
sessionToken = (payload as any).sessionToken || (payload as any).redisSessionId;
|
|
277
|
-
}
|
|
265
|
+
const verified = await resolveVerifiedSessionCookie(cookieValue);
|
|
266
|
+
const sessionToken = verified?.token;
|
|
267
|
+
const payload: JWTPayload | undefined = verified?.payload;
|
|
278
268
|
|
|
279
269
|
if (!sessionToken) {
|
|
280
|
-
console.warn('[DECODE-SESSION]
|
|
270
|
+
console.warn('[DECODE-SESSION] Session cookie did not verify (shape:', shaped?.shape ?? 'unrecognized', ')');
|
|
281
271
|
return null;
|
|
282
272
|
}
|
|
283
273
|
|
|
@@ -292,7 +282,7 @@ export async function decodeSession(
|
|
|
292
282
|
// fixing a silent path.
|
|
293
283
|
console.warn(
|
|
294
284
|
'[DECODE-SESSION] No sess: record for token',
|
|
295
|
-
sessionToken
|
|
285
|
+
tokenLogId(sessionToken),
|
|
296
286
|
'— no fallback available, session will be signed out'
|
|
297
287
|
);
|
|
298
288
|
return null;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1476 / card 372275 (R941 s1b) - THE ONE SHARED VERIFIED RESOLVER for a session token taken from a cookie.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT (4.4.5, measured at the deployed portal by QAPert-NightHawk, mail 59030):
|
|
5
|
+
* Module A server/decode-session.ts the ba-hmac branch took `{token}.{anything}` and used segment 0 unread.
|
|
6
|
+
* Module B server/auth.ts Path B took the token from ANY recognised shape and never verified anything;
|
|
7
|
+
* a forged JWS resolved there too.
|
|
8
|
+
* Both then treated "a Redis record exists" as the validation. So a leaked session token string was a bearer
|
|
9
|
+
* credential with no signature needed, including on the portal's admin API gate (api-admin-gate -> decodeSession).
|
|
10
|
+
*
|
|
11
|
+
* THE RULE THIS MODULE ENFORCES: a cookie yields a session token ONLY after its signature checks out against
|
|
12
|
+
* the auth secret. Every caller that turns a cookie into a token for the session store awaits this function.
|
|
13
|
+
* lib/session-cookie-shape.ts stays classification-only and secret-free (it cannot verify, by construction).
|
|
14
|
+
*
|
|
15
|
+
* ba-hmac `{token}.{sig}` as Better Auth mints it (better-call signCookieValue): sig =
|
|
16
|
+
* base64(HMAC-SHA256(secret, token)), standard padding, 44 characters. Compared with
|
|
17
|
+
* crypto.timingSafeEqual over equal-length buffers.
|
|
18
|
+
* jws legacy NextAuth-era cookie: jose jwtVerify with the same secret, then the sessionToken
|
|
19
|
+
* (or redisSessionId) claim.
|
|
20
|
+
* anything else, a missing secret, or a failed check -> null. Never throws.
|
|
21
|
+
*
|
|
22
|
+
* THE SECRET is the auth secret from getIDPClientConfig(), which resolves env-first (BETTER_AUTH_SECRET,
|
|
23
|
+
* NEXTAUTH_SECRET, then IdP config; PAY-1460). The cookie HMAC key IS the auth secret, so the resolver and the
|
|
24
|
+
* config loader cannot disagree about which secret signs sessions (BAPert 59034).
|
|
25
|
+
*/
|
|
26
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
27
|
+
import { jwtVerify, type JWTPayload } from 'jose';
|
|
28
|
+
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
29
|
+
import { extractSessionCookieValue, extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
|
|
30
|
+
|
|
31
|
+
/** Better Auth's signature: base64 of a 32-byte HMAC-SHA256, padded = 44 chars ending in '='. */
|
|
32
|
+
const BA_SIGNATURE_LENGTH = 44;
|
|
33
|
+
|
|
34
|
+
async function authSecret(): Promise<string | null> {
|
|
35
|
+
try {
|
|
36
|
+
const config = await getIDPClientConfig();
|
|
37
|
+
return config?.authSecret ? String(config.authSecret) : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function verifyBaHmac(rawValue: string, secret: string): string | null {
|
|
44
|
+
const dot = rawValue.lastIndexOf('.');
|
|
45
|
+
if (dot < 1) return null;
|
|
46
|
+
const token = rawValue.slice(0, dot);
|
|
47
|
+
let signature = rawValue.slice(dot + 1);
|
|
48
|
+
if (signature.includes('%')) {
|
|
49
|
+
try {
|
|
50
|
+
signature = decodeURIComponent(signature);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (signature.length !== BA_SIGNATURE_LENGTH || !signature.endsWith('=')) return null;
|
|
56
|
+
const expected = createHmac('sha256', secret).update(token, 'utf8').digest('base64');
|
|
57
|
+
const a = Buffer.from(signature, 'utf8');
|
|
58
|
+
const b = Buffer.from(expected, 'utf8');
|
|
59
|
+
if (a.length !== b.length) return null;
|
|
60
|
+
return timingSafeEqual(a, b) ? token : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface VerifiedSessionCookie {
|
|
64
|
+
token: string;
|
|
65
|
+
shape: 'ba-hmac' | 'jws';
|
|
66
|
+
/** The verified JWS payload (jws shape only). */
|
|
67
|
+
payload?: JWTPayload;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function verifyJws(rawValue: string, secret: string): Promise<VerifiedSessionCookie | null> {
|
|
71
|
+
try {
|
|
72
|
+
const { payload } = await jwtVerify(rawValue, new TextEncoder().encode(secret));
|
|
73
|
+
const claim = (payload as Record<string, unknown>).sessionToken ?? (payload as Record<string, unknown>).redisSessionId;
|
|
74
|
+
return typeof claim === 'string' && claim ? { token: claim, shape: 'jws', payload } : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The verified session cookie (token, shape, JWS payload), or null unless its signature verifies. */
|
|
81
|
+
export async function resolveVerifiedSessionCookie(rawValue: string | null | undefined): Promise<VerifiedSessionCookie | null> {
|
|
82
|
+
if (!rawValue) return null;
|
|
83
|
+
const shaped = extractSessionTokenFromCookieValue(rawValue);
|
|
84
|
+
if (!shaped) return null;
|
|
85
|
+
const secret = await authSecret();
|
|
86
|
+
if (!secret) return null;
|
|
87
|
+
if (shaped.shape === 'ba-hmac') {
|
|
88
|
+
const token = verifyBaHmac(rawValue, secret);
|
|
89
|
+
return token ? { token, shape: 'ba-hmac' } : null;
|
|
90
|
+
}
|
|
91
|
+
return verifyJws(rawValue, secret);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The session token carried by a raw session-cookie value, or null unless its signature verifies. */
|
|
95
|
+
export async function resolveVerifiedSessionToken(rawValue: string | null | undefined): Promise<string | null> {
|
|
96
|
+
const verified = await resolveVerifiedSessionCookie(rawValue);
|
|
97
|
+
return verified ? verified.token : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Same, reading the session cookie (every name it is minted under, chunks joined) from a Request. */
|
|
101
|
+
export async function resolveVerifiedSessionTokenFromRequest(request: Request): Promise<string | null> {
|
|
102
|
+
return resolveVerifiedSessionToken(extractSessionCookieValue(request));
|
|
103
|
+
}
|
package/src/stores/authStore.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getCsrfCookieName,
|
|
27
27
|
getSecureCsrfCookieName
|
|
28
28
|
} from '../lib/app-slug';
|
|
29
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
29
30
|
|
|
30
31
|
// ===============================
|
|
31
32
|
// INTERFACES & TYPES
|
|
@@ -484,19 +485,28 @@ export const useAuthStore = create<AuthStore>()(
|
|
|
484
485
|
|
|
485
486
|
forceLogoutAndRedirect: async (reason: string) => {
|
|
486
487
|
const state = get();
|
|
487
|
-
|
|
488
|
+
// Same truncation convention as the server-side session logs (session-store.ts,
|
|
489
|
+
// refresh.ts): a session token is a bearer credential, and this line goes to the
|
|
490
|
+
// browser console (and whatever aggregates authLogger client-side) - full value
|
|
491
|
+
// was leftover debug logging (DotNetPert-Scout, mail 58678).
|
|
492
|
+
// PAY-1505: the ternary that used to stand here fell back to the RAW token on its false arm.
|
|
493
|
+
// Safe only by the unenforced property that the arm is reached when the token is falsy -
|
|
494
|
+
// exactly the shape the source guard exists to refuse. tokenLogId already answers 'none'
|
|
495
|
+
// for an absent token, so the fallback is not needed.
|
|
496
|
+
const sessionTokenPrefix = tokenLogId(state.session?.sessionToken);
|
|
497
|
+
|
|
488
498
|
// AGGRESSIVE LOGGING TO TRACK EXECUTION
|
|
489
499
|
console.error('🚨 FORCE LOGOUT INITIATED 🚨', {
|
|
490
500
|
reason,
|
|
491
501
|
userId: state.user?.id,
|
|
492
|
-
sessionToken:
|
|
502
|
+
sessionToken: sessionTokenPrefix,
|
|
493
503
|
timestamp: new Date().toISOString()
|
|
494
504
|
});
|
|
495
|
-
|
|
505
|
+
|
|
496
506
|
authLogger.error('[AuthStore] Force logout initiated', {
|
|
497
507
|
reason,
|
|
498
508
|
userId: state.user?.id,
|
|
499
|
-
sessionToken:
|
|
509
|
+
sessionToken: sessionTokenPrefix
|
|
500
510
|
});
|
|
501
511
|
|
|
502
512
|
try {
|
|
@@ -1870,7 +1880,8 @@ export const useAuthStore = create<AuthStore>()(
|
|
|
1870
1880
|
|
|
1871
1881
|
// If this event is about the current user, update their state
|
|
1872
1882
|
if (data.userId === state.user?.id && state.session) {
|
|
1873
|
-
|
|
1883
|
+
// PAY-1427: the event is user state; log its shape, not its contents.
|
|
1884
|
+
authLogger.info('[AuthStore] Updating current user state from SignalR:', { keys: Object.keys(data || {}) });
|
|
1874
1885
|
// ... user state update logic would go here
|
|
1875
1886
|
}
|
|
1876
1887
|
},
|
package/src/types/recovery.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type RecoveryStep = 'initiate' | 'select-method' | 'verify-code' | 'set-password' | 'complete';
|
|
1
|
+
export type RecoveryStep = 'initiate' | 'select-method' | 'verify-code' | 'set-password' | 'requested' | 'complete';
|
|
2
2
|
|
|
3
3
|
export interface RecoverySession {
|
|
4
4
|
recoveryToken: string;
|