From af146cb75f5fa2dbb26fd3e7aea5d99cf7a48ac6 Mon Sep 17 00:00:00 2001 From: StringKE Date: Fri, 14 Aug 2026 12:40:34 +0400 Subject: [PATCH 1/2] fix(auth): harden one-time link recovery Signed-off-by: StringKE --- apps/server/src/lib/one-time-link-error.ts | 23 +++ .../server/src/lib/use-one-time-link-token.ts | 45 ++++- .../src/routes/forgot-password/index.test.tsx | 1 + .../src/routes/forgot-password/index.tsx | 23 +-- .../src/routes/magic-link/index.test.tsx | 177 ++++++++++++++++++ apps/server/src/routes/magic-link/index.tsx | 40 ++-- .../src/routes/verify-email/index.test.tsx | 68 +++++++ apps/server/src/routes/verify-email/index.tsx | 40 ++-- apps/server/worker/auth/magic-link.ts | 67 +++++-- apps/server/worker/lib/errors.ts | 4 + apps/server/worker/lib/safe-log.ts | 1 + .../me-auth/__tests__/token-tenant.test.ts | 2 + apps/server/worker/me-auth/token-tenant.ts | 8 +- .../worker/middleware/__tests__/error.test.ts | 27 ++- apps/server/worker/middleware/error.ts | 7 + docs/design/01-authentication.md | 5 + docs/design/07-platform-operations.md | 3 + docs/zh-Hans/design/01-authentication.md | 6 +- docs/zh-Hans/design/07-platform-operations.md | 5 +- packages/i18n/locales/de/messages.po | 4 + packages/i18n/locales/en/messages.po | 4 + packages/i18n/locales/es/messages.po | 4 + packages/i18n/locales/fr/messages.po | 4 + packages/i18n/locales/ja/messages.po | 4 + packages/i18n/locales/ko/messages.po | 4 + packages/i18n/locales/pt-BR/messages.po | 4 + packages/i18n/locales/zh-Hans/messages.po | 4 + 27 files changed, 513 insertions(+), 71 deletions(-) create mode 100644 apps/server/src/lib/one-time-link-error.ts diff --git a/apps/server/src/lib/one-time-link-error.ts b/apps/server/src/lib/one-time-link-error.ts new file mode 100644 index 00000000..7dbfba0a --- /dev/null +++ b/apps/server/src/lib/one-time-link-error.ts @@ -0,0 +1,23 @@ +import type { XidErrorCode } from '@xid-kit/types' + +export type OneTimeLinkErrorKind = 'expired' | 'invalid' | 'retryable' + +type OneTimeLinkTerminalCodes = { + expired: XidErrorCode + invalid: XidErrorCode +} + +function xidErrorCode(error: unknown): XidErrorCode | null { + if (typeof error !== 'object' || error === null || !('code' in error)) return null + return typeof error.code === 'string' ? (error.code as XidErrorCode) : null +} + +export function classifyOneTimeLinkError( + error: unknown, + terminalCodes: OneTimeLinkTerminalCodes, +): OneTimeLinkErrorKind { + const code = xidErrorCode(error) + if (code === terminalCodes.expired) return 'expired' + if (code === terminalCodes.invalid) return 'invalid' + return 'retryable' +} diff --git a/apps/server/src/lib/use-one-time-link-token.ts b/apps/server/src/lib/use-one-time-link-token.ts index 34976ed8..7c033175 100644 --- a/apps/server/src/lib/use-one-time-link-token.ts +++ b/apps/server/src/lib/use-one-time-link-token.ts @@ -6,6 +6,8 @@ type OneTimeLinkToken = { clearToken: () => void } +const HISTORY_STORAGE_KEY = '__xidOneTimeLinkStorageKey' + function sessionStorageOrNull(): Storage | null { try { return globalThis.sessionStorage ?? null @@ -28,6 +30,17 @@ function storedToken(storageKey: string): string | null { } } +function historyStateRecord(): Record { + const state = globalThis.history.state as unknown + return typeof state === 'object' && state !== null && !Array.isArray(state) + ? (state as Record) + : {} +} + +function storedTokenForCurrentEntry(storageKey: string): string | null { + return historyStateRecord()[HISTORY_STORAGE_KEY] === storageKey ? storedToken(storageKey) : null +} + function rememberToken(storageKey: string, token: string): void { try { sessionStorageOrNull()?.setItem(storageKey, token) @@ -36,7 +49,11 @@ function rememberToken(storageKey: string, token: string): void { } } -function scrubCredentialUrl(fragmentParameter: string, legacyQueryToken: string | null): void { +function scrubCredentialUrl( + fragmentParameter: string, + legacyQueryToken: string | null, + storageKey: string, +): void { const url = new URL(globalThis.location.href) let changed = false if (new URLSearchParams(url.hash.slice(1)).has(fragmentParameter)) { @@ -49,12 +66,28 @@ function scrubCredentialUrl(fragmentParameter: string, legacyQueryToken: string } if (!changed) return globalThis.history.replaceState( - globalThis.history.state, + { ...historyStateRecord(), [HISTORY_STORAGE_KEY]: storageKey }, '', `${url.pathname}${url.search}${url.hash}`, ) } +function clearCurrentHistoryMarker(storageKey: string): void { + try { + const state = historyStateRecord() + if (state[HISTORY_STORAGE_KEY] !== storageKey) return + const next = { ...state } + delete next[HISTORY_STORAGE_KEY] + globalThis.history.replaceState( + next, + '', + `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`, + ) + } catch { + // Server 已消费 credential;History marker 清理失败不得阻止内存状态失效。 + } +} + // Action-link credential 只从 fragment/旧 query 捕获到组件状态与 sessionStorage,随后立即清理 URL。 export function useOneTimeLinkToken(input: { storageKey: string @@ -65,7 +98,10 @@ export function useOneTimeLinkToken(input: { const legacyQueryToken = input.legacyQueryToken?.trim() || null const [ready, setReady] = useState(false) const [token, setToken] = useState( - () => fragmentToken(fragmentParameter) ?? legacyQueryToken ?? storedToken(input.storageKey), + () => + fragmentToken(fragmentParameter) ?? + legacyQueryToken ?? + storedTokenForCurrentEntry(input.storageKey), ) useLayoutEffect(() => { @@ -74,7 +110,7 @@ export function useOneTimeLinkToken(input: { rememberToken(input.storageKey, captured) setToken(captured) } - scrubCredentialUrl(fragmentParameter, legacyQueryToken) + scrubCredentialUrl(fragmentParameter, legacyQueryToken, input.storageKey) setReady(true) }, [fragmentParameter, input.storageKey, legacyQueryToken]) @@ -84,6 +120,7 @@ export function useOneTimeLinkToken(input: { } catch { // Server 已消费 credential;storage 清理失败不会改变安全状态。 } + clearCurrentHistoryMarker(input.storageKey) setToken(null) }, [input.storageKey]) diff --git a/apps/server/src/routes/forgot-password/index.test.tsx b/apps/server/src/routes/forgot-password/index.test.tsx index 149ece7c..82f770ea 100644 --- a/apps/server/src/routes/forgot-password/index.test.tsx +++ b/apps/server/src/routes/forgot-password/index.test.tsx @@ -203,6 +203,7 @@ describe('ForgotPasswordPage navigation links', () => { expect(container.textContent).toContain('Request a new reset link') expect(container.innerHTML).toContain('href="/forgot-password"') + expect(globalThis.sessionStorage.getItem('xid.password-reset.token')).toBeNull() await unmount(container, root) }) diff --git a/apps/server/src/routes/forgot-password/index.tsx b/apps/server/src/routes/forgot-password/index.tsx index 16c636ac..03899b1b 100644 --- a/apps/server/src/routes/forgot-password/index.tsx +++ b/apps/server/src/routes/forgot-password/index.tsx @@ -27,7 +27,6 @@ type RequestStepProps = { type ResetStepProps = { token: string clearToken: () => void - requestNewLinkHref: string } function scorePassword(password: string): 0 | 1 | 2 | 3 | 4 { @@ -183,7 +182,7 @@ function RequestStep({ organizationId, onDone }: RequestStepProps): ReactNode { ) } -function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): ReactNode { +function ResetStep({ token, clearToken }: ResetStepProps): ReactNode { const { t } = useLingui() const { api, refresh } = useAuth() const navigate = useNavigate() @@ -194,8 +193,6 @@ function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): R const [confirmError, setConfirmError] = useState(null) const [globalError, setGlobalError] = useState(null) - const [tokenInvalid, setTokenInvalid] = useState(false) - const handlePasswordChange = useCallback((value: string): void => { setPassword(value) setPasswordScore(scorePassword(value)) @@ -208,8 +205,7 @@ function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): R if (!result.ok) { const { error } = result if (error.code === 'token_expired' || error.code === 'token_invalid') { - setGlobalError(t`This reset link is invalid or has expired. Please request a new one.`) - setTokenInvalid(true) + clearToken() } else if (error.code === 'password_breached') { setPasswordError( t`This password has appeared in a data breach. Please choose a different password.`, @@ -235,7 +231,6 @@ function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): R setPasswordError(null) setConfirmError(null) setGlobalError(null) - setTokenInvalid(false) let hasError = false if (password.length < 12) { @@ -265,14 +260,6 @@ function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): R {globalError ? {globalError} : null} - {tokenInvalid ? ( -

- - Request a new reset link - -

- ) : null} -
New password} error={passwordError ?? undefined} required> @@ -398,11 +385,7 @@ function ForgotPasswordPage(): ReactNode { return ( {isResetRoute ? ( - + ) : ( setRequestDone(true)} /> )} diff --git a/apps/server/src/routes/magic-link/index.test.tsx b/apps/server/src/routes/magic-link/index.test.tsx index 489aaf44..dd9f08f9 100644 --- a/apps/server/src/routes/magic-link/index.test.tsx +++ b/apps/server/src/routes/magic-link/index.test.tsx @@ -54,6 +54,14 @@ vi.mock('../../lib/router', () => ({ import { MagicLinkPage } from './index' +async function flush(): Promise { + for (let index = 0; index < 4; index++) { + await act(async () => { + await Promise.resolve() + }) + } +} + describe('MagicLinkPage explicit confirmation', () => { beforeEach(() => { routerState.navigate.mockReset() @@ -122,4 +130,173 @@ describe('MagicLinkPage explicit confirmation', () => { queryClient.clear() container.remove() }) + + it('does not reuse a stored credential on an unrelated history entry', async () => { + globalThis.sessionStorage.setItem('xid.magic-link.token', 'stale-magic-link') + globalThis.history.replaceState({}, '', '/magic-link') + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await act(async () => { + root.render( + + + , + ) + }) + + expect(container.textContent).toContain('No magic-link token found') + expect(container.textContent).not.toContain('Confirm sign in') + expect(authState.post).not.toHaveBeenCalled() + + await act(async () => root.unmount()) + queryClient.clear() + container.remove() + }) + + it('recovers the credential when the same scrubbed history entry is reloaded', async () => { + const firstContainer = document.createElement('div') + document.body.appendChild(firstContainer) + const firstRoot = createRoot(firstContainer) + const firstQueryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + await act(async () => { + firstRoot.render( + + + , + ) + }) + expect(globalThis.location.hash).toBe('') + expect(firstContainer.textContent).toContain('Confirm sign in') + await act(async () => firstRoot.unmount()) + firstQueryClient.clear() + firstContainer.remove() + + const reloadedContainer = document.createElement('div') + document.body.appendChild(reloadedContainer) + const reloadedRoot = createRoot(reloadedContainer) + const reloadedQueryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + await act(async () => { + reloadedRoot.render( + + + , + ) + }) + + expect(reloadedContainer.textContent).toContain('Confirm sign in') + expect(reloadedContainer.textContent).not.toContain('No magic-link token found') + + await act(async () => reloadedRoot.unmount()) + reloadedQueryClient.clear() + reloadedContainer.remove() + }) + + it('clears a rejected credential and keeps one recovery message', async () => { + authState.post.mockResolvedValue({ + ok: false, + error: { code: 'magic_link_invalid', message: 'invalid', httpStatus: 400 }, + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await act(async () => { + root.render( + + + , + ) + }) + const button = container.querySelector('button') + if (!button) throw new Error('confirmation button missing') + await act(async () => button.click()) + await flush() + + expect(globalThis.sessionStorage.getItem('xid.magic-link.token')).toBeNull() + expect(container.textContent).toContain('This magic link is invalid or has already been used.') + expect(container.textContent).not.toContain('No magic-link token found') + + await act(async () => root.unmount()) + queryClient.clear() + container.remove() + }) + + it('invalidates component state when History marker cleanup is unavailable', async () => { + authState.post.mockResolvedValue({ + ok: false, + error: { code: 'magic_link_invalid', message: 'invalid', httpStatus: 400 }, + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await act(async () => { + root.render( + + + , + ) + }) + const replaceState = vi + .spyOn(globalThis.history, 'replaceState') + .mockImplementation(() => { + throw new DOMException('History unavailable', 'SecurityError') + }) + const button = container.querySelector('button') + if (!button) throw new Error('confirmation button missing') + await act(async () => button.click()) + await flush() + + expect(globalThis.sessionStorage.getItem('xid.magic-link.token')).toBeNull() + expect(container.textContent).toContain('This magic link is invalid or has already been used.') + expect(container.textContent).not.toContain('No magic-link token found') + + replaceState.mockRestore() + await act(async () => root.unmount()) + queryClient.clear() + container.remove() + }) + + it('retains the credential and offers retry for a transient failure', async () => { + authState.post.mockResolvedValue({ + ok: false, + error: { code: 'server_error', message: 'unavailable', httpStatus: 500 }, + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await act(async () => { + root.render( + + + , + ) + }) + const confirm = container.querySelector('button') + if (!confirm) throw new Error('confirmation button missing') + await act(async () => { + confirm.click() + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)) + }) + await flush() + + expect(globalThis.sessionStorage.getItem('xid.magic-link.token')).toBe('signed-magic-link') + expect(container.textContent).toContain('Something went wrong. Please try again.') + expect( + Array.from(container.querySelectorAll('button')).map((button) => button.textContent), + ).toEqual(['Try again']) + + await act(async () => root.unmount()) + queryClient.clear() + container.remove() + }) }) diff --git a/apps/server/src/routes/magic-link/index.tsx b/apps/server/src/routes/magic-link/index.tsx index 211bb547..8b40a51b 100644 --- a/apps/server/src/routes/magic-link/index.tsx +++ b/apps/server/src/routes/magic-link/index.tsx @@ -3,22 +3,22 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import { createLazyRoute, useSearch } from '@tanstack/react-router' import { useMutation } from '@tanstack/react-query' -import type { XidErrorCode } from '@xid-kit/types' import * as stylex from '@stylexjs/stylex' import { AuthLayout } from '../../components/layout' import { Alert, Button, PageHeader, Spinner } from '../../components/ui' import { useAuth } from '../../lib/auth-context' import { Link, useNavigate } from '../../lib/router' import { useOneTimeLinkToken } from '../../lib/use-one-time-link-token' +import { classifyOneTimeLinkError, type OneTimeLinkErrorKind } from '../../lib/one-time-link-error' import { styles as signInStyles } from '../sign-in/styles' import { tokens } from '../../styles/tokens.stylex' type MagicLinkResult = { redirectUrl: string } -type MagicLinkErrorKind = 'expired' | 'invalid' -function classifyError(code: XidErrorCode): MagicLinkErrorKind { - return code === 'magic_link_expired' ? 'expired' : 'invalid' -} +const MAGIC_LINK_TERMINAL_CODES = { + expired: 'magic_link_expired', + invalid: 'magic_link_invalid', +} as const const styles = stylex.create({ stack: { @@ -60,6 +60,9 @@ export function MagicLinkPage(): ReactNode { clearToken() return result.value }, + onError: (error) => { + if (classifyOneTimeLinkError(error, MAGIC_LINK_TERMINAL_CODES) !== 'retryable') clearToken() + }, }) useEffect(() => { @@ -73,12 +76,9 @@ export function MagicLinkPage(): ReactNode { return () => globalThis.clearTimeout(timer) }, [navigate, verification.data?.redirectUrl, verification.isSuccess]) - const errorKind: MagicLinkErrorKind | null = - verification.error && typeof verification.error === 'object' && 'code' in verification.error - ? classifyError((verification.error as { code: XidErrorCode }).code) - : verification.error - ? 'invalid' - : null + const errorKind: OneTimeLinkErrorKind | null = verification.error + ? classifyOneTimeLinkError(verification.error, MAGIC_LINK_TERMINAL_CODES) + : null const confirmReady = ready && token !== null && verification.isIdle @@ -103,7 +103,7 @@ export function MagicLinkPage(): ReactNode {
) : null} - {ready && token === null && !verification.isSuccess ? ( + {ready && token === null && !verification.isSuccess && !verification.error ? ( No magic-link token found. Please use the link from your email. @@ -142,6 +142,22 @@ export function MagicLinkPage(): ReactNode { ) : null} + {errorKind === 'retryable' ? ( + <> + + Something went wrong. Please try again. + + + + ) : null} + {ready && !verification.isPending && !verification.isSuccess ? ( Back to sign in diff --git a/apps/server/src/routes/verify-email/index.test.tsx b/apps/server/src/routes/verify-email/index.test.tsx index 290efc6d..2e0e1550 100644 --- a/apps/server/src/routes/verify-email/index.test.tsx +++ b/apps/server/src/routes/verify-email/index.test.tsx @@ -130,4 +130,72 @@ describe('VerifyEmailPage explicit confirmation', () => { queryClient.clear() container.remove() }) + + it('clears a rejected credential without stacking the missing-token state', async () => { + authState.post.mockResolvedValue({ + ok: false, + error: { code: 'token_invalid', message: 'invalid', httpStatus: 400 }, + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await act(async () => { + root.render( + + + , + ) + }) + const button = container.querySelector('button') + if (!button) throw new Error('confirmation button missing') + await act(async () => button.click()) + await flush() + + expect(globalThis.sessionStorage.getItem('xid.verify-email.token')).toBeNull() + expect(container.textContent).toContain( + 'This verification link is invalid or has already been used.', + ) + expect(container.textContent).not.toContain('No verification token found') + + await act(async () => root.unmount()) + queryClient.clear() + container.remove() + }) + + it('retains the credential and offers retry for a transient failure', async () => { + authState.post.mockResolvedValue({ + ok: false, + error: { code: 'server_error', message: 'unavailable', httpStatus: 500 }, + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await act(async () => { + root.render( + + + , + ) + }) + const confirm = container.querySelector('button') + if (!confirm) throw new Error('confirmation button missing') + await act(async () => confirm.click()) + await flush() + await act(async () => vi.runOnlyPendingTimers()) + await flush() + + expect(globalThis.sessionStorage.getItem('xid.verify-email.token')).toBe('signed-token') + expect(container.textContent).toContain('Something went wrong. Please try again.') + expect( + Array.from(container.querySelectorAll('button')).map((button) => button.textContent), + ).toEqual(['Try again']) + + await act(async () => root.unmount()) + queryClient.clear() + container.remove() + }) }) diff --git a/apps/server/src/routes/verify-email/index.tsx b/apps/server/src/routes/verify-email/index.tsx index 93a9f1b0..082a2aad 100644 --- a/apps/server/src/routes/verify-email/index.tsx +++ b/apps/server/src/routes/verify-email/index.tsx @@ -6,7 +6,6 @@ import type { ReactNode } from 'react' import { createLazyRoute, useSearch } from '@tanstack/react-router' import { Link, useNavigate } from '../../lib/router' import { useMutation } from '@tanstack/react-query' -import type { XidErrorCode } from '@xid-kit/types' import * as stylex from '@stylexjs/stylex' import { tokens } from '../../styles/tokens.stylex' import { Alert, Button, PageHeader, Spinner } from '../../components/ui' @@ -15,13 +14,14 @@ import { useAuth } from '../../lib/auth-context' import { trackEmailVerified } from '../../lib/google-analytics-funnel' import { styles as signInStyles } from '../sign-in/styles' import { useOneTimeLinkToken } from '../../lib/use-one-time-link-token' +import { classifyOneTimeLinkError, type OneTimeLinkErrorKind } from '../../lib/one-time-link-error' -type VerifyErrorKind = 'expired' | 'invalid' type VerifyEmailResult = { ok: true; email?: string; redirectUrl?: string } -function classifyError(code: XidErrorCode): VerifyErrorKind { - return code === 'token_expired' ? 'expired' : 'invalid' -} +const VERIFY_EMAIL_TERMINAL_CODES = { + expired: 'token_expired', + invalid: 'token_invalid', +} as const // 回 sign-in 附 verified=1 + login_hint,供成功 Alert 与预填。 function withVerifiedHint(target: string, email: string | undefined): string { @@ -85,6 +85,9 @@ function VerifyEmailPage(): ReactNode { clearToken() return result.value }, + onError: (error) => { + if (classifyOneTimeLinkError(error, VERIFY_EMAIL_TERMINAL_CODES) !== 'retryable') clearToken() + }, }) // 短暂停留再跳转,让用户看到成功提示。 @@ -100,12 +103,9 @@ function VerifyEmailPage(): ReactNode { return () => globalThis.clearTimeout(timer) }, [navigate, verification.data?.email, verification.data?.redirectUrl, verification.isSuccess]) - const errorKind: VerifyErrorKind | null = - verification.error && typeof verification.error === 'object' && 'code' in verification.error - ? classifyError((verification.error as { code: XidErrorCode }).code) - : verification.error - ? 'invalid' - : null + const errorKind: OneTimeLinkErrorKind | null = verification.error + ? classifyOneTimeLinkError(verification.error, VERIFY_EMAIL_TERMINAL_CODES) + : null return ( @@ -136,7 +136,7 @@ function VerifyEmailPage(): ReactNode {
) : null} - {ready && token === null && !verification.isSuccess ? ( + {ready && token === null && !verification.isSuccess && !verification.error ? ( <> No verification token found. Please use the link from your email. @@ -183,6 +183,22 @@ function VerifyEmailPage(): ReactNode { ) : null} + + {errorKind === 'retryable' ? ( + <> + + Something went wrong. Please try again. + + + + ) : null} ) diff --git a/apps/server/worker/auth/magic-link.ts b/apps/server/worker/auth/magic-link.ts index 59d502d3..dfe0b390 100644 --- a/apps/server/worker/auth/magic-link.ts +++ b/apps/server/worker/auth/magic-link.ts @@ -60,6 +60,10 @@ type SignMagicTokenInput = { type MagicLinkAction = 'login' | 'user_creation' +function rejectMagicLink(reason: string): never { + throw new AppError('magic_link_invalid', { logReason: reason }) +} + async function resolveMagicLinkTenant( c: Context, rawToken: string, @@ -280,19 +284,32 @@ async function verifyMagicJwt( const verifyKeys = await buildVerifyKeySet(tenant) const verified = await verifyJwt(rawToken, verifyKeys, { expectedIssuer: tenant.issuer }) if (!verified.ok) { - throw new AppError( - verified.error.reason === 'expired' ? 'magic_link_expired' : 'magic_link_invalid', - ) + if (verified.error.reason === 'expired') { + throw new AppError('magic_link_expired', { logReason: 'jwt_expired' }) + } + rejectMagicLink('jwt_verification_failed') } const { sub: userId, jti, purpose, action, flow_context: rawFlowContext } = verified.value.payload - if (!userId || !jti || purpose !== 'magic_link') throw new AppError('magic_link_invalid') - if (action !== 'login' && action !== 'user_creation') throw new AppError('magic_link_invalid') - if (typeof rawFlowContext !== 'string') throw new AppError('magic_link_invalid') + if (!userId || !jti || purpose !== 'magic_link') rejectMagicLink('jwt_claims_invalid') + if (action !== 'login' && action !== 'user_creation') rejectMagicLink('jwt_action_invalid') + if (typeof rawFlowContext !== 'string') rejectMagicLink('flow_context_missing') + let flow: PasswordlessFlowContext + try { + flow = parsePasswordlessFlowContext(rawFlowContext, 'magic_link_invalid') + } catch (error) { + if (error instanceof AppError && error.code === 'magic_link_invalid') { + throw new AppError('magic_link_invalid', { + cause: error, + logReason: 'flow_context_invalid', + }) + } + throw error + } return { jti, userId, action, - flow: parsePasswordlessFlowContext(rawFlowContext, 'magic_link_invalid'), + flow, } } @@ -311,14 +328,17 @@ async function consumeMagicToken( ? undefined : await db.verificationTokens.findOne(eq(schema.verificationTokens.tokenHash, tokenHash)) const tokenRow = ledgerRow ?? legacyRow - if (!tokenRow || tokenRow.consumedAt !== null) throw new AppError('magic_link_invalid') - if (tokenRow.expiresAt.getTime() <= Date.now()) throw new AppError('magic_link_expired') - if (legacyRow && legacyRow.purpose !== 'magic_link') throw new AppError('magic_link_invalid') + if (!tokenRow) rejectMagicLink('ledger_token_missing') + if (tokenRow.consumedAt !== null) rejectMagicLink('ledger_token_consumed') + if (tokenRow.expiresAt.getTime() <= Date.now()) { + throw new AppError('magic_link_expired', { logReason: 'ledger_token_expired' }) + } + if (legacyRow && legacyRow.purpose !== 'magic_link') rejectMagicLink('ledger_purpose_invalid') if ( tokenRow.userId !== signedUserId || tokenRow.flowContext !== serializePasswordlessFlowContext(signedFlow) ) { - throw new AppError('magic_link_invalid') + rejectMagicLink('ledger_binding_mismatch') } const consumed = ledgerRow ? await db.magicLinkTokens.update( @@ -338,7 +358,7 @@ async function consumeMagicToken( gt(schema.verificationTokens.expiresAt, new Date()), ), ) - if (consumed && consumed.length === 0 && tokenRow.id) throw new AppError('magic_link_invalid') + if (consumed && consumed.length === 0 && tokenRow.id) rejectMagicLink('ledger_consume_conflict') return tokenRow.userId } @@ -364,11 +384,22 @@ export async function handleMagicLinkVerifyRedirect(c: Context): Pro export async function handleMagicLinkVerify(c: Context): Promise { const json = await readJsonBody(c) - if (!json.ok) throw new AppError('magic_link_invalid') - const body = validateCredentialBody(magicLinkVerifyBodySchema, json.value, { - code: 'magic_link_invalid', - credentialFields: ['token'], - }) + if (!json.ok) rejectMagicLink('request_body_invalid') + let body: { token: string } + try { + body = validateCredentialBody(magicLinkVerifyBodySchema, json.value, { + code: 'magic_link_invalid', + credentialFields: ['token'], + }) + } catch (error) { + if (error instanceof AppError && error.code === 'magic_link_invalid') { + throw new AppError('magic_link_invalid', { + cause: error, + logReason: 'request_token_invalid', + }) + } + throw error + } const rawToken = body.token const tenant = await resolveMagicLinkTenant(c, rawToken) @@ -384,7 +415,7 @@ export async function handleMagicLinkVerify(c: Context): Promise { const { jti, userId: signedUserId, action, flow } = await verifyMagicJwt(tenant, rawToken) // Legacy magic-link tokens carrying an invitation continuation must never consume or accept it. - if (flow.invitationId) throw new AppError('magic_link_invalid') + if (flow.invitationId) rejectMagicLink('invitation_flow_unsupported') try { assertMethodAllowed(tenant, 'magicLink', action) } catch (error) { diff --git a/apps/server/worker/lib/errors.ts b/apps/server/worker/lib/errors.ts index 71fa67b6..b51188c1 100644 --- a/apps/server/worker/lib/errors.ts +++ b/apps/server/worker/lib/errors.ts @@ -93,6 +93,8 @@ export type AppErrorOptions = { longMessage?: string // 原始底层错误,仅用于服务端日志,绝不外泄(见错误处理铁律不吞错)。 cause?: unknown + // 低基数诊断原因,仅进入服务端结构化日志,绝不进入响应体。 + logReason?: string } // 业务/协议失败的 typed 错误。message 在 onError 阶段由 i18n 渲染,此处只持 code。 @@ -101,6 +103,7 @@ export class AppError extends Error { readonly httpStatus: number readonly meta?: XidErrorMeta readonly longMessage?: string + readonly logReason?: string constructor(code: XidErrorCode, options: AppErrorOptions = {}) { super(code, options.cause === undefined ? undefined : { cause: options.cause }) @@ -109,6 +112,7 @@ export class AppError extends Error { this.httpStatus = options.httpStatus ?? httpStatusForCode(code) if (options.meta) this.meta = options.meta if (options.longMessage) this.longMessage = options.longMessage + if (options.logReason) this.logReason = options.logReason } } diff --git a/apps/server/worker/lib/safe-log.ts b/apps/server/worker/lib/safe-log.ts index acbfb1f7..98e6f1e7 100644 --- a/apps/server/worker/lib/safe-log.ts +++ b/apps/server/worker/lib/safe-log.ts @@ -2,6 +2,7 @@ type SafeLogContext = { component?: string operation?: string outcome?: string + reason?: string queue?: string attempt?: number status?: number diff --git a/apps/server/worker/me-auth/__tests__/token-tenant.test.ts b/apps/server/worker/me-auth/__tests__/token-tenant.test.ts index 332deb0a..dd5fad7e 100644 --- a/apps/server/worker/me-auth/__tests__/token-tenant.test.ts +++ b/apps/server/worker/me-auth/__tests__/token-tenant.test.ts @@ -75,6 +75,7 @@ describe('resolveTokenTenant', () => { const c = await makeContext(unresolvedTenant()).getCtx() await expect(resolveTokenTenant(c, 'not-a-jwt', 'invalid_token')).rejects.toMatchObject({ code: 'invalid_token', + logReason: 'token_tenant_hint_missing', }) }) @@ -105,6 +106,7 @@ describe('resolveTokenTenant', () => { const token = fakeJwt({ iss: 'https://missing.xid.dev' }) await expect(resolveTokenTenant(c, token, 'invalid_token')).rejects.toMatchObject({ code: 'invalid_token', + logReason: 'token_tenant_resolution_failed', }) }) }) diff --git a/apps/server/worker/me-auth/token-tenant.ts b/apps/server/worker/me-auth/token-tenant.ts index 5b398206..c682c651 100644 --- a/apps/server/worker/me-auth/token-tenant.ts +++ b/apps/server/worker/me-auth/token-tenant.ts @@ -37,10 +37,14 @@ export async function resolveTokenTenant( const current = c.get('tenant') if (!current.resolution?.unresolvedRoot) return current const hint = tokenTenantHint(rawToken) - if (!hint.issuer) throw new AppError(invalidCode) + if (!hint.issuer) { + throw new AppError(invalidCode, { logReason: 'token_tenant_hint_missing' }) + } const result = await resolveTenantContextByIssuer(c.req.raw, c.env, hint.issuer, { tenantId: hint.tenantId, }) - if (!result.ok) throw new AppError(invalidCode) + if (!result.ok) { + throw new AppError(invalidCode, { logReason: 'token_tenant_resolution_failed' }) + } return result.value.tenant } diff --git a/apps/server/worker/middleware/__tests__/error.test.ts b/apps/server/worker/middleware/__tests__/error.test.ts index 694a7787..0d4bcc03 100644 --- a/apps/server/worker/middleware/__tests__/error.test.ts +++ b/apps/server/worker/middleware/__tests__/error.test.ts @@ -117,7 +117,7 @@ describe('errorHandler', () => { }) app.onError(errorHandler) app.post('/auth/magic-link/verify', () => { - throw new AppError('magic_link_expired') + throw new AppError('magic_link_expired', { logReason: 'jwt_expired' }) }) const res = await app.request('/auth/magic-link/verify?token=raw-secret', { method: 'POST' }) @@ -129,6 +129,7 @@ describe('errorHandler', () => { component: 'auth', operation: 'magic_link', outcome: 'magic_link_expired', + reason: 'jwt_expired', status: 400, }) const logged = JSON.stringify(vi.mocked(console.warn).mock.calls) @@ -137,6 +138,30 @@ describe('errorHandler', () => { expect(logged).not.toContain('https://') }) + it('drops an unsafe one-time-link log reason without leaking it', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('i18n', { _: (descriptor: { id: string }) => `localized:${descriptor.id}` } as never) + await next() + }) + app.onError(errorHandler) + app.post('/auth/magic-link/verify', () => { + throw new AppError('magic_link_invalid', { logReason: 'token=raw-secret' }) + }) + + await app.request('/auth/magic-link/verify', { method: 'POST' }) + + expect(console.warn).toHaveBeenCalledWith({ + event: 'auth.one_time_link.rejected', + severity: 'warning', + component: 'auth', + operation: 'magic_link', + outcome: 'magic_link_invalid', + status: 400, + }) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('raw-secret') + }) + it('keeps an error response available when request i18n is absent', async () => { const json = vi.fn( (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), diff --git a/apps/server/worker/middleware/error.ts b/apps/server/worker/middleware/error.ts index 3f174d6f..ac78bd21 100644 --- a/apps/server/worker/middleware/error.ts +++ b/apps/server/worker/middleware/error.ts @@ -70,6 +70,8 @@ const ONE_TIME_LINK_OPERATIONS: Readonly> = { '/auth/invitation/claim/verify': 'invitation_email_claim', } +const SAFE_LOG_REASON = /^[a-z][a-z0-9_]{0,63}$/u + function logOneTimeLinkRejection( c: Parameters>[1], error: AppError | XidError, @@ -81,6 +83,11 @@ function logOneTimeLinkRejection( component: 'auth', operation, outcome: error.code, + ...(error instanceof AppError && + error.logReason !== undefined && + SAFE_LOG_REASON.test(error.logReason) + ? { reason: error.logReason } + : {}), status: error.httpStatus, }) } diff --git a/docs/design/01-authentication.md b/docs/design/01-authentication.md index 6f95c0e0..e3a5ce3b 100644 --- a/docs/design/01-authentication.md +++ b/docs/design/01-authentication.md @@ -442,6 +442,11 @@ binding cannot be unlinked. scrubs that fragment before rendering and does not submit the token until the user presses the explicit confirmation button. Link scanners, prefetchers, and `GET` navigation therefore cannot consume the credential or establish a session. +- After scrubbing, the browser may retain the credential in `sessionStorage` only for the same + History entry so a reload of that confirmation page remains usable. A navigation without the + matching History marker must not recover a stale credential from another link attempt. Success, + expiry, invalidity, or any other terminal verification rejection clears both the stored token and + its History marker before the recovery state is shown. - The legacy `GET /auth/magic-link/verify?token=...` endpoint is a mutation-free compatibility shim: it resolves the trusted Hosted Auth origin and redirects to the fragment-based confirmation page. Missing or unresolvable legacy credentials redirect to the tokenless Hosted UI error state instead diff --git a/docs/design/07-platform-operations.md b/docs/design/07-platform-operations.md index 938bacaf..2b0943be 100644 --- a/docs/design/07-platform-operations.md +++ b/docs/design/07-platform-operations.md @@ -452,6 +452,9 @@ Implemented baseline: severity, an allowlisted error type/code, and bounded operational fields. Error message, stack, cause, cookie, Authorization, IP, raw URL/query, provider payload, and user identifiers are never passed to `console`. +- One-time-link rejections add a static low-cardinality `reason` for the failed verification stage + such as tenant resolution, JWT validation, or ledger consumption. The reason is server-side only, + must match the safe-log identifier allowlist, and never contains the credential or request URL. - Production and staging Workers Logs sample 100%. Cloudflare invocation logs and automatic request traces are disabled in every environment because both persist the request URL, and automatic Fetch spans include `url.full`. Core URLs can carry OAuth codes, invitation tokens, diff --git a/docs/zh-Hans/design/01-authentication.md b/docs/zh-Hans/design/01-authentication.md index c10e7111..4999c8bd 100644 --- a/docs/zh-Hans/design/01-authentication.md +++ b/docs/zh-Hans/design/01-authentication.md @@ -1,4 +1,4 @@ - + > Translation of `docs/design/01-authentication.md` at commit `5d55b0c`. The English version is authoritative. > 本文是 [`docs/design/01-authentication.md`](../../design/01-authentication.md) 的中文翻译,英文版为准。两版不一致时以英文版为准。 @@ -279,6 +279,10 @@ UTF-8 解码后 `JSON.parse`,按以下顺序校验,任一失败即拒绝并返 - 事务邮件把 magic-link token 放在 Hosted UI URL fragment 中。浏览器在渲染前清除 fragment, 用户点击显式确认按钮后才提交 token。Email scanner、prefetch 和普通 `GET` 均不得消费凭据 或建立 session。 +- 清除 fragment 后,浏览器只允许为同一个 History entry 在 `sessionStorage` 中保留 credential, + 使该确认页 reload 后仍可继续。缺少匹配 History marker 的 navigation 不得恢复其他链接尝试 + 留下的 stale credential。成功、过期、无效或其他终态 verification rejection 必须先清除 + stored token 与 History marker,再展示 recovery state。 - 旧 `GET /auth/magic-link/verify?token=...` 仅作为无 mutation 的兼容跳转:解析可信 Hosted Auth origin 后跳到 fragment 确认页。缺失或无法解析的旧 credential 必须跳到不带 token 的 Hosted UI 错误状态,不得向浏览器展示 API JSON。只有 `POST /auth/magic-link/verify` 可以消费 token 并签发 diff --git a/docs/zh-Hans/design/07-platform-operations.md b/docs/zh-Hans/design/07-platform-operations.md index 8708bbe1..8a8be84c 100644 --- a/docs/zh-Hans/design/07-platform-operations.md +++ b/docs/zh-Hans/design/07-platform-operations.md @@ -1,4 +1,4 @@ - + > Translation of `docs/design/07-platform-operations.md` at commit `5d55b0c`. The English version is authoritative. > 本文是 [`docs/design/07-platform-operations.md`](../../design/07-platform-operations.md) 的中文翻译,英文版为准。两版不一致时以英文版为准。 @@ -385,6 +385,9 @@ source consumer 仍需保留自己的 idempotency boundary。已经完成的 `re allowlist error type/code 与有界运维字段。Error message、stack、cause、cookie、 Authorization、IP、原始 URL/query、provider payload 与 user identifier 均不得传给 `console`。 +- 一次性链接拒绝会为失败的 verification stage 增加静态、低基数 `reason`,例如 Tenant + resolution、JWT validation 或 ledger consumption。该 reason 仅供服务端使用,必须符合 + safe-log identifier allowlist,且绝不包含 credential 或 request URL。 - Production 和 staging Workers Logs 都采样 100%。所有环境都关闭 Cloudflare invocation logs 与 automatic request traces,因为两者都会持久化 request URL,automatic Fetch span 还会包含 `url.full`。Core URL 可能携带 OAuth code、invitation token、 diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 5cf62057..30427afe 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "SolidJS-Kontext-Provider, signalbasierte Auth-Primitive undHeadless-Komponenten auf Basis von @xid-kit/core." #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." @@ -10591,7 +10593,9 @@ msgstr "Vertrauenswürdige Geräte | Konto | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "Vertrauenswürdige Geräte können MFA-Prüfungen überspringen oder reduzieren. Entfernen Sie ein Gerät, wenn Sie ihm nicht mehr vertrauen oder es verloren wurde." +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "Erneut versuchen" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 13d655d9..8d0e746a 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "SolidJS context provider, signal-based auth primitives, and headless components on top of @xid-kit/core." #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "Something went wrong. Please try again." @@ -10591,7 +10593,9 @@ msgstr "Trusted devices | Account | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "Try again" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index e8e30cc1..40484b87 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "Provider de contexto SolidJS, primitivas de autenticación basadas en signals y componentes headless sobre @xid-kit/core." #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "Algo salió mal. Inténtalo de nuevo." @@ -10591,7 +10593,9 @@ msgstr "Dispositivos de confianza | Cuenta | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "Los dispositivos de confianza pueden omitir o reducir comprobaciones MFA. Elimina un dispositivo si ya no confías en él o si se perdió." +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "Reintentar" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index b0ff1d3b..d4586837 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "Fournisseur de contexte SolidJS, primitives d'authentification basées surdes signaux et composants headless construits sur @xid-kit/core." #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "Une erreur s'est produite. Réessayez." @@ -10591,7 +10593,9 @@ msgstr "Appareils de confiance | Compte | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "Les appareils de confiance peuvent ignorer ou réduire les vérifications MFA. Supprimez un appareil si vous ne lui faites plus confiance ou s'il a été perdu." +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "Réessayer" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index bd69b2a0..2cea7838 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "SolidJS コンテキストプロバイダー、シグナルベースの認証プリミティブ、@xid-kit/core 上のヘッドレスコンポーネントをサポート。" #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "問題が発生しました。もう一度お試しください。" @@ -10591,7 +10593,9 @@ msgstr "信頼済みデバイス | アカウント | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "信頼済みデバイスは MFA チェックをスキップまたは軽減できます。信頼しなくなった場合、または紛失した場合はデバイスを削除してください。" +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "再試行" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index fcf1e898..0ac46d6a 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "@xid-kit/core 기반의 SolidJS 컨텍스트 provider, 신호 기반 인증 프리미티브, 헤드리스 컴포넌트." #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "문제가 발생했습니다. 다시 시도하세요." @@ -10591,7 +10593,9 @@ msgstr "신뢰 기기 | 계정 | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "신뢰된 디바이스는 MFA 확인을 건너뛰거나 줄일 수 있습니다. 더 이상 신뢰하지 않거나 분실한 디바이스는 제거하세요." +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "다시 시도" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 29c43ced..6a6343ed 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "Provider de contexto SolidJS, primitivas de autenticação baseadas emsignals e componentes headless sobre @xid-kit/core." #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "Algo deu errado. Tente novamente." @@ -10591,7 +10593,9 @@ msgstr "Dispositivos confiáveis | Conta | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "Dispositivos confiáveis podem pular ou reduzir verificações MFA. Remova um dispositivo se você não confia mais nele ou se ele foi perdido." +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "Tentar novamente" diff --git a/packages/i18n/locales/zh-Hans/messages.po b/packages/i18n/locales/zh-Hans/messages.po index 8d1d8dbb..773ba6f9 100644 --- a/packages/i18n/locales/zh-Hans/messages.po +++ b/packages/i18n/locales/zh-Hans/messages.po @@ -9509,6 +9509,8 @@ msgid "SolidJS context provider, signal-based auth primitives, and headless comp msgstr "基于 @xid-kit/core 的 SolidJS context provider、基于 signal 的认证原语和无样式组件。" #: apps/server/src/routes/forgot-password/index.tsx +#: apps/server/src/routes/magic-link/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Something went wrong. Please try again." msgstr "出现错误。请重试。" @@ -10591,7 +10593,9 @@ msgstr "受信设备 | 账户 | XID" msgid "Trusted devices can skip or reduce MFA checks. Remove a device if you no longer trust it or if it was lost." msgstr "受信任设备可以跳过或减少 MFA 检查。如果不再信任设备或设备已丢失,请移除它。" +#: apps/server/src/routes/magic-link/index.tsx #: apps/server/src/routes/mfa/index.tsx +#: apps/server/src/routes/verify-email/index.tsx msgid "Try again" msgstr "重试" From c9cd4474a106c816c4419814ddf900d4f27a3d22 Mon Sep 17 00:00:00 2001 From: StringKE Date: Fri, 14 Aug 2026 12:44:39 +0400 Subject: [PATCH 2/2] style(auth): format one-time link regression test Signed-off-by: StringKE --- apps/server/src/routes/magic-link/index.test.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/server/src/routes/magic-link/index.test.tsx b/apps/server/src/routes/magic-link/index.test.tsx index dd9f08f9..847d1b0a 100644 --- a/apps/server/src/routes/magic-link/index.test.tsx +++ b/apps/server/src/routes/magic-link/index.test.tsx @@ -244,11 +244,9 @@ describe('MagicLinkPage explicit confirmation', () => { , ) }) - const replaceState = vi - .spyOn(globalThis.history, 'replaceState') - .mockImplementation(() => { - throw new DOMException('History unavailable', 'SecurityError') - }) + const replaceState = vi.spyOn(globalThis.history, 'replaceState').mockImplementation(() => { + throw new DOMException('History unavailable', 'SecurityError') + }) const button = container.querySelector('button') if (!button) throw new Error('confirmation button missing') await act(async () => button.click())