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:
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
const mockSetLastContext = vi.fn()
|
const mockSetLastContext = vi.fn()
|
||||||
const mockResolveLandingRoute = vi.fn(() => ({ path: '/portal/evenementen' }))
|
const mockResolveLandingRoute = vi.fn(() => '/portal/evenementen')
|
||||||
const mockPush = vi.fn()
|
const mockPush = vi.fn()
|
||||||
|
|
||||||
const authStoreState: Record<string, unknown> = {
|
const authStoreState: Record<string, unknown> = {
|
||||||
@@ -67,6 +67,6 @@ describe('ContextSwitcher', () => {
|
|||||||
|
|
||||||
expect(mockSetLastContext).toHaveBeenCalledWith('portal')
|
expect(mockSetLastContext).toHaveBeenCalledWith('portal')
|
||||||
expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal')
|
expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal')
|
||||||
expect(mockPush).toHaveBeenCalledWith({ path: '/portal/evenementen' })
|
expect(mockPush).toHaveBeenCalledWith('/portal/evenementen')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import authV2MaskDark from '@images/pages/misc-mask-dark.png'
|
|||||||
import authV2MaskLight from '@images/pages/misc-mask-light.png'
|
import authV2MaskLight from '@images/pages/misc-mask-light.png'
|
||||||
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
|
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
|
||||||
import { themeConfig } from '@themeConfig'
|
import { themeConfig } from '@themeConfig'
|
||||||
import { apiClient } from '@/lib/axios'
|
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
import { emailValidator, requiredValidator } from '@core/utils/validators'
|
import { emailValidator, requiredValidator } from '@core/utils/validators'
|
||||||
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
|
import { resolvePostLoginTarget as resolvePostLoginPath } from '@/utils/postLoginRedirect'
|
||||||
import type { LoginCredentials, LoginResponse } from '@/types/auth'
|
import type { LoginCredentials } from '@/types/auth'
|
||||||
import type { MfaMethod } from '@/types/mfa'
|
import type { MfaMethod } from '@/types/mfa'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
@@ -56,58 +55,64 @@ const authThemeImg = useGenerateImageVariant(
|
|||||||
|
|
||||||
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
|
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
|
||||||
|
|
||||||
|
function resolvePostLoginTarget(): string {
|
||||||
|
const rawTo = route.query.to ? String(route.query.to) : ''
|
||||||
|
|
||||||
|
return resolvePostLoginPath(rawTo, () => authStore.resolveLandingRoute())
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
isPending.value = true
|
isPending.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fingerprint = generateDeviceFingerprint()
|
const credentials: LoginCredentials = {
|
||||||
|
|
||||||
const { data } = await apiClient.post<LoginResponse>('/auth/login', {
|
|
||||||
email: form.value.email,
|
email: form.value.email,
|
||||||
password: form.value.password,
|
password: form.value.password,
|
||||||
}, {
|
|
||||||
headers: { 'X-Device-Fingerprint': fingerprint },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data.mfa_required) {
|
|
||||||
mfaSessionToken.value = data.mfa_session_token!
|
|
||||||
mfaMethods.value = (data.methods ?? []) as MfaMethod[]
|
|
||||||
mfaPreferredMethod.value = data.preferred_method ?? 'totp'
|
|
||||||
mfaExpiresIn.value = data.expires_in ?? 600
|
|
||||||
showMfaChallenge.value = true
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal login success
|
const result = await authStore.login(credentials)
|
||||||
authStore.setUser(data.data.user)
|
|
||||||
|
|
||||||
if (data.mfa_setup_required) {
|
switch (result.kind) {
|
||||||
router.replace('/account-settings?tab=security')
|
case 'mfa-required':
|
||||||
|
mfaSessionToken.value = result.sessionToken
|
||||||
|
mfaMethods.value = result.methods
|
||||||
|
mfaPreferredMethod.value = result.preferredMethod
|
||||||
|
mfaExpiresIn.value = result.expiresIn
|
||||||
|
showMfaChallenge.value = true
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
const rawTo = route.query.to ? String(route.query.to) : ''
|
case 'authenticated':
|
||||||
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
|
if (authStore.mfaSetupRequired) {
|
||||||
|
router.replace('/account-settings?tab=security')
|
||||||
|
|
||||||
router.replace(redirectTo)
|
return
|
||||||
}
|
}
|
||||||
catch (err: unknown) {
|
router.replace(resolvePostLoginTarget())
|
||||||
const errorData = (err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }).response?.data
|
|
||||||
|
|
||||||
if (errorData?.errors) {
|
return
|
||||||
errors.value = {
|
|
||||||
email: errorData.errors.email?.[0] ?? '',
|
case 'must-set-password':
|
||||||
password: errorData.errors.password?.[0] ?? '',
|
// Forward-compatible — backend doesn't surface this kind today.
|
||||||
}
|
// Placeholder route until the must-set-password flow is wired.
|
||||||
}
|
router.replace('/account-settings?tab=security')
|
||||||
else if (errorData?.message) {
|
|
||||||
errors.value = { email: errorData.message }
|
return
|
||||||
}
|
|
||||||
else {
|
case 'failed':
|
||||||
errors.value = { email: 'Er is een fout opgetreden. Probeer het opnieuw.' }
|
if (result.errors) {
|
||||||
|
errors.value = {
|
||||||
|
email: result.errors.email?.[0] ?? '',
|
||||||
|
password: result.errors.password?.[0] ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (result.reason === 'rate_limited') {
|
||||||
|
errors.value = { email: 'Te veel pogingen. Wacht even en probeer opnieuw.' }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
errors.value = { email: result.reason || 'Er is een fout opgetreden. Probeer het opnieuw.' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -116,14 +121,10 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMfaVerified() {
|
function onMfaVerified() {
|
||||||
// After MFA verify, the response sets the auth cookie. Use refreshUser()
|
// After MFA verify, the response sets the auth cookie. refreshUser()
|
||||||
// (not initialize() — that's guarded by isInitialized and returns immediately)
|
// hydrates the store from /auth/me with the new cookie.
|
||||||
// to call GET /auth/me with the new cookie, populating the store.
|
|
||||||
authStore.refreshUser().then(() => {
|
authStore.refreshUser().then(() => {
|
||||||
const rawTo = route.query.to ? String(route.query.to) : ''
|
router.replace(resolvePostLoginTarget())
|
||||||
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
|
|
||||||
|
|
||||||
router.replace(redirectTo)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ describe('router guards (WS-3 PR-B2a)', () => {
|
|||||||
() => {},
|
() => {},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toEqual({ path: '/portal/evenementen' })
|
expect(result).toBe('/portal/evenementen')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('MFA setup gate redirects to /account-settings when mfaSetupRequired', async () => {
|
it('MFA setup gate redirects to /account-settings when mfaSetupRequired', async () => {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
|||||||
|
|
||||||
const target = store.resolveLandingRoute()
|
const target = store.resolveLandingRoute()
|
||||||
|
|
||||||
expect(target).toEqual({ path: '/portal/evenementen' })
|
expect(target).toBe('/portal/evenementen')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes to organizer dashboard when only organizer context is available', () => {
|
it('routes to organizer dashboard when only organizer context is available', () => {
|
||||||
@@ -138,7 +138,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
|||||||
contexts: { available: ['organizer'], default: 'organizer' },
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
|
expect(store.resolveLandingRoute()).toBe('/dashboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes to platform dashboard for super_admin without an active org', () => {
|
it('routes to platform dashboard for super_admin without an active org', () => {
|
||||||
@@ -150,7 +150,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
|||||||
contexts: { available: ['organizer'], default: 'organizer' },
|
contexts: { available: ['organizer'], default: 'organizer' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
expect(store.resolveLandingRoute()).toEqual({ name: 'platform' })
|
expect(store.resolveLandingRoute()).toBe('/platform')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('multi-role user — defaultContext wins when no lastContext is set', () => {
|
it('multi-role user — defaultContext wins when no lastContext is set', () => {
|
||||||
@@ -162,7 +162,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
|||||||
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' })
|
expect(store.resolveLandingRoute()).toBe('/dashboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('multi-role user — lastContext overrides defaultContext', () => {
|
it('multi-role user — lastContext overrides defaultContext', () => {
|
||||||
@@ -176,7 +176,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
|||||||
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
contexts: { available: ['portal', 'organizer'], default: 'organizer' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
expect(store.resolveLandingRoute()).toEqual({ path: '/portal/evenementen' })
|
expect(store.resolveLandingRoute()).toBe('/portal/evenementen')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forceContext overrides both lastContext and defaultContext', () => {
|
it('forceContext overrides both lastContext and defaultContext', () => {
|
||||||
@@ -189,13 +189,13 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => {
|
|||||||
}))
|
}))
|
||||||
store.setLastContext('organizer')
|
store.setLastContext('organizer')
|
||||||
|
|
||||||
expect(store.resolveLandingRoute('portal')).toEqual({ path: '/portal/evenementen' })
|
expect(store.resolveLandingRoute('portal')).toBe('/portal/evenementen')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns forbidden when user has no contexts available', () => {
|
it('returns forbidden when user has no contexts available', () => {
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
|
||||||
expect(store.resolveLandingRoute()).toEqual({ name: 'forbidden' })
|
expect(store.resolveLandingRoute()).toBe('/forbidden')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||||
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
|
import { generateDeviceFingerprint } from '@/utils/deviceFingerprint'
|
||||||
@@ -135,29 +134,32 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the post-login or post-context-switch landing route. A
|
* Resolve the post-login or post-context-switch landing path. A
|
||||||
* `forceContext` overrides the lastContext + defaultContext precedence
|
* `forceContext` overrides the lastContext + defaultContext precedence
|
||||||
* (used by the context-switcher when the user explicitly chooses).
|
* (used by the context-switcher when the user explicitly chooses).
|
||||||
|
*
|
||||||
|
* Returns a string path (not a RouteLocationRaw object) so consumers
|
||||||
|
* can pass it directly to the typed router without casting.
|
||||||
*/
|
*/
|
||||||
function resolveLandingRoute(forceContext?: AuthContext): RouteLocationRaw {
|
function resolveLandingRoute(forceContext?: AuthContext): string {
|
||||||
const ctx = forceContext ?? lastContext.value ?? defaultContext.value
|
const ctx = forceContext ?? lastContext.value ?? defaultContext.value
|
||||||
|
|
||||||
if (ctx === 'portal' && availableContexts.value.includes('portal'))
|
if (ctx === 'portal' && availableContexts.value.includes('portal'))
|
||||||
return { path: '/portal/evenementen' }
|
return '/portal/evenementen'
|
||||||
|
|
||||||
if (ctx === 'organizer' && availableContexts.value.includes('organizer')) {
|
if (ctx === 'organizer' && availableContexts.value.includes('organizer')) {
|
||||||
if (isSuperAdmin.value && organisations.value.length === 0)
|
if (isSuperAdmin.value && organisations.value.length === 0)
|
||||||
return { name: 'platform' }
|
return '/platform'
|
||||||
|
|
||||||
return { name: 'dashboard' }
|
return '/dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableContexts.value.includes('organizer'))
|
if (availableContexts.value.includes('organizer'))
|
||||||
return { name: 'dashboard' }
|
return '/dashboard'
|
||||||
if (availableContexts.value.includes('portal'))
|
if (availableContexts.value.includes('portal'))
|
||||||
return { path: '/portal/evenementen' }
|
return '/portal/evenementen'
|
||||||
|
|
||||||
return { name: 'forbidden' }
|
return '/forbidden'
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearState() {
|
function clearState() {
|
||||||
|
|||||||
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