feat(auth): post-login landing route resolution per context

login.vue is rewritten to consume useAuthStore.login()'s discriminated
union — no more direct apiClient calls or branching on raw API response
shapes. The page maps result.kind to UI/routing decisions only:

- mfa-required → swap to MfaChallengeCard with the typed payload
- authenticated → resolvePostLoginTarget() (?to= relative, else
  auth.resolveLandingRoute())
- must-set-password → forward-compatible placeholder route
- failed → field-level errors + rate_limit message branch

resolveLandingRoute() now returns a string path instead of
RouteLocationRaw — the typed router accepts string-paths cleanly,
removes the cast at every call site, and lets useAuthStore.spec.ts +
guards.spec.ts assert the resolved path directly.

A13-3 minimum precaution lives in a new utility:
src/utils/postLoginRedirect.ts. The relative-only check
(`startsWith('/') && !startsWith('//')`) rejects absolute, protocol-
relative, javascript:, and data: schemes. Full domain validation lands
in WS-3 PR-B2b.

6 vitest specs in utils/__tests__/postLoginRedirect.spec.ts cover the
six rejection / passthrough scenarios.

Test count 192 → 198. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:40:32 +02:00
parent 209e0ef682
commit 38a94c78e9
7 changed files with 127 additions and 67 deletions

View File

@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { resolvePostLoginTarget } from '@/utils/postLoginRedirect'
describe('resolvePostLoginTarget — A13-3 minimum open-redirect guard', () => {
it('returns the relative ?to= path when it starts with /', () => {
const fallback = vi.fn(() => '/dashboard')
const result = resolvePostLoginTarget('/events/01ABC', fallback)
expect(result).toBe('/events/01ABC')
expect(fallback).not.toHaveBeenCalled()
})
it('falls back to the resolver when ?to= is missing', () => {
expect(resolvePostLoginTarget('', () => '/dashboard')).toBe('/dashboard')
expect(resolvePostLoginTarget(null, () => '/dashboard')).toBe('/dashboard')
expect(resolvePostLoginTarget(undefined, () => '/dashboard')).toBe('/dashboard')
})
it('rejects absolute URLs (open-redirect attempt)', () => {
expect(resolvePostLoginTarget('https://evil.com', () => '/dashboard')).toBe('/dashboard')
expect(resolvePostLoginTarget('http://evil.com/x', () => '/dashboard')).toBe('/dashboard')
})
it('rejects protocol-relative URLs (//evil.com)', () => {
expect(resolvePostLoginTarget('//evil.com', () => '/dashboard')).toBe('/dashboard')
expect(resolvePostLoginTarget('//evil.com/path', () => '/dashboard')).toBe('/dashboard')
})
it('rejects javascript: and data: schemes', () => {
expect(resolvePostLoginTarget('javascript:alert(1)', () => '/dashboard')).toBe('/dashboard')
expect(resolvePostLoginTarget('data:text/html,<script>', () => '/dashboard')).toBe('/dashboard')
})
it('preserves query parameters and fragments on relative paths', () => {
expect(resolvePostLoginTarget('/events?tab=overview#info', () => '/x'))
.toBe('/events?tab=overview#info')
})
})

View File

@@ -0,0 +1,19 @@
/**
* Resolve the post-login redirect target. If the caller supplied a `?to=`
* query that's a same-origin relative path, honour it; otherwise fall back
* to the auth-store's resolveLandingRoute().
*
* The `startsWith('/')` + `!startsWith('//')` guard is the **minimum**
* A13-3 (open-redirect) precaution. Full domain-validation lands in
* WS-3 PR-B2b.
*/
export function resolvePostLoginTarget(
rawTo: string | null | undefined,
fallback: () => string,
): string {
const to = rawTo ?? ''
if (to.startsWith('/') && !to.startsWith('//'))
return to
return fallback()
}