Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/server/src/lib/one-time-link-error.ts
Original file line number Diff line number Diff line change
@@ -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'
}
45 changes: 41 additions & 4 deletions apps/server/src/lib/use-one-time-link-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ type OneTimeLinkToken = {
clearToken: () => void
}

const HISTORY_STORAGE_KEY = '__xidOneTimeLinkStorageKey'

function sessionStorageOrNull(): Storage | null {
try {
return globalThis.sessionStorage ?? null
Expand All @@ -28,6 +30,17 @@ function storedToken(storageKey: string): string | null {
}
}

function historyStateRecord(): Record<string, unknown> {
const state = globalThis.history.state as unknown
return typeof state === 'object' && state !== null && !Array.isArray(state)
? (state as Record<string, unknown>)
: {}
}

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)
Expand All @@ -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)) {
Expand All @@ -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
Expand All @@ -65,7 +98,10 @@ export function useOneTimeLinkToken(input: {
const legacyQueryToken = input.legacyQueryToken?.trim() || null
const [ready, setReady] = useState(false)
const [token, setToken] = useState<string | null>(
() => fragmentToken(fragmentParameter) ?? legacyQueryToken ?? storedToken(input.storageKey),
() =>
fragmentToken(fragmentParameter) ??
legacyQueryToken ??
storedTokenForCurrentEntry(input.storageKey),
)

useLayoutEffect(() => {
Expand All @@ -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])

Expand All @@ -84,6 +120,7 @@ export function useOneTimeLinkToken(input: {
} catch {
// Server 已消费 credential;storage 清理失败不会改变安全状态。
}
clearCurrentHistoryMarker(input.storageKey)
setToken(null)
}, [input.storageKey])

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/routes/forgot-password/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
23 changes: 3 additions & 20 deletions apps/server/src/routes/forgot-password/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ type RequestStepProps = {
type ResetStepProps = {
token: string
clearToken: () => void
requestNewLinkHref: string
}

function scorePassword(password: string): 0 | 1 | 2 | 3 | 4 {
Expand Down Expand Up @@ -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()
Expand All @@ -194,8 +193,6 @@ function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): R
const [confirmError, setConfirmError] = useState<string | null>(null)
const [globalError, setGlobalError] = useState<string | null>(null)

const [tokenInvalid, setTokenInvalid] = useState(false)

const handlePasswordChange = useCallback((value: string): void => {
setPassword(value)
setPasswordScore(scorePassword(value))
Expand All @@ -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.`,
Expand All @@ -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) {
Expand Down Expand Up @@ -265,14 +260,6 @@ function ResetStep({ token, clearToken, requestNewLinkHref }: ResetStepProps): R

{globalError ? <Alert tone="error">{globalError}</Alert> : null}

{tokenInvalid ? (
<p {...stylex.props(styles.footerText)}>
<Link to={requestNewLinkHref} {...stylex.props(styles.textLink)}>
<Trans>Request a new reset link</Trans>
</Link>
</p>
) : null}

<div {...stylex.props(styles.formFields)}>
<div {...stylex.props(styles.passwordGroup)}>
<Field label={<Trans>New password</Trans>} error={passwordError ?? undefined} required>
Expand Down Expand Up @@ -398,11 +385,7 @@ function ForgotPasswordPage(): ReactNode {
return (
<AuthLayout footer={backToSignIn}>
{isResetRoute ? (
<ResetStep
token={token as string}
clearToken={clearToken}
requestNewLinkHref={requestNewLinkHref}
/>
<ResetStep token={token as string} clearToken={clearToken} />
) : (
<RequestStep organizationId={organizationId} onDone={() => setRequestDone(true)} />
)}
Expand Down
175 changes: 175 additions & 0 deletions apps/server/src/routes/magic-link/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ vi.mock('../../lib/router', () => ({

import { MagicLinkPage } from './index'

async function flush(): Promise<void> {
for (let index = 0; index < 4; index++) {
await act(async () => {
await Promise.resolve()
})
}
}

describe('MagicLinkPage explicit confirmation', () => {
beforeEach(() => {
routerState.navigate.mockReset()
Expand Down Expand Up @@ -122,4 +130,171 @@ 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(
<QueryClientProvider client={queryClient}>
<MagicLinkPage />
</QueryClientProvider>,
)
})

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(
<QueryClientProvider client={firstQueryClient}>
<MagicLinkPage />
</QueryClientProvider>,
)
})
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(
<QueryClientProvider client={reloadedQueryClient}>
<MagicLinkPage />
</QueryClientProvider>,
)
})

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(
<QueryClientProvider client={queryClient}>
<MagicLinkPage />
</QueryClientProvider>,
)
})
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(
<QueryClientProvider client={queryClient}>
<MagicLinkPage />
</QueryClientProvider>,
)
})
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(
<QueryClientProvider client={queryClient}>
<MagicLinkPage />
</QueryClientProvider>,
)
})
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()
})
})
Loading