diff --git a/apps/api/app/auth/dependencies.py b/apps/api/app/auth/dependencies.py index 4e8ae34..8e63983 100644 --- a/apps/api/app/auth/dependencies.py +++ b/apps/api/app/auth/dependencies.py @@ -12,21 +12,25 @@ bearer_scheme = HTTPBearer(auto_error=False) async def _get_or_create_dev_user(session: AsyncSession) -> User: - """Return or create the fixed development user.""" result = await session.execute( select(User).where(User.authentik_sub == "dev-user") ) user = result.scalar_one_or_none() if user is None: - user = User( - authentik_sub="dev-user", - email="dev@localhost", - display_name="Dev User", - is_active=True, + result = await session.execute( + select(User).where(User.email == "dev@localhost") ) - session.add(user) - await session.commit() - await session.refresh(user) + user = result.scalar_one_or_none() + if user is None: + user = User( + authentik_sub="dev-user", + email="dev@localhost", + display_name="Dev User", + is_active=True, + ) + session.add(user) + await session.commit() + await session.refresh(user) return user @@ -44,7 +48,7 @@ async def get_current_user( ) try: - claims = decode_token(token.credentials) + claims = await decode_token(token.credentials) except Exception as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -69,6 +73,16 @@ async def get_current_user( user = result.scalar_one_or_none() if user is None: + result = await session.execute( + select(User).where(User.email == email) + ) + existing_user = result.scalar_one_or_none() + if existing_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User with email {email} already exists", + ) + user = User( authentik_sub=authentik_sub, email=email, @@ -105,7 +119,7 @@ async def validate_traefik_auth( ) try: - claims = decode_token(token.credentials) + claims = await decode_token(token.credentials) except Exception as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/apps/api/app/auth/jwt.py b/apps/api/app/auth/jwt.py index 476a345..e7d387c 100644 --- a/apps/api/app/auth/jwt.py +++ b/apps/api/app/auth/jwt.py @@ -1,31 +1,17 @@ from typing import Any +import httpx import jwt from app.config import settings +_jwks_cache: dict[str, Any] | None = None -def decode_token(token: str) -> dict[str, Any]: - """Decode a JWT token. - When authentik_issuer_url is configured, validates the token - against the OIDC discovery document JWKS. - Otherwise, decodes without verification (local development only). - """ +async def decode_token(token: str) -> dict[str, Any]: if settings.authentik_issuer_url: - import httpx - issuer = settings.authentik_issuer_url.rstrip("/") - discovery_url = f"{issuer}/.well-known/openid-configuration" - with httpx.Client() as client: - resp = client.get(discovery_url) - resp.raise_for_status() - discovery = resp.json() - jwks_uri = discovery["jwks_uri"] - - jwks_resp = client.get(jwks_uri) - jwks_resp.raise_for_status() - jwks = jwks_resp.json() + jwks = await _get_jwks(issuer) signing_key = jwt.algorithms.RSAAlgorithm.from_jwk( _find_matching_key(jwks, token) @@ -42,8 +28,27 @@ def decode_token(token: str) -> dict[str, Any]: return jwt.decode(token, options={"verify_signature": False}) +async def _get_jwks(issuer: str) -> dict[str, Any]: + global _jwks_cache + + if _jwks_cache is not None: + return _jwks_cache + + discovery_url = f"{issuer}/.well-known/openid-configuration" + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + discovery = resp.json() + jwks_uri = discovery["jwks_uri"] + + jwks_resp = await client.get(jwks_uri) + jwks_resp.raise_for_status() + _jwks_cache = jwks_resp.json() + + return _jwks_cache + + def _find_matching_key(jwks: dict[str, Any], token: str) -> dict[str, Any]: - """Find the key in JWKS that matches the token's kid header.""" unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") for key in jwks.get("keys", []): diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 6545814..905cc42 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -21,7 +21,7 @@ class Settings(BaseSettings): database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter" # CORS - cors_origins: str = "http://localhost:5173" + cors_origins: str = "http://localhost:5173,http://localhost:3000" # Deployment root_domain: str = "localhost" diff --git a/apps/api/app/main.py b/apps/api/app/main.py index fa2d6dd..e1c9ac3 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -32,7 +32,7 @@ app = FastAPI( lifespan=lifespan, ) -allow_origins = ["*"] if settings.debug else [] +allow_origins = settings.cors_origins.split(",") if settings.cors_origins else [] app.add_middleware( CORSMiddleware, allow_origins=allow_origins, diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py index 4a6a600..c3e2b7b 100644 --- a/apps/api/tests/test_auth.py +++ b/apps/api/tests/test_auth.py @@ -64,3 +64,24 @@ async def test_inactive_user_raises_403( await session.commit() assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_dev_bypass_email_uniqueness( + auth_client: AsyncClient, db_session: async_sessionmaker[Any] +) -> None: + async with db_session() as session: + result = await session.execute(select(User).where(User.email == "dev@localhost")) + existing = result.scalar_one_or_none() + if existing: + await session.delete(existing) + await session.commit() + + response = await auth_client.get("/api/v1/users/me") + assert response.status_code == 200 + + async with db_session() as session: + result = await session.execute(select(User).where(User.email == "dev@localhost")) + user = result.scalar_one_or_none() + assert user is not None + assert user.authentik_sub == "dev-user" diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e6fe487..189222c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,5 @@ import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Repository, RepositoryConnection, RepositoryConnectionCreate, RepositoryCreate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts' +import { getToken, clearToken } from '../auth/oidc' const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' @@ -13,8 +14,49 @@ export class ApiError extends Error { } } -function getAuthToken(): string | null { - return localStorage.getItem('access_token') +let isRefreshing = false +let refreshPromise: Promise | null = null + +async function refreshToken(): Promise { + if (isRefreshing && refreshPromise) { + return refreshPromise + } + + isRefreshing = true + refreshPromise = (async () => { + try { + const token = getToken() + if (!token) return null + + const response = await fetch(`${API_URL}/api/v1/auth/refresh`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + clearToken() + return null + } + + const data = await response.json() + if (data.access_token) { + localStorage.setItem('access_token', data.access_token) + return data.access_token + } + return null + } catch { + clearToken() + return null + } finally { + isRefreshing = false + refreshPromise = null + } + })() + + return refreshPromise } async function fetchWithAuth( @@ -22,7 +64,7 @@ async function fetchWithAuth( options: RequestInit = {} ): Promise { const url = `${API_URL}/api/v1${endpoint}` - const token = getAuthToken() + const token = getToken() const headers: Record = { 'Content-Type': 'application/json', @@ -46,6 +88,22 @@ async function fetchWithAuth( console.log(`[API] ${response.status} ${response.statusText}`) } + if (response.status === 401 && token) { + const newToken = await refreshToken() + if (newToken) { + headers['Authorization'] = `Bearer ${newToken}` + const retryResponse = await fetch(url, { + ...options, + headers, + }) + if (!retryResponse.ok) { + const data = await retryResponse.json().catch(() => undefined) + throw new ApiError(retryResponse.status, retryResponse.statusText, data) + } + return retryResponse + } + } + if (!response.ok) { const data = await response.json().catch(() => undefined) throw new ApiError(response.status, response.statusText, data) diff --git a/apps/web/src/auth/AuthProvider.tsx b/apps/web/src/auth/AuthProvider.tsx index 020b4e0..52ed6ca 100644 --- a/apps/web/src/auth/AuthProvider.tsx +++ b/apps/web/src/auth/AuthProvider.tsx @@ -1,25 +1,45 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAuthStore } from '../stores/auth' import { api } from '../api/client' +import { getToken, clearToken } from './oidc' export function AuthProvider({ children }: { children: React.ReactNode }) { const { setUser, setError, setState } = useAuthStore() + const isMounted = useRef(true) + + useEffect(() => { + return () => { + isMounted.current = false + } + }, []) useEffect(() => { const initAuth = async () => { try { - const token = localStorage.getItem('access_token') + const token = getToken() if (!token) { - setState('unauthenticated') + if (isMounted.current) { + setState('unauthenticated') + } return } const user = await api.getCurrentUser() - setUser(user) + + if (isMounted.current) { + setUser(user) + } } catch (error) { console.error('Auth initialization failed:', error) - setError(error instanceof Error ? error : new Error('Auth failed')) - localStorage.removeItem('access_token') + + if (error instanceof Error && error.message.includes('401')) { + clearToken() + } + + if (isMounted.current) { + setError(error instanceof Error ? error : new Error('Auth failed')) + clearToken() + } } } diff --git a/apps/web/src/auth/oidc.ts b/apps/web/src/auth/oidc.ts index bd6dca9..942a54a 100644 --- a/apps/web/src/auth/oidc.ts +++ b/apps/web/src/auth/oidc.ts @@ -1,4 +1,3 @@ -// PKCE utilities for OIDC flow function generateRandomString(length: number): string { const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' let text = '' @@ -34,6 +33,18 @@ export function clearPKCE(): void { sessionStorage.removeItem('pkce_verifier') } +export function storeState(state: string): void { + sessionStorage.setItem('oidc_state', state) +} + +export function getState(): string | null { + return sessionStorage.getItem('oidc_state') +} + +export function clearState(): void { + sessionStorage.removeItem('oidc_state') +} + export function storeToken(token: string): void { localStorage.setItem('access_token', token) } diff --git a/apps/web/src/pages/CallbackPage.tsx b/apps/web/src/pages/CallbackPage.tsx index b65bddc..220c010 100644 --- a/apps/web/src/pages/CallbackPage.tsx +++ b/apps/web/src/pages/CallbackPage.tsx @@ -1,10 +1,9 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { getPKCE, clearPKCE, storeToken } from '../auth/oidc' +import { getPKCE, clearPKCE, storeToken, getState, clearState } from '../auth/oidc' import { api } from '../api/client' import { useAuthStore } from '../stores/auth' -const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI @@ -12,6 +11,13 @@ export default function CallbackPage() { const navigate = useNavigate() const { setUser, setError } = useAuthStore() const [status, setStatus] = useState('Processing authentication...') + const isMounted = useRef(true) + + useEffect(() => { + return () => { + isMounted.current = false + } + }, []) useEffect(() => { const handleCallback = async () => { @@ -19,6 +25,8 @@ export default function CallbackPage() { const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') const error = urlParams.get('error') + const state = urlParams.get('state') + const storedState = getState() if (error) { throw new Error(`Authentication error: ${error}`) @@ -28,12 +36,18 @@ export default function CallbackPage() { throw new Error('No authorization code received') } + if (!state || state !== storedState) { + throw new Error('Invalid or missing state parameter') + } + const verifier = getPKCE() if (!verifier) { throw new Error('PKCE verifier not found') } - setStatus('Exchanging code for token...') + if (isMounted.current) { + setStatus('Exchanging code for token...') + } const tokenEndpoint = 'https://auth.commumedia.org/application/o/token/' const tokenResponse = await fetch(tokenEndpoint, { @@ -51,23 +65,35 @@ export default function CallbackPage() { }) if (!tokenResponse.ok) { - throw new Error('Token exchange failed') + const errorData = await tokenResponse.json().catch(() => ({})) + throw new Error(errorData.error_description || errorData.error || 'Token exchange failed') } const tokenData = await tokenResponse.json() storeToken(tokenData.access_token) clearPKCE() + clearState() + + if (isMounted.current) { + setStatus('Fetching user information...') + } - setStatus('Fetching user information...') const user = await api.getCurrentUser() - setUser(user) - navigate('/') + if (isMounted.current) { + setUser(user) + navigate('/') + } } catch (error) { console.error('Callback error:', error) - setError(error instanceof Error ? error : new Error('Authentication failed')) - setStatus('Authentication failed') - setTimeout(() => navigate('/login'), 3000) + clearPKCE() + clearState() + + if (isMounted.current) { + setError(error instanceof Error ? error : new Error('Authentication failed')) + setStatus('Authentication failed') + setTimeout(() => navigate('/login'), 3000) + } } } diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index f7b2151..8cf27f8 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -1,16 +1,25 @@ import { useEffect } from 'react' -import { createPKCE, storePKCE } from '../auth/oidc' +import { createPKCE, storePKCE, storeState } from '../auth/oidc' const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI +function generateState(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') +} + export default function LoginPage() { useEffect(() => { const initiateLogin = async () => { const { verifier, challenge } = await createPKCE() storePKCE(verifier) + const state = generateState() + storeState(state) + const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, @@ -18,6 +27,7 @@ export default function LoginPage() { scope: 'openid profile email', code_challenge: challenge, code_challenge_method: 'S256', + state, }) const authorizationEndpoint = 'https://auth.commumedia.org/application/o/authorize/' diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 72e3f09..57657ee 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -20,5 +20,8 @@ export const useAuthStore = create((set) => ({ setState: (state) => set({ state }), setUser: (user) => set({ user, state: user ? 'authenticated' : 'unauthenticated' }), setError: (error) => set({ error, state: 'error' }), - logout: () => set({ user: null, state: 'unauthenticated', error: null }), + logout: () => { + localStorage.removeItem('access_token') + set({ user: null, state: 'unauthenticated', error: null }) + }, }))