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:
38
apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
Normal file
38
apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
19
apps/app/src/utils/postLoginRedirect.ts
Normal file
19
apps/app/src/utils/postLoginRedirect.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user