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
2 changes: 1 addition & 1 deletion backend/app/api/routes/saved_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.services.auth_service import AuthService
from app.services.saved_script_service import SavedScriptService

router = APIRouter(route_class=DishkaRoute)
router = APIRouter(route_class=DishkaRoute, tags=["scripts"])


@router.post("/scripts", response_model=SavedScriptResponse)
Expand Down
145 changes: 145 additions & 0 deletions docs/architecture/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Authentication

The platform uses cookie-based JWT authentication with CSRF protection via the double-submit pattern. This approach
keeps tokens secure (httpOnly cookies) while enabling CSRF protection for state-changing requests.

## Architecture

```mermaid
sequenceDiagram
participant Browser
participant Frontend
participant Backend
participant MongoDB

Browser->>Frontend: Login form submit
Frontend->>Backend: POST /auth/login
Backend->>MongoDB: Verify credentials
MongoDB-->>Backend: User record
Backend->>Backend: Generate JWT + CSRF token
Backend-->>Frontend: Set-Cookie: access_token (httpOnly)
Backend-->>Frontend: Set-Cookie: csrf_token
Frontend->>Frontend: Store CSRF in memory

Note over Browser,Backend: Subsequent requests
Browser->>Frontend: Click action
Frontend->>Backend: POST /api/... + X-CSRF-Token header
Backend->>Backend: Validate JWT from cookie
Backend->>Backend: Validate CSRF header == cookie
Backend-->>Frontend: Response
```

## Token Flow

Login creates two cookies:

| Cookie | Properties | Purpose |
|----------------|------------------------------------|---------------------------------|
| `access_token` | httpOnly, secure, samesite=strict | JWT for authentication |
| `csrf_token` | secure, samesite=strict (readable) | CSRF double-submit verification |

The `access_token` cookie is httpOnly, so JavaScript cannot read it—this prevents XSS attacks from stealing the token.
The `csrf_token` cookie is readable by JavaScript so the frontend can include it in request headers.

## Backend Implementation

### Password Hashing

Passwords are hashed using bcrypt via passlib:

```python
--8<-- "backend/app/core/security.py:23:32"
```

### JWT Creation

JWTs are signed with HS256 using a secret key from settings:

```python
--8<-- "backend/app/core/security.py:34:39"
```

The token payload contains the username in the `sub` claim and an expiration time. Token lifetime is configured via
`ACCESS_TOKEN_EXPIRE_MINUTES` (default: 30 minutes).

### CSRF Validation

The double-submit pattern requires the CSRF token to be sent in both a cookie and a header. The
[`validate_csrf_token`](https://github.com/HardMax71/Integr8sCode/blob/main/backend/app/core/security.py) dependency
validates this for all authenticated POST/PUT/DELETE requests:

```python
--8<-- "backend/app/core/security.py:77:107"
```

Safe methods (GET, HEAD, OPTIONS) and auth endpoints (login, register, logout) skip CSRF validation.

### Cookie Configuration

Login sets cookies with security best practices:

```python
--8<-- "backend/app/api/routes/auth.py:94:112"
```

| Setting | Value | Purpose |
|------------|--------|---------------------------------------------|
| `httponly` | true | Prevents JavaScript access (XSS protection) |
| `secure` | true | HTTPS only |
| `samesite` | strict | Prevents CSRF via cross-site requests |
| `path` | / | Cookie sent for all paths |

## Frontend Implementation

### Auth Store

The frontend maintains authentication state in a Svelte store with sessionStorage persistence:

```typescript
--8<-- "frontend/src/stores/auth.ts:9:17"
```

The store caches verification results for 30 seconds to reduce server load:

```typescript
--8<-- "frontend/src/stores/auth.ts:45:47"
```

### CSRF Injection

The API interceptor automatically adds the CSRF token header to all non-GET requests:

```typescript
--8<-- "frontend/src/lib/api-interceptors.ts:137:145"
```

### Session Handling

On 401 responses, the interceptor clears auth state and redirects to login, preserving the original URL for
post-login redirect:

```typescript
--8<-- "frontend/src/lib/api-interceptors.ts:64:81"
```

## Endpoints

<swagger-ui src="../reference/openapi.json" filter="authentication" docExpansion="none" defaultModelsExpandDepth="-1" supportedSubmitMethods="[]"/>

## Offline-First Behavior

The frontend uses an offline-first approach for auth verification. On network failure, it returns the cached auth state
rather than immediately logging out. This provides better UX during transient network issues but means server-revoked
tokens may remain "valid" locally for up to 30 seconds.

Security-critical operations should use `verifyAuth(forceRefresh=true)` to bypass the cache.

## Key Files

| File | Purpose |
|------------------------------------------------------------------------------------------------------------------------|--------------------------------|
| [`core/security.py`](https://github.com/HardMax71/Integr8sCode/blob/main/backend/app/core/security.py) | JWT, password, CSRF utilities |
| [`services/auth_service.py`](https://github.com/HardMax71/Integr8sCode/blob/main/backend/app/services/auth_service.py) | Auth service layer |
| [`api/routes/auth.py`](https://github.com/HardMax71/Integr8sCode/blob/main/backend/app/api/routes/auth.py) | Auth endpoints |
| [`stores/auth.ts`](https://github.com/HardMax71/Integr8sCode/blob/main/frontend/src/stores/auth.ts) | Frontend auth state |
| [`api-interceptors.ts`](https://github.com/HardMax71/Integr8sCode/blob/main/frontend/src/lib/api-interceptors.ts) | CSRF injection, error handling |
107 changes: 48 additions & 59 deletions docs/architecture/domain-exceptions.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
# Domain exceptions

This document explains how the backend handles errors using domain exceptions. It covers the exception hierarchy, how services use them, and how the middleware maps them to HTTP responses.
This document explains how the backend handles errors using domain exceptions. It covers the exception hierarchy, how
services use them, and how the middleware maps them to HTTP responses.

## Why domain exceptions

Services used to throw `HTTPException` directly with status codes like 404 or 422. That worked but created tight coupling between business logic and HTTP semantics. A service that throws `HTTPException(status_code=404)` knows it's running behind an HTTP API, which breaks when you want to reuse that service from a CLI tool, a message consumer, or a test harness.
Services used to throw `HTTPException` directly with status codes like 404 or 422. That worked but created tight
coupling between business logic and HTTP semantics. A service that throws `HTTPException(status_code=404)` knows it's
running behind an HTTP API, which breaks when you want to reuse that service from a CLI tool, a message consumer, or a
test harness.

Domain exceptions fix this by letting services speak in business terms. A service raises `ExecutionNotFoundError(execution_id)` instead of `HTTPException(404, "Execution not found")`. The exception handler middleware maps domain exceptions to HTTP responses in one place. Services stay transport-agnostic, tests assert on meaningful exception types, and the mapping logic lives where it belongs.
Domain exceptions fix this by letting services speak in business terms. A service raises
`ExecutionNotFoundError(execution_id)` instead of `HTTPException(404, "Execution not found")`. The exception handler
middleware maps domain exceptions to HTTP responses in one place. Services stay transport-agnostic, tests assert on
meaningful exception types, and the mapping logic lives where it belongs.

## Exception hierarchy

All domain exceptions inherit from `DomainError`, which lives in `app/domain/exceptions.py`. The base classes map to HTTP status codes:
All domain exceptions inherit from `DomainError`, which lives in `app/domain/exceptions.py`. The base classes map to
HTTP status codes:

| Base class | HTTP status | Use case |
|------------|-------------|----------|
| `NotFoundError` | 404 | Entity doesn't exist |
| `ValidationError` | 422 | Invalid input or state |
| `ThrottledError` | 429 | Rate limit exceeded |
| `ConflictError` | 409 | Concurrent modification or duplicate |
| `UnauthorizedError` | 401 | Missing or invalid credentials |
| `ForbiddenError` | 403 | Authenticated but not allowed |
| `InvalidStateError` | 400 | Operation invalid for current state |
| `InfrastructureError` | 500 | External system failure |
| Base class | HTTP status | Use case |
|-----------------------|-------------|--------------------------------------|
| `NotFoundError` | 404 | Entity doesn't exist |
| `ValidationError` | 422 | Invalid input or state |
| `ThrottledError` | 429 | Rate limit exceeded |
| `ConflictError` | 409 | Concurrent modification or duplicate |
| `UnauthorizedError` | 401 | Missing or invalid credentials |
| `ForbiddenError` | 403 | Authenticated but not allowed |
| `InvalidStateError` | 400 | Operation invalid for current state |
| `InfrastructureError` | 500 | External system failure |

Each domain module defines specific exceptions that inherit from these bases. The hierarchy looks like this:

Expand Down Expand Up @@ -62,64 +70,43 @@ DomainError

Domain exceptions live in their respective domain modules:

| Module | File | Exceptions |
|--------|------|------------|
| Base | `app/domain/exceptions.py` | `DomainError`, `NotFoundError`, `ValidationError`, etc. |
| Execution | `app/domain/execution/exceptions.py` | `ExecutionNotFoundError`, `RuntimeNotSupportedError`, `EventPublishError` |
| Saga | `app/domain/saga/exceptions.py` | `SagaNotFoundError`, `SagaAccessDeniedError`, `SagaInvalidStateError`, `SagaCompensationError`, `SagaTimeoutError`, `SagaConcurrencyError` |
| Notification | `app/domain/notification/exceptions.py` | `NotificationNotFoundError`, `NotificationThrottledError`, `NotificationValidationError` |
| Saved Script | `app/domain/saved_script/exceptions.py` | `SavedScriptNotFoundError` |
| Replay | `app/domain/replay/exceptions.py` | `ReplaySessionNotFoundError`, `ReplayOperationError` |
| User/Auth | `app/domain/user/exceptions.py` | `AuthenticationRequiredError`, `InvalidCredentialsError`, `TokenExpiredError`, `CSRFValidationError`, `AdminAccessRequiredError`, `UserNotFoundError` |
| Module | File | Exceptions |
|--------------|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| Base | `app/domain/exceptions.py` | `DomainError`, `NotFoundError`, `ValidationError`, etc. |
| Execution | `app/domain/execution/exceptions.py` | `ExecutionNotFoundError`, `RuntimeNotSupportedError`, `EventPublishError` |
| Saga | `app/domain/saga/exceptions.py` | `SagaNotFoundError`, `SagaAccessDeniedError`, `SagaInvalidStateError`, `SagaCompensationError`, `SagaTimeoutError`, `SagaConcurrencyError` |
| Notification | `app/domain/notification/exceptions.py` | `NotificationNotFoundError`, `NotificationThrottledError`, `NotificationValidationError` |
| Saved Script | `app/domain/saved_script/exceptions.py` | `SavedScriptNotFoundError` |
| Replay | `app/domain/replay/exceptions.py` | `ReplaySessionNotFoundError`, `ReplayOperationError` |
| User/Auth | `app/domain/user/exceptions.py` | `AuthenticationRequiredError`, `InvalidCredentialsError`, `TokenExpiredError`, `CSRFValidationError`, `AdminAccessRequiredError`, `UserNotFoundError` |

## Rich constructors

Specific exceptions have constructors that capture context for logging and debugging. Instead of just a message string, they take structured arguments:
Specific exceptions have constructors that capture context for logging and debugging. Instead of just a message string,
they take structured arguments:

```python
class SagaAccessDeniedError(ForbiddenError):
def __init__(self, saga_id: str, user_id: str) -> None:
self.saga_id = saga_id
self.user_id = user_id
super().__init__(f"Access denied to saga '{saga_id}' for user '{user_id}'")
--8<-- "backend/app/domain/saga/exceptions.py:11:17"
```

class NotificationThrottledError(ThrottledError):
def __init__(self, user_id: str, limit: int, window_hours: int) -> None:
self.user_id = user_id
self.limit = limit
self.window_hours = window_hours
super().__init__(f"Rate limit exceeded for user '{user_id}': max {limit} per {window_hours}h")
```python
--8<-- "backend/app/domain/notification/exceptions.py:11:18"
```

This means you can log `exc.saga_id` or `exc.limit` without parsing the message, and tests can assert on specific fields.
This means you can log `exc.saga_id` or `exc.limit` without parsing the message, and tests can assert on specific
fields.

## Exception handler

The middleware in `app/core/exceptions/handlers.py` catches all `DomainError` subclasses and maps them to JSON responses:
The middleware in `app/core/exceptions/handlers.py` catches all `DomainError` subclasses and maps them to JSON
responses:

```python
def configure_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse:
status_code = _map_to_status_code(exc)
return JSONResponse(
status_code=status_code,
content={"detail": exc.message, "type": type(exc).__name__},
)

def _map_to_status_code(exc: DomainError) -> int:
if isinstance(exc, NotFoundError): return 404
if isinstance(exc, ValidationError): return 422
if isinstance(exc, ThrottledError): return 429
if isinstance(exc, ConflictError): return 409
if isinstance(exc, UnauthorizedError): return 401
if isinstance(exc, ForbiddenError): return 403
if isinstance(exc, InvalidStateError): return 400
if isinstance(exc, InfrastructureError): return 500
return 500
--8<-- "backend/app/core/exceptions/handlers.py:17:44"
```

The response includes the exception type name, so clients can distinguish between `ExecutionNotFoundError` and `SagaNotFoundError` even though both return 404.
The response includes the exception type name, so clients can distinguish between `ExecutionNotFoundError` and
`SagaNotFoundError` even though both return 404.

## Using exceptions in services

Expand All @@ -145,7 +132,8 @@ async def get_execution(self, execution_id: str) -> DomainExecution:
return execution
```

The service no longer knows about HTTP. It raises a domain exception that describes what went wrong in business terms. The middleware handles the translation to HTTP.
The service no longer knows about HTTP. It raises a domain exception that describes what went wrong in business terms.
The middleware handles the translation to HTTP.

## Testing with domain exceptions

Expand Down Expand Up @@ -203,4 +191,5 @@ API routes can still use `HTTPException` for route-level concerns that don't bel
- Authentication checks in route dependencies
- Route-specific access control before calling services

The general rule: if it's about the business domain, use domain exceptions. If it's about HTTP mechanics at the route level, `HTTPException` is fine.
The general rule: if it's about the business domain, use domain exceptions. If it's about HTTP mechanics at the route
level, `HTTPException` is fine.
Loading