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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ ARG XNET_VERSION=0.56.0
# renovate: datasource=go depName=golang.org/x/crypto
ARG XCRYPTO_VERSION=0.53.0
# renovate: datasource=npm depName=npm
ARG NPM_VERSION=11.17.0
ARG NPM_VERSION=11.18.0

# Allow pinning Caddy version - Renovate will update this
# Build the most recent Caddy 2.x release (keeps major pinned under v3).
Expand Down Expand Up @@ -485,7 +485,7 @@ RUN go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION} && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.13 && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.0 && \
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.1 && \
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.7 && \
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.102.1 && \
# CVE-2026-32952: go-ntlmssp DoS via malicious NTLM challenge response
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, type ReactNode, type FC } from 'react';
import { toast } from 'react-hot-toast';

import { AuthContext, type User } from './AuthContextValue';
import client, { setAuthToken, setAuthErrorHandler } from '../api/client';
Expand All @@ -19,12 +20,15 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {

// Handle session expiry by clearing auth state and redirecting to login
const handleAuthError = useCallback(() => {
console.warn('Session expired, clearing auth state');
invalidateAuthRequests();
localStorage.removeItem('charon_auth_token');
setAuthToken(null);
setUser(null);
setIsLoading(false);
toast.error('Session expired. Please log in again.', {
id: 'auth-session-expired',
duration: 10000,
});
}, [invalidateAuthRequests]);

// Register auth error handler on mount; unregister on unmount so the axios
Expand Down Expand Up @@ -76,6 +80,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const requestVersion = authRequestVersionRef.current + 1;
authRequestVersionRef.current = requestVersion;
setIsLoading(true);
toast.dismiss('auth-session-expired');

if (token) {
localStorage.setItem('charon_auth_token', token);
Expand Down
43 changes: 40 additions & 3 deletions frontend/src/context/__tests__/AuthContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { toast } from 'react-hot-toast'
import { describe, it, expect, beforeEach, vi } from 'vitest'

import client, { setAuthToken, setAuthErrorHandler } from '../../api/client'
Expand All @@ -7,6 +8,13 @@ import { useAuth } from '../../hooks/useAuth'

const TOKEN_KEY = 'charon_auth_token'

vi.mock('react-hot-toast', () => ({
toast: {
error: vi.fn(),
dismiss: vi.fn(),
},
}))

vi.mock('../../api/client', () => ({
default: {
post: vi.fn().mockResolvedValue({}),
Expand All @@ -19,6 +27,7 @@ vi.mock('../../api/client', () => ({
const mockClient = vi.mocked(client)
const mockSetAuthToken = vi.mocked(setAuthToken)
const mockSetAuthErrorHandler = vi.mocked(setAuthErrorHandler)
const mockToast = vi.mocked(toast)

const AuthStateProbe = () => {
const { user, isAuthenticated, isLoading } = useAuth()
Expand Down Expand Up @@ -83,10 +92,9 @@ describe('<AuthProvider /> session validation on mount (page reload)', () => {
expect(mockSetAuthToken).toHaveBeenCalledWith('valid-token')
})

it('registers an auth-error handler that clears the session, and unregisters it on unmount', async () => {
it('registers an auth-error handler that clears the session, shows a toast, and unregisters on unmount', async () => {
localStorage.setItem(TOKEN_KEY, 'valid-token')
mockClient.get.mockResolvedValue({ data: sessionUser, status: 200 })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

const { unmount } = renderProvider()
await waitFor(() => expect(screen.getByTestId('authenticated').textContent).toBe('true'))
Expand All @@ -99,10 +107,39 @@ describe('<AuthProvider /> session validation on mount (page reload)', () => {
await waitFor(() => expect(screen.getByTestId('authenticated').textContent).toBe('false'))
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
expect(mockSetAuthToken).toHaveBeenLastCalledWith(null)
expect(mockToast.error).toHaveBeenCalledWith(
'Session expired. Please log in again.',
expect.objectContaining({ id: 'auth-session-expired' })
)

unmount()
expect(mockSetAuthErrorHandler).toHaveBeenLastCalledWith(null)
})

it('dismisses the session-expired toast when login succeeds', async () => {
mockClient.get.mockResolvedValue({ data: sessionUser, status: 200 })

warnSpy.mockRestore()
let loginFn: ((token?: string) => Promise<void>) | undefined
const LoginProbe = () => {
const { login } = useAuth()
loginFn = login
return null
}

render(
<AuthProvider>
<LoginProbe />
<AuthStateProbe />
</AuthProvider>
)

await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))

await act(async () => {
await loginFn?.('new-token')
})

expect(mockToast.dismiss).toHaveBeenCalledWith('auth-session-expired')
expect(screen.getByTestId('authenticated').textContent).toBe('true')
})
})
22 changes: 17 additions & 5 deletions tests/core/authentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,18 @@ test.describe('Authentication Flows', () => {
test('should show validation error for invalid email format', async ({ page }) => {
await page.goto('/login');

await page.locator('input[type="email"]').fill('not-an-email');
// Wait for the setup-status check to complete so the login form is visible.
// In slow Firefox CI environments the form is replaced by a loading screen
// while the check is in-flight, causing fill() to time out.
const emailInput = page.locator('input[type="email"]');
await emailInput.waitFor({ state: 'visible', timeout: 15000 });

await emailInput.fill('not-an-email');
await page.locator('input[type="password"]').fill('SomePassword123!');

await test.step('Verify email validation error', async () => {
await page.getByRole('button', { name: /sign in/i }).click();

const emailInput = page.locator('input[type="email"]');

// Check for HTML5 validation state or aria-invalid or visible error text
const isInvalid =
(await emailInput.getAttribute('aria-invalid')) === 'true' ||
Expand Down Expand Up @@ -369,7 +373,15 @@ test.describe('Authentication Flows', () => {
test('should handle 401 response gracefully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);

await test.step('Intercept API calls to return 401', async () => {
await test.step('Simulate expired session (clear token and intercept API with 401)', async () => {
// Clear the auth token from storage so checkAuth immediately sees an
// invalid session without needing a network round-trip. This avoids
// a Firefox CDP timing issue where XHR requests fired during page.goto()
// navigation are not intercepted by Playwright route handlers.
await page.evaluate(() => localStorage.removeItem('charon_auth_token'));

// Also intercept API calls to return 401 to test the axios error
// interceptor path (defense-in-depth).
await page.route('**/api/v1/**', async (route) => {
// Let health check through, block others with 401
if (route.request().url().includes('/health')) {
Expand All @@ -384,7 +396,7 @@ test.describe('Authentication Flows', () => {
});
});

await test.step('Trigger an API call by navigating', async () => {
await test.step('Trigger an auth check by navigating', async () => {
await page.goto('/proxy-hosts');
// Wait for the 401 response to be processed and UI to react
await waitForDebounce(page);
Expand Down
Loading