@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
|
@@ -23,6 +23,7 @@ import { resolveCallbackDestination } from '../auth/safe-callback';
|
|
|
23
23
|
import { getInternalApiUrl } from '../edge/internal-api-url';
|
|
24
24
|
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
25
25
|
import { checkPagePermission, isRBACEnabled } from './rbac-check';
|
|
26
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
26
27
|
|
|
27
28
|
// =============================================================================
|
|
28
29
|
// TYPES
|
|
@@ -40,7 +41,8 @@ export interface AuthContext {
|
|
|
40
41
|
|
|
41
42
|
export interface SessionPointer {
|
|
42
43
|
exists: boolean;
|
|
43
|
-
|
|
44
|
+
/** PAY-1481: tokenLogId of the session token (presence + log correlation), never the token itself. */
|
|
45
|
+
sessionTokenId?: string | null;
|
|
44
46
|
expired?: boolean;
|
|
45
47
|
hasRefreshToken?: boolean;
|
|
46
48
|
roles: string[];
|
|
@@ -208,6 +210,40 @@ function redirectTo(
|
|
|
208
210
|
return response;
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Terminal response for a FAILED token refresh.
|
|
215
|
+
*
|
|
216
|
+
* For `/api/` requests a redirect is the wrong terminator: fetch() follows the 307,
|
|
217
|
+
* receives 200 + an HTML login page, and the caller parses garbage instead of
|
|
218
|
+
* branching on an error. Those callers get a 401 JSON body they can branch on;
|
|
219
|
+
* document requests keep the existing redirect exactly as before.
|
|
220
|
+
*
|
|
221
|
+
* Cookie-clearing is preserved on BOTH arms. That is load-bearing, not incidental:
|
|
222
|
+
* on a permanent failure (401/403) stale cookies must be cleared or the next request
|
|
223
|
+
* refreshes again and loops (see the CRITICAL note in handleRefresh). A JSON arm that
|
|
224
|
+
* skipped clearing would convert a redirect loop into a silent refresh loop.
|
|
225
|
+
*
|
|
226
|
+
* Scoped deliberately to handleRefresh's failure exits. It is NOT folded into
|
|
227
|
+
* redirectTo(), and NOT hoisted above the refresh decision: an `/api/` hatch placed
|
|
228
|
+
* higher would intercept 'refresh_needed' and disable middleware token refresh across
|
|
229
|
+
* the whole API surface (card 340024 C1).
|
|
230
|
+
*/
|
|
231
|
+
function refreshFailureResponse(
|
|
232
|
+
request: NextRequest,
|
|
233
|
+
location: string,
|
|
234
|
+
errorCode: string,
|
|
235
|
+
clearCookies = false
|
|
236
|
+
): NextResponse {
|
|
237
|
+
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
238
|
+
const response = NextResponse.json({ error: errorCode }, { status: 401 });
|
|
239
|
+
if (clearCookies) {
|
|
240
|
+
clearSessionCookies(response, request);
|
|
241
|
+
}
|
|
242
|
+
return response;
|
|
243
|
+
}
|
|
244
|
+
return redirectTo(request, location, clearCookies);
|
|
245
|
+
}
|
|
246
|
+
|
|
211
247
|
// =============================================================================
|
|
212
248
|
// AUTH DECISION ENGINE
|
|
213
249
|
// =============================================================================
|
|
@@ -423,9 +459,16 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
423
459
|
// The warning is deliberately KEPT: it is the only operational signal that
|
|
424
460
|
// stale sessions are occurring, and it costs nothing to log before
|
|
425
461
|
// delegating. Do not re-introduce a `return` in this block.
|
|
426
|
-
if (!isPublic && sessionStatus.exists === false && sessionPointer.
|
|
462
|
+
if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionTokenId) {
|
|
463
|
+
// PAY-1505: the KEY is `sessionTokenId`, not `sessionToken`. The value has
|
|
464
|
+
// always been a tokenLogId fingerprint (viability.ts builds it as
|
|
465
|
+
// `tokenLogId(sessionToken)`), but labelling it `sessionToken` told any
|
|
466
|
+
// operator reading this log that the line carried the token itself. The
|
|
467
|
+
// credential-in-logs source guard reads the key for exactly that reason:
|
|
468
|
+
// a field named `sessionToken` must hold a safe value, and proving that
|
|
469
|
+
// across an API boundary is not possible from the name alone.
|
|
427
470
|
log.warn('Stale session detected (delegating to decision engine)', {
|
|
428
|
-
|
|
471
|
+
sessionTokenId: sessionPointer.sessionTokenId,
|
|
429
472
|
pathname
|
|
430
473
|
});
|
|
431
474
|
}
|
|
@@ -470,7 +513,7 @@ async function checkViability(
|
|
|
470
513
|
};
|
|
471
514
|
const sessionPointer: SessionPointer = {
|
|
472
515
|
exists: false,
|
|
473
|
-
|
|
516
|
+
sessionTokenId: undefined,
|
|
474
517
|
expired: false,
|
|
475
518
|
hasRefreshToken: false,
|
|
476
519
|
roles: [],
|
|
@@ -493,11 +536,11 @@ async function checkViability(
|
|
|
493
536
|
if (response.ok) {
|
|
494
537
|
const data = await response.json();
|
|
495
538
|
// Support both response formats (viability and refresh-viability)
|
|
496
|
-
sessionStatus.exists = data.authenticated ?? (data.
|
|
539
|
+
sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
|
|
497
540
|
sessionStatus.requires2FA = data.requires2FA || false;
|
|
498
541
|
sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
|
|
499
542
|
sessionPointer.exists = sessionStatus.exists ?? false;
|
|
500
|
-
sessionPointer.
|
|
543
|
+
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
501
544
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
502
545
|
sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
|
|
503
546
|
sessionPointer.roles = data.roles || [];
|
|
@@ -630,9 +673,10 @@ async function handleRefresh(
|
|
|
630
673
|
// Check if circuit breaker allows refresh attempt
|
|
631
674
|
if (!cb.canAttemptRefresh()) {
|
|
632
675
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
633
|
-
return
|
|
676
|
+
return refreshFailureResponse(
|
|
634
677
|
request,
|
|
635
678
|
`${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`,
|
|
679
|
+
'ServiceUnavailable',
|
|
636
680
|
true
|
|
637
681
|
);
|
|
638
682
|
}
|
|
@@ -677,9 +721,10 @@ async function handleRefresh(
|
|
|
677
721
|
|
|
678
722
|
onRefreshFailure?.(response.status, false);
|
|
679
723
|
|
|
680
|
-
return
|
|
724
|
+
return refreshFailureResponse(
|
|
681
725
|
request,
|
|
682
726
|
`${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`,
|
|
727
|
+
errorParam,
|
|
683
728
|
isPermanentFailure
|
|
684
729
|
);
|
|
685
730
|
} catch (error: any) {
|
|
@@ -697,9 +742,10 @@ async function handleRefresh(
|
|
|
697
742
|
onRefreshFailure?.(0, isNetwork);
|
|
698
743
|
|
|
699
744
|
// Network error - don't clear cookies, might be transient
|
|
700
|
-
return
|
|
745
|
+
return refreshFailureResponse(
|
|
701
746
|
request,
|
|
702
747
|
`${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=RefreshError`,
|
|
748
|
+
'RefreshError',
|
|
703
749
|
false
|
|
704
750
|
);
|
|
705
751
|
}
|
|
@@ -9,6 +9,7 @@ import { SelectMethodStep } from '../../components/recovery/SelectMethodStep';
|
|
|
9
9
|
import { VerifyCodeStep } from '../../components/recovery/VerifyCodeStep';
|
|
10
10
|
import { SetPasswordStep } from '../../components/recovery/SetPasswordStep';
|
|
11
11
|
import { CompleteStep } from '../../components/recovery/CompleteStep';
|
|
12
|
+
import { RequestedStep } from '../../components/recovery/RequestedStep';
|
|
12
13
|
import { useColors } from '../../theme/useTheme';
|
|
13
14
|
|
|
14
15
|
function RecoveryContent() {
|
|
@@ -24,7 +25,12 @@ function RecoveryContent() {
|
|
|
24
25
|
const [verificationCode, setVerificationCode] = useState('');
|
|
25
26
|
const [passwordResetToken, setPasswordResetToken] = useState<PasswordResetToken | null>(null);
|
|
26
27
|
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
// PAY-1591: true when the reset failed because the recovery itself is dead; the error box then offers a way back.
|
|
29
|
+
const [recoveryExpired, setRecoveryExpired] = useState(false);
|
|
27
30
|
const [loading, setLoading] = useState(false);
|
|
31
|
+
// PAY-1588: true only after the IdP accepted the new password. The 'complete' step is ALSO reached from initiate()
|
|
32
|
+
// when no recovery session comes back, and that path must not tell the user a password was set.
|
|
33
|
+
const [resetSucceeded, setResetSucceeded] = useState(false);
|
|
28
34
|
|
|
29
35
|
const handleInitiateRecovery = async () => {
|
|
30
36
|
setLoading(true);
|
|
@@ -33,7 +39,7 @@ function RecoveryContent() {
|
|
|
33
39
|
try {
|
|
34
40
|
const response = await accountApi.initiateRecovery(email);
|
|
35
41
|
|
|
36
|
-
if (response.success && response.data
|
|
42
|
+
if (response.success && response.data?.recovery_session_token) {
|
|
37
43
|
setRecoverySession({
|
|
38
44
|
recoveryToken: response.data.recovery_session_token,
|
|
39
45
|
email: email,
|
|
@@ -45,7 +51,8 @@ function RecoveryContent() {
|
|
|
45
51
|
});
|
|
46
52
|
setCurrentStep('select-method');
|
|
47
53
|
} else {
|
|
48
|
-
|
|
54
|
+
// No recovery session (unknown / locked / throttled): the ONE neutral step, never CompleteStep (rigpert 60711).
|
|
55
|
+
setCurrentStep('requested');
|
|
49
56
|
}
|
|
50
57
|
} catch (err) {
|
|
51
58
|
setError(err instanceof Error ? err.message : 'Failed to initiate recovery. Please try again.');
|
|
@@ -110,6 +117,7 @@ function RecoveryContent() {
|
|
|
110
117
|
|
|
111
118
|
setLoading(true);
|
|
112
119
|
setError(null);
|
|
120
|
+
setRecoveryExpired(false);
|
|
113
121
|
|
|
114
122
|
try {
|
|
115
123
|
const response = await accountApi.resetPasswordWithToken(
|
|
@@ -120,12 +128,14 @@ function RecoveryContent() {
|
|
|
120
128
|
);
|
|
121
129
|
|
|
122
130
|
if (response.success) {
|
|
131
|
+
setResetSucceeded(true);
|
|
123
132
|
setCurrentStep('complete');
|
|
124
133
|
} else {
|
|
125
134
|
setError('Failed to reset password. Please try again.');
|
|
126
135
|
}
|
|
127
136
|
} catch (err) {
|
|
128
137
|
setError(err instanceof Error ? err.message : 'Failed to reset password.');
|
|
138
|
+
setRecoveryExpired(!!err && (err as { code?: string }).code === 'RECOVERY_EXPIRED');
|
|
129
139
|
} finally {
|
|
130
140
|
setLoading(false);
|
|
131
141
|
}
|
|
@@ -143,6 +153,11 @@ function RecoveryContent() {
|
|
|
143
153
|
{error && (
|
|
144
154
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-2xl p-3">
|
|
145
155
|
<p className="text-red-700 text-sm">{error}</p>
|
|
156
|
+
{recoveryExpired && (
|
|
157
|
+
<a href="/account-auth/recovery" className="text-sm font-medium underline">
|
|
158
|
+
Start password recovery again
|
|
159
|
+
</a>
|
|
160
|
+
)}
|
|
146
161
|
</div>
|
|
147
162
|
)}
|
|
148
163
|
|
|
@@ -191,13 +206,15 @@ function RecoveryContent() {
|
|
|
191
206
|
/>
|
|
192
207
|
)}
|
|
193
208
|
|
|
209
|
+
{currentStep === 'requested' && <RequestedStep onGoToLogin={() => router.push('/account-auth/login')} />}
|
|
210
|
+
|
|
194
211
|
{currentStep === 'complete' && (
|
|
195
212
|
<CompleteStep
|
|
196
|
-
onGoToLogin={() => router.push('/account-auth/login')}
|
|
213
|
+
onGoToLogin={() => router.push(resetSucceeded ? '/account-auth/login?notice=password-set' : '/account-auth/login')}
|
|
197
214
|
/>
|
|
198
215
|
)}
|
|
199
216
|
|
|
200
|
-
{currentStep !== 'complete' && currentStep !== 'initiate' && !loading && (
|
|
217
|
+
{currentStep !== 'complete' && currentStep !== 'requested' && currentStep !== 'initiate' && !loading && (
|
|
201
218
|
<div className="mt-6 text-center">
|
|
202
219
|
<button
|
|
203
220
|
type="button"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { authClient } from '../../client/better-auth-client';
|
|
4
4
|
import { useState, useEffect } from 'react';
|
|
5
|
+
import { maskToken, redactCredentials } from './redact-credentials';
|
|
5
6
|
|
|
6
7
|
// Decode JWT header (contains kid, alg, typ)
|
|
7
8
|
function decodeJwtHeader(token: string): any {
|
|
@@ -70,7 +71,6 @@ export function JwtInspectPage() {
|
|
|
70
71
|
const { data: sessionData, isPending } = authClient.useSession();
|
|
71
72
|
const session = sessionData;
|
|
72
73
|
const status = isPending ? 'loading' : session ? 'authenticated' : 'unauthenticated';
|
|
73
|
-
const [copied, setCopied] = useState<string | null>(null);
|
|
74
74
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
75
75
|
const [jwtHeader, setJwtHeader] = useState<any>(null);
|
|
76
76
|
const [jwtPayload, setJwtPayload] = useState<any>(null);
|
|
@@ -109,116 +109,10 @@ export function JwtInspectPage() {
|
|
|
109
109
|
}
|
|
110
110
|
}, [serverSession, session]);
|
|
111
111
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// click from the clipboard. That is the precondition the 246007 finding needs
|
|
117
|
-
// ("a caller who knows a session token can act on a session it does not own"),
|
|
118
|
-
// so this page was the supply side of the attack the same package was being
|
|
119
|
-
// hardened against. A screen-share, a screenshot or a support call was enough.
|
|
120
|
-
//
|
|
121
|
-
// The fix is deliberately NOT removing the tokens or the copy button: replaying
|
|
122
|
-
// a bearer in curl is the entire reason this tool exists, and a debug tool that
|
|
123
|
-
// cannot show you the thing you are debugging just gets replaced by a worse
|
|
124
|
-
// ad-hoc one. What changes is that exposure now requires an INTENTIONAL ACT per
|
|
125
|
-
// token, so the default state of a loaded page - the state a bystander, a
|
|
126
|
-
// screen-share or a screenshot captures - carries no usable credential.
|
|
127
|
-
//
|
|
128
|
-
// NOTE: this is a UI affordance, not an access control. Anyone who can load
|
|
129
|
-
// this page can still click reveal, and the values are in the network response
|
|
130
|
-
// either way. It shrinks accidental disclosure; it does not gate the route.
|
|
131
|
-
// The gate is mounting - see this file's docstring and index.ts.
|
|
132
|
-
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
|
133
|
-
|
|
134
|
-
const maskToken = (value: string): string => {
|
|
135
|
-
if (value.length <= 12) return '\u2022'.repeat(Math.max(value.length, 8));
|
|
136
|
-
return `${value.slice(0, 6)}${'\u2022'.repeat(12)}${value.slice(-4)} (${value.length} chars, hidden)`;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
// Keys whose STRING values are live credentials. Anchored at the END of the key
|
|
140
|
-
// name on purpose: it catches sessionToken / accessToken / refreshToken /
|
|
141
|
-
// idpRefreshToken, while deliberately NOT catching the metadata beside them -
|
|
142
|
-
// accessTokenExpires, idpAccessTokenExpires, tokenType - which are safe and are
|
|
143
|
-
// the fields you actually want when debugging an expiry problem.
|
|
144
|
-
const CREDENTIAL_KEY = /(token|secret|password|apikey|credential)$/i;
|
|
145
|
-
|
|
146
|
-
// VALUE-SHAPE checks, because a key-name sweep has a blind spot that this page
|
|
147
|
-
// is squarely in: a credential under an innocuous key. Scout hit the same class
|
|
148
|
-
// elsewhere today (35297) - a postgres URL whose only key-name hit was a
|
|
149
|
-
// POINTER to the secret, while the actual password sat in the URL value and the
|
|
150
|
-
// sweep scored the file SAFE. Here the equivalents are a raw JWT parked under
|
|
151
|
-
// `jwt` / `bearer` / `authorization` / `cookie` - none of which end in `token`.
|
|
152
|
-
//
|
|
153
|
-
// JWT: three base64url segments. The length floors matter - without them
|
|
154
|
-
// "4.1.42" matches the three-segment shape and the package version would render
|
|
155
|
-
// as a redacted credential, which teaches people the redactor is broken.
|
|
156
|
-
const looksLikeJwt = (v: string): boolean => {
|
|
157
|
-
if (v.length < 40) return false;
|
|
158
|
-
const parts = v.split('.');
|
|
159
|
-
return parts.length === 3 && parts.every((p) => p.length >= 8 && /^[A-Za-z0-9_-]+$/.test(p));
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
// scheme://user:password@host - redact ONLY the password and keep the rest, so
|
|
163
|
-
// the URL stays usable for debugging. Masking the whole value would hide which
|
|
164
|
-
// host you are pointed at, which is usually the thing you opened this page for.
|
|
165
|
-
const URL_WITH_USERINFO = /^([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/i;
|
|
166
|
-
|
|
167
|
-
// Third value-shape check, on DotNetPert-Scout's 251951 rule: "a content-pattern
|
|
168
|
-
// sweep CANNOT find a credential that has no pattern." A PEM block defeats BOTH
|
|
169
|
-
// checks above and the key-name check as well - it has no key=value pair, its key
|
|
170
|
-
// name is usually something innocuous like `cert`, `pem`, `chain` or `data`, it is
|
|
171
|
-
// not three base64url segments so looksLikeJwt rejects it, and it is not a URL.
|
|
172
|
-
// He found a live wildcard TLS private key that had sat committed for 215 days for
|
|
173
|
-
// exactly this reason: it was never in anyone's search space, missed by
|
|
174
|
-
// construction rather than by carelessness.
|
|
175
|
-
//
|
|
176
|
-
// HONEST SCOPE: this is defence-in-depth, not a measured exposure. I have not seen
|
|
177
|
-
// PEM material in a session object and would not expect to. It is here because this
|
|
178
|
-
// dump renders WHATEVER the session carries, and the entire lesson of the two
|
|
179
|
-
// previous hardenings on this function is that the next credential arrives in a
|
|
180
|
-
// shape the current checks were not written for. Matching the armour band rather
|
|
181
|
-
// than the payload costs one regex.
|
|
182
|
-
// Matches the PEM ARMOUR BAND itself rather than enumerating key types. A first
|
|
183
|
-
// draft listed PRIVATE KEY / CERTIFICATE / RSA / EC / OPENSSH / PGP and a test
|
|
184
|
-
// caught it missing plain "-----BEGIN CERTIFICATE-----", because that header has
|
|
185
|
-
// no space before the closing dashes while "-----BEGIN RSA PRIVATE KEY-----" does.
|
|
186
|
-
// Enumerating the payload types is the same mistake as enumerating credential key
|
|
187
|
-
// names, one level down: it only ever covers the shapes already thought of.
|
|
188
|
-
// Requiring an alphanumeric after "BEGIN " keeps prose containing the word, and a
|
|
189
|
-
// bare "-----BEGIN-----", from matching.
|
|
190
|
-
const PEM_BLOCK = /-----BEGIN [A-Z0-9][A-Z0-9 ]*-----/;
|
|
191
|
-
|
|
192
|
-
// 248991 item 3, SECOND HALF. The masked fields above were only half the fix:
|
|
193
|
-
// the Raw Session Data block below re-rendered the SAME tokens in plain text via
|
|
194
|
-
// JSON.stringify, one click away behind a neutral "Click to expand" summary - and
|
|
195
|
-
// with them idpRefreshToken, which has NO other raw surface on this page (it is
|
|
196
|
-
// rendered as a boolean at the Refresh Token row precisely because it is the
|
|
197
|
-
// longest-lived credential of the three). Redacting per-field while dumping the
|
|
198
|
-
// whole object verbatim is not a partial fix, it is the appearance of one.
|
|
199
|
-
const redactCredentials = (value: unknown, keyName = ''): unknown => {
|
|
200
|
-
if (Array.isArray(value)) return value.map((v) => redactCredentials(v));
|
|
201
|
-
if (value && typeof value === 'object') {
|
|
202
|
-
return Object.fromEntries(
|
|
203
|
-
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactCredentials(v, k)]),
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
if (typeof value !== 'string' || value.length === 0) return value;
|
|
207
|
-
if (CREDENTIAL_KEY.test(keyName)) return maskToken(value);
|
|
208
|
-
// Key-name matching alone is not enough (DotNetPert-Scout 35297): a credential
|
|
209
|
-
// can sit under a key that is not credential-shaped at all. Two value-shape
|
|
210
|
-
// checks close the cases that actually reach a session object:
|
|
211
|
-
if (looksLikeJwt(value)) return maskToken(value);
|
|
212
|
-
if (PEM_BLOCK.test(value)) return '••• PEM block redacted (' + value.length + ' chars) •••';
|
|
213
|
-
if (URL_WITH_USERINFO.test(value)) return value.replace(URL_WITH_USERINFO, '$1$2:•••••@');
|
|
214
|
-
return value;
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const copyToClipboard = (text: string, label: string) => {
|
|
218
|
-
navigator.clipboard.writeText(text);
|
|
219
|
-
setCopied(label);
|
|
220
|
-
setTimeout(() => setCopied(null), 2000);
|
|
221
|
-
};
|
|
112
|
+
// PAY-1470 (BAPert rulings 59022, 59040): credentials are shown as tokenLogId + length ONLY. The Reveal toggles
|
|
113
|
+
// (per token and for the raw session JSON) and the Copy buttons are gone: "never renders token material"
|
|
114
|
+
// includes click-to-reveal, because a full bearer one click away on a prod-reachable page is the same defect.
|
|
115
|
+
// The redaction rules (key names, JWT / PEM / URL-password shapes) live in ./redact-credentials.
|
|
222
116
|
|
|
223
117
|
if (status === 'loading') {
|
|
224
118
|
return (
|
|
@@ -371,58 +265,18 @@ export function JwtInspectPage() {
|
|
|
371
265
|
<div>
|
|
372
266
|
<div className="flex items-center justify-between mb-2">
|
|
373
267
|
<span className={labelClass}>Session Token (Redis Key):</span>
|
|
374
|
-
{displayExt.sessionToken && (
|
|
375
|
-
<div className="flex items-center gap-2">
|
|
376
|
-
<RevealButton
|
|
377
|
-
revealed={!!revealed.session}
|
|
378
|
-
onClick={() => setRevealed((r) => ({ ...r, session: !r.session }))}
|
|
379
|
-
isDarkMode={isDarkMode}
|
|
380
|
-
/>
|
|
381
|
-
{revealed.session && (
|
|
382
|
-
<CopyButton
|
|
383
|
-
onClick={() => copyToClipboard(displayExt.sessionToken, 'session')}
|
|
384
|
-
copied={copied === 'session'}
|
|
385
|
-
isDarkMode={isDarkMode}
|
|
386
|
-
/>
|
|
387
|
-
)}
|
|
388
|
-
</div>
|
|
389
|
-
)}
|
|
390
268
|
</div>
|
|
391
269
|
<code className={`block p-2 rounded text-xs break-all ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
|
|
392
|
-
{displayExt.sessionToken
|
|
393
|
-
? revealed.session
|
|
394
|
-
? displayExt.sessionToken
|
|
395
|
-
: maskToken(displayExt.sessionToken)
|
|
396
|
-
: 'N/A'}
|
|
270
|
+
{displayExt.sessionToken ? maskToken(displayExt.sessionToken) : 'N/A'}
|
|
397
271
|
</code>
|
|
398
272
|
</div>
|
|
399
273
|
|
|
400
274
|
<div>
|
|
401
275
|
<div className="flex items-center justify-between mb-2">
|
|
402
276
|
<span className={labelClass}>Access Token (IDP):</span>
|
|
403
|
-
{displayExt.accessToken && (
|
|
404
|
-
<div className="flex items-center gap-2">
|
|
405
|
-
<RevealButton
|
|
406
|
-
revealed={!!revealed.access}
|
|
407
|
-
onClick={() => setRevealed((r) => ({ ...r, access: !r.access }))}
|
|
408
|
-
isDarkMode={isDarkMode}
|
|
409
|
-
/>
|
|
410
|
-
{revealed.access && (
|
|
411
|
-
<CopyButton
|
|
412
|
-
onClick={() => copyToClipboard(displayExt.accessToken, 'access')}
|
|
413
|
-
copied={copied === 'access'}
|
|
414
|
-
isDarkMode={isDarkMode}
|
|
415
|
-
/>
|
|
416
|
-
)}
|
|
417
|
-
</div>
|
|
418
|
-
)}
|
|
419
277
|
</div>
|
|
420
278
|
<code className={`block p-2 rounded text-xs break-all max-h-24 overflow-auto ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
|
|
421
|
-
{displayExt.accessToken
|
|
422
|
-
? revealed.access
|
|
423
|
-
? displayExt.accessToken
|
|
424
|
-
: maskToken(displayExt.accessToken)
|
|
425
|
-
: 'N/A'}
|
|
279
|
+
{displayExt.accessToken ? maskToken(displayExt.accessToken) : 'N/A'}
|
|
426
280
|
</code>
|
|
427
281
|
</div>
|
|
428
282
|
|
|
@@ -465,7 +319,7 @@ export function JwtInspectPage() {
|
|
|
465
319
|
<InfoRow
|
|
466
320
|
key={key}
|
|
467
321
|
label={key}
|
|
468
|
-
value={typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
|
322
|
+
value={typeof value === 'object' ? JSON.stringify(redactCredentials(value)) : String(redactCredentials(value, key))}
|
|
469
323
|
labelClass={labelClass}
|
|
470
324
|
valueClass={valueClass}
|
|
471
325
|
/>
|
|
@@ -531,26 +385,13 @@ export function JwtInspectPage() {
|
|
|
531
385
|
<summary className="font-semibold cursor-pointer">
|
|
532
386
|
Raw Session Data — credentials redacted (Click to expand)
|
|
533
387
|
</summary>
|
|
534
|
-
<div className="mt-3
|
|
535
|
-
<RevealButton
|
|
536
|
-
revealed={!!revealed.raw}
|
|
537
|
-
onClick={() => setRevealed((r) => ({ ...r, raw: !r.raw }))}
|
|
538
|
-
isDarkMode={isDarkMode}
|
|
539
|
-
/>
|
|
388
|
+
<div className="mt-3">
|
|
540
389
|
<span className={`text-xs ${labelClass}`}>
|
|
541
|
-
|
|
542
|
-
? 'Showing LIVE credentials in plain text, including the refresh token.'
|
|
543
|
-
: 'Token values are masked. Structure, claims and expiry fields are unchanged.'}
|
|
390
|
+
Token values are masked. Structure, claims and expiry fields are unchanged.
|
|
544
391
|
</span>
|
|
545
392
|
</div>
|
|
546
393
|
<pre className={`mt-4 text-xs overflow-auto p-3 rounded ${isDarkMode ? 'bg-slate-800' : 'bg-gray-100'}`}>
|
|
547
|
-
{JSON.stringify(
|
|
548
|
-
revealed.raw
|
|
549
|
-
? { clientSession: session, serverSession: serverSession }
|
|
550
|
-
: redactCredentials({ clientSession: session, serverSession: serverSession }),
|
|
551
|
-
null,
|
|
552
|
-
2,
|
|
553
|
-
)}
|
|
394
|
+
{JSON.stringify(redactCredentials({ clientSession: session, serverSession: serverSession }), null, 2)}
|
|
554
395
|
</pre>
|
|
555
396
|
</details>
|
|
556
397
|
</div>
|
|
@@ -604,68 +445,5 @@ function StatusBadge({
|
|
|
604
445
|
);
|
|
605
446
|
}
|
|
606
447
|
|
|
607
|
-
function CopyButton({
|
|
608
|
-
onClick,
|
|
609
|
-
copied,
|
|
610
|
-
isDarkMode,
|
|
611
|
-
}: {
|
|
612
|
-
onClick: () => void;
|
|
613
|
-
copied: boolean;
|
|
614
|
-
isDarkMode: boolean;
|
|
615
|
-
}) {
|
|
616
|
-
return (
|
|
617
|
-
<button
|
|
618
|
-
onClick={onClick}
|
|
619
|
-
className={`px-2 py-1 rounded text-xs ${
|
|
620
|
-
copied
|
|
621
|
-
? 'bg-green-600 text-white'
|
|
622
|
-
: isDarkMode
|
|
623
|
-
? 'bg-slate-700 hover:bg-slate-600 text-white'
|
|
624
|
-
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
|
625
|
-
}`}
|
|
626
|
-
>
|
|
627
|
-
{copied ? 'Copied!' : 'Copy'}
|
|
628
|
-
</button>
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* Reveal toggle for a redacted credential (248991 item 3).
|
|
634
|
-
*
|
|
635
|
-
* Deliberately labelled "Reveal token" rather than something neutral like
|
|
636
|
-
* "Show": the label is the last chance to tell whoever is about to click it,
|
|
637
|
-
* possibly while screen-sharing, that a live credential is about to appear.
|
|
638
|
-
*/
|
|
639
|
-
function RevealButton({
|
|
640
|
-
revealed,
|
|
641
|
-
onClick,
|
|
642
|
-
isDarkMode,
|
|
643
|
-
}: {
|
|
644
|
-
revealed: boolean;
|
|
645
|
-
onClick: () => void;
|
|
646
|
-
isDarkMode: boolean;
|
|
647
|
-
}) {
|
|
648
|
-
return (
|
|
649
|
-
<button
|
|
650
|
-
onClick={onClick}
|
|
651
|
-
title={
|
|
652
|
-
revealed
|
|
653
|
-
? 'Hide this credential again'
|
|
654
|
-
: 'Reveals a LIVE credential in plain text. Not while screen-sharing.'
|
|
655
|
-
}
|
|
656
|
-
className={`px-2 py-1 rounded text-xs ${
|
|
657
|
-
revealed
|
|
658
|
-
? isDarkMode
|
|
659
|
-
? 'bg-amber-700 hover:bg-amber-600 text-white'
|
|
660
|
-
: 'bg-amber-200 hover:bg-amber-300 text-amber-900'
|
|
661
|
-
: isDarkMode
|
|
662
|
-
? 'bg-slate-700 hover:bg-slate-600 text-white'
|
|
663
|
-
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
|
664
|
-
}`}
|
|
665
|
-
>
|
|
666
|
-
{revealed ? 'Hide' : 'Reveal token'}
|
|
667
|
-
</button>
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
448
|
|
|
671
449
|
export default JwtInspectPage;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import React, { useState, useEffect } from "react";
|
|
3
3
|
import { authClient } from '../../client/better-auth-client';
|
|
4
|
+
// PAY-1470 (BAPert 59022): never render characters of a token, whatever the ENABLE_TEST_ROUTES gate says.
|
|
5
|
+
// idealresume production mounted this page on 4.1.14 (pre-gate) showing 40 chars of both tokens.
|
|
6
|
+
import { redactCredentials } from './redact-credentials';
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* ⚠️ DEBUG PAGE THAT DRIVES A DESTRUCTIVE ENDPOINT — DO NOT MOUNT IN
|
|
@@ -112,11 +115,12 @@ export function RefreshTokenPage() {
|
|
|
112
115
|
<div><span className="text-gray-400">2FA Complete:</span> {String((session?.user as any)?.twoFactorSessionVerified ?? "unknown")}</div>
|
|
113
116
|
<div>
|
|
114
117
|
<span className="text-gray-400">Access Token:</span>{" "}
|
|
115
|
-
{
|
|
118
|
+
{/* PAY-1481: the session route returns presence only. */}
|
|
119
|
+
{sessionDetails ? (sessionDetails.hasAccessToken ? "present (server-side only)" : "absent") : "N/A"}
|
|
116
120
|
</div>
|
|
117
121
|
<div>
|
|
118
122
|
<span className="text-gray-400">Refresh Token:</span>{" "}
|
|
119
|
-
{sessionDetails
|
|
123
|
+
{sessionDetails ? (sessionDetails.hasRefreshToken ? "present (server-side only)" : "absent") : "N/A"}
|
|
120
124
|
</div>
|
|
121
125
|
<div>
|
|
122
126
|
<span className="text-gray-400">Access Expires:</span>{" "}
|
|
@@ -154,7 +158,7 @@ export function RefreshTokenPage() {
|
|
|
154
158
|
<div className="rounded border border-gray-600 bg-gray-800 p-4">
|
|
155
159
|
<h3 className="font-semibold mb-2 text-gray-300">Result:</h3>
|
|
156
160
|
<pre className="text-xs overflow-x-auto whitespace-pre-wrap text-green-400">
|
|
157
|
-
{JSON.stringify(result, null, 2)}
|
|
161
|
+
{JSON.stringify(redactCredentials(result), null, 2)}
|
|
158
162
|
</pre>
|
|
159
163
|
</div>
|
|
160
164
|
)}
|
|
@@ -163,7 +167,8 @@ export function RefreshTokenPage() {
|
|
|
163
167
|
<details className="mt-4">
|
|
164
168
|
<summary className="cursor-pointer text-gray-400 hover:text-white">Raw Session Details</summary>
|
|
165
169
|
<pre className="mt-2 text-xs bg-gray-800 p-2 rounded overflow-x-auto text-gray-300">
|
|
166
|
-
{
|
|
170
|
+
{/* PAY-1470: this dumped the access AND refresh token in full (Nextpert-Scout 59060). */}
|
|
171
|
+
{JSON.stringify(redactCredentials(sessionDetails), null, 2)}
|
|
167
172
|
</pre>
|
|
168
173
|
</details>
|
|
169
174
|
</div>
|