Skip to content
Open
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
131 changes: 129 additions & 2 deletions backend/secuscan/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,141 @@
Clients must supply it via:
- Authorization: Bearer <key>
- X-Api-Key: <key>

Session management (signed cookie, no server-side state):
- POST /api/v1/auth/session — validate API key and set HttpOnly session cookie
- GET /api/v1/auth/session/check — verify active session cookie
- POST /api/v1/auth/session/logout — clear session cookie
"""

import base64
import hmac
import json
import os
import secrets
import time
from pathlib import Path

from fastapi import Depends, HTTPException, Security, status, Request
from fastapi import Depends, HTTPException, Security, status, Request, Response
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from fastapi import APIRouter

_bearer_scheme = HTTPBearer(auto_error=False)
_api_key_header = APIKeyHeader(name="X-Api-Key", auto_error=False)

_api_key: str | None = None

SESSION_TTL_SECONDS = 3600 # 1 hour
COOKIE_NAME = "secuscan_session"
_SIGNING_KEY: bytes | None = None


def _init_signing_key() -> bytes:
global _SIGNING_KEY
if _SIGNING_KEY is None:
_SIGNING_KEY = secrets.token_bytes(32)
return _SIGNING_KEY


def _make_signed_token() -> str:
key = _init_signing_key()
expires = int(time.time()) + SESSION_TTL_SECONDS
payload = json.dumps({"s": secrets.token_urlsafe(16), "e": expires}, separators=(",", ":")).encode()
payload_b64 = base64.urlsafe_b64encode(payload).decode().rstrip("=")
sig = hmac.new(key, payload, "sha256").hexdigest()
return f"{payload_b64}.{sig}"


def _verify_signed_token(token: str) -> bool:
key = _init_signing_key()
try:
parts = token.split(".")
if len(parts) != 2:
return False
payload_b64, sig = parts
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload = base64.urlsafe_b64decode(payload_b64.encode())
expected_sig = hmac.new(key, payload, "sha256").hexdigest()
if not secrets.compare_digest(expected_sig, sig):
return False
data = json.loads(payload)
if time.time() > data["e"]:
return False
return True
except Exception:
return False


def _cookie_secure(request: Request) -> bool:
forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
if forwarded_proto.lower() == "https":
return True
return request.url.scheme == "https"


auth_router = APIRouter(prefix="/api/v1/auth")


@auth_router.post("/session")
async def create_session(request: Request, response: Response):
"""Validate the API key and set an HttpOnly session cookie.

The client sends the API key via the X-Api-Key header (or Authorization
Bearer). On success the server sets a signed HttpOnly session cookie so
the key itself never needs to touch localStorage. The cookie is self-
contained (HMAC-signed) and requires no server-side session store.
The Secure flag is only set when the request arrives over HTTPS or
carries an X-Forwarded-Proto: https header, preserving HTTP localhost
development.
"""
if _api_key is None:
raise HTTPException(
status_code=503, detail="Authentication service not initialised"
)

candidate = request.headers.get("X-Api-Key")
if not candidate:
bearer = request.headers.get("Authorization", "")
if bearer.lower().startswith("bearer "):
candidate = bearer[7:]

if not candidate or not secrets.compare_digest(candidate, _api_key):
raise HTTPException(status_code=401, detail="Invalid API key")

token = _make_signed_token()
response.set_cookie(
key=COOKIE_NAME,
value=token,
httponly=True,
secure=_cookie_secure(request),
samesite="strict",
max_age=SESSION_TTL_SECONDS,
)
return {"status": "authenticated"}


@auth_router.get("/session/check")
async def check_session(request: Request):
"""Return whether the request carries a valid signed session cookie."""
token = request.cookies.get(COOKIE_NAME)
if token and _verify_signed_token(token):
return {"authenticated": True}
return {"authenticated": False}


@auth_router.post("/session/logout")
async def logout_session(request: Request, response: Response):
"""Destroy the session cookie."""
response.delete_cookie(COOKIE_NAME)
return {"status": "logged_out"}


def is_authenticated_by_session(request: Request) -> bool:
token = request.cookies.get(COOKIE_NAME)
return bool(token and _verify_signed_token(token))


def init_api_key(data_dir: str) -> str:
"""
Expand Down Expand Up @@ -47,17 +168,23 @@ async def require_api_key(
x_api_key: str | None = Security(_api_key_header),
) -> str:
"""
FastAPI dependency — rejects requests that do not carry the correct API key.
FastAPI dependency — rejects requests that do not carry the correct API key
or a valid session cookie.

Accepts the key in either:
- ``Authorization: Bearer <key>``
- ``X-Api-Key: <key>``
- Valid ``secuscan_session`` HttpOnly cookie (set via POST /auth/session)
"""
if request is not None and request.url.path.startswith("/api/v1/admin"):
# Admin endpoints have their own separate verify_admin_access dependency.
# We bypass require_api_key verification to avoid blocking valid admin key requests.
return ""

# Allow requests authenticated via session cookie
if request is not None and is_authenticated_by_session(request):
return "session-authenticated"

if _api_key is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
Expand Down
3 changes: 2 additions & 1 deletion backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .request_context import get_request_id

from .config import settings
from .auth import init_api_key
from .auth import init_api_key, auth_router
from .cache import init_cache, cache as global_cache
from .database import init_db, db as global_db
from .routes import router
Expand Down Expand Up @@ -200,6 +200,7 @@ async def custom_unhandled_exception_handler(request: Request, exc: Exception):
return response

# Include API routes
app.include_router(auth_router)
app.include_router(router)
app.include_router(saved_views_router)

Expand Down
19 changes: 14 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { ThemeProvider } from './components/ThemeContext'
import { ToastProvider } from './components/ToastContext'
import { I18nProvider } from './components/I18nContext'
import { routes } from './routes'
import { AUTH_REQUIRED_EVENT, getStoredApiKey } from './api'
import { AUTH_REQUIRED_EVENT, checkAuthSession } from './api'

export function AppRoutes() {
return (
Expand All @@ -40,8 +40,15 @@ export function AppRoutes() {
}

export default function App() {
// True when setup is needed: no key stored, or any request got a 401.
const [needsKey, setNeedsKey] = useState(() => !getStoredApiKey())
const [needsKey, setNeedsKey] = useState(true)
const [checkingSession, setCheckingSession] = useState(true)

useEffect(() => {
checkAuthSession().then((authenticated) => {
setNeedsKey(!authenticated)
setCheckingSession(false)
})
}, [])

useEffect(() => {
function onAuthRequired() {
Expand All @@ -51,14 +58,16 @@ export default function App() {
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired)
}, [])

if (checkingSession) {
return null
}

return (
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<ErrorBoundary>
{needsKey ? (
// Render ONLY the setup screen — no page routes are mounted, so no
// API calls can fire and spam 401 failures before the key is saved.
<ApiKeySetupScreen onSaved={() => setNeedsKey(false)} />
) : (
<Router>
Expand Down
51 changes: 42 additions & 9 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,26 +288,59 @@ export interface TaskStartResponse {
stream_url: string
}

const API_KEY_STORAGE_KEY = 'secuscan_api_key'
let _apiKey: string | null = null

export function getStoredApiKey(): string | null {
return _apiKey
}

export function setStoredApiKey(key: string): void {
_apiKey = key
}

export function clearStoredApiKey(): void {
_apiKey = null
}

export async function authenticateWithApiKey(apiKey: string): Promise<void> {
const response = await fetch(`${API_BASE}/auth/session`, {
method: 'POST',
headers: { 'X-Api-Key': apiKey },
credentials: 'include',
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body?.detail || 'Authentication failed')
}
_apiKey = apiKey
}

export async function checkAuthSession(): Promise<boolean> {
try {
return localStorage.getItem(API_KEY_STORAGE_KEY) || null
const response = await fetch(`${API_BASE}/auth/session/check`, {
credentials: 'include',
})
const data = await response.json()
return !!data.authenticated
} catch {
return null
return false
}
}

export function setStoredApiKey(key: string): void {
export async function logoutSession(): Promise<void> {
try {
localStorage.setItem(API_KEY_STORAGE_KEY, key)
await fetch(`${API_BASE}/auth/session/logout`, {
method: 'POST',
credentials: 'include',
})
} catch {
// ignore storage errors
// ignore
}
_apiKey = null
}

function getApiKey(): string | null {
return getStoredApiKey()
return _apiKey
}

/** Fired on the window when any API request receives HTTP 401. */
Expand All @@ -327,12 +360,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
...authHeaders,
...(init?.headers as Record<string, string> | undefined),
},
credentials: 'include',
signal: controller.signal,
})

if (response.status === 401) {
// Notify the app so it can show the API-key setup UI without every
// caller needing to handle auth independently.
_apiKey = null
window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT))
throw new Error('AUTH_REQUIRED')
}
Expand Down
25 changes: 15 additions & 10 deletions frontend/src/components/ApiKeySetupModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { setStoredApiKey } from '../api'
import { authenticateWithApiKey } from '../api'

interface Props {
onSaved: () => void
Expand All @@ -10,24 +10,29 @@ interface Props {
*
* Shown when the app receives HTTP 401 or detects no stored API key.
* The operator reads the key from `backend/data/.api_key`, pastes it here,
* and clicks Save. The key is written only to localStorage (secuscan_api_key);
* it is never sent to any server other than as the X-Api-Key request header.
* and clicks Save. The key is sent to the backend which validates it and
* sets an HttpOnly session cookie; the raw key is never persisted in the
* browser.
*/
export default function ApiKeySetupModal({ onSaved }: Props) {
const [key, setKey] = useState('')
const [visible, setVisible] = useState(false)
const [error, setError] = useState('')

function handleSave() {
async function handleSave() {
const trimmed = key.trim()
if (!trimmed) {
setError('Please enter the API key.')
return
}
setStoredApiKey(trimmed)
setKey('')
setError('')
onSaved()
try {
await authenticateWithApiKey(trimmed)
setKey('')
setError('')
onSaved()
} catch (err: any) {
setError(err?.message || 'Authentication failed. Check the API key.')
}
}

return (
Expand Down Expand Up @@ -116,8 +121,8 @@ export default function ApiKeySetupModal({ onSaved }: Props) {
Save and connect
</button>
<p style={{ marginTop: '1rem', color: '#64748b', fontSize: 12 }}>
The key is stored only in your browser's localStorage and sent exclusively
as the <code>X-Api-Key</code> request header — it is never stored server-side.
The key is sent to the backend which validates it and sets an HttpOnly
session cookie. The raw key is never persisted in the browser.
</p>
</div>
</div>
Expand Down
Loading
Loading