fix: tighten 401 redirect allowlist and add reset-password paths

Replaced loose includes()/startsWith() path checks with exact equality
for static routes and strict prefix matching for dynamic-token routes.
Added /forgot-password and /reset-password to the allowlist so the
password-reset flow is usable without auth. Extracted isAuthPublicPath
as a pure testable function with 14 unit tests covering regressions.
This commit is contained in:
jubnl
2026-04-20 21:55:15 +02:00
parent 4a5a59cb78
commit b556c636eb
2 changed files with 80 additions and 2 deletions
+9 -2
View File
@@ -62,13 +62,20 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error) (error) => Promise.reject(error)
) )
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit // Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) { const { pathname } = window.location
const currentPath = window.location.pathname + window.location.search if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
} }
} }
@@ -0,0 +1,71 @@
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
import { describe, it, expect } from 'vitest'
import { isAuthPublicPath } from '../../../src/api/client'
describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
describe('exact-match public paths — no redirect', () => {
it('FE-CLIENT-INTERCEPTOR-001: /login', () => {
expect(isAuthPublicPath('/login')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-002: /register', () => {
expect(isAuthPublicPath('/register')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-003: /forgot-password', () => {
expect(isAuthPublicPath('/forgot-password')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
expect(isAuthPublicPath('/reset-password')).toBe(true)
})
})
describe('prefix-match public paths — no redirect', () => {
it('FE-CLIENT-INTERCEPTOR-005: /shared/:token', () => {
expect(isAuthPublicPath('/shared/abc123token')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-006: /public/journey/:token', () => {
expect(isAuthPublicPath('/public/journey/xyz789')).toBe(true)
})
})
describe('paths that matched via includes() before fix — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-007: /admin/login', () => {
expect(isAuthPublicPath('/admin/login')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-008: /admin/register', () => {
expect(isAuthPublicPath('/admin/register')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-009: /some-login-page', () => {
expect(isAuthPublicPath('/some-login-page')).toBe(false)
})
})
describe('paths that matched via loose startsWith before fix — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-010: /reset-password-extra', () => {
expect(isAuthPublicPath('/reset-password-extra')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-011: /forgot-password-extra', () => {
expect(isAuthPublicPath('/forgot-password-extra')).toBe(false)
})
})
describe('private app paths — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-012: /dashboard', () => {
expect(isAuthPublicPath('/dashboard')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-013: /trips/123', () => {
expect(isAuthPublicPath('/trips/123')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-014: / (root)', () => {
expect(isAuthPublicPath('/')).toBe(false)
})
})
})