# Silo Auth Middleware ## Middleware Chain Every request passes through this middleware stack in order: ``` RequestID Assigns X-Request-ID header | RealIP Extracts client IP from X-Forwarded-For | RequestLogger Logs method, path, status, duration (zerolog) | Recoverer Catches panics, logs stack trace, returns 500 | CORS Validates origin, sets Access-Control headers | SessionLoadAndSave Loads session from PostgreSQL, saves on response | [route group middleware applied per-group below] ``` ## Route Groups ### Public (no auth) ``` GET /health GET /ready GET /login POST /login POST /logout GET /auth/oidc GET /auth/callback ``` No authentication or CSRF middleware. The login form includes a CSRF hidden field but nosurf is not enforced on these routes (they are outside the CSRF-protected group). ### Web UI (auth + CSRF) ``` RequireAuth -> CSRFProtect -> Handler GET / Items page GET /projects Projects page GET /schemas Schemas page GET /settings Settings page (account info, tokens) POST /settings/tokens Create API token (form) POST /settings/tokens/{id}/revoke Revoke token (form) ``` Both `RequireAuth` and `CSRFProtect` are applied. Form submissions must include a `csrf_token` hidden field with the value from `nosurf.Token(r)`. ### API (auth, no CSRF) ``` RequireAuth -> [RequireRole where needed] -> Handler GET /api/auth/me Current user info (viewer) GET /api/auth/tokens List tokens (viewer) POST /api/auth/tokens Create token (viewer) DELETE /api/auth/tokens/{id} Revoke token (viewer) GET /api/schemas/* Read schemas (viewer) POST /api/schemas/*/segments/* Modify schema values (editor) GET /api/projects/* Read projects (viewer) POST /api/projects Create project (editor) PUT /api/projects/{code} Update project (editor) DELETE /api/projects/{code} Delete project (editor) GET /api/items/* Read items (viewer) POST /api/items Create item (editor) PUT /api/items/{partNumber} Update item (editor) DELETE /api/items/{partNumber} Delete item (editor) POST /api/items/{partNumber}/file Upload file (editor) POST /api/items/import CSV import (editor) GET /api/items/{partNumber}/bom/* Read BOM (viewer) POST /api/items/{partNumber}/bom Add BOM entry (editor) PUT /api/items/{partNumber}/bom/* Update BOM entry (editor) DELETE /api/items/{partNumber}/bom/* Delete BOM entry (editor) POST /api/generate-part-number Generate PN (editor) GET /api/integrations/odoo/* Read Odoo config (viewer) PUT /api/integrations/odoo/* Modify Odoo config (editor) POST /api/integrations/odoo/* Odoo sync operations (editor) ``` API routes are exempt from CSRF (they use Bearer token auth). CORS credentials are allowed so browser-based API clients with session cookies work. ## RequireAuth `internal/api/middleware.go` Authentication check order: 1. **Auth disabled?** Inject synthetic dev user (`admin` role) and continue 2. **Bearer token?** Extract from `Authorization: Bearer silo_...` header, validate via `auth.Service.ValidateToken()`. On success, inject user into context 3. **Session cookie?** Read `user_id` from `scs` session, look up user via `auth.Service.GetUserByID()`. On success, inject user into context. On stale session (user not found), destroy session 4. **None of the above?** - API requests (`/api/*`): Return `401 Unauthorized` JSON - Web requests: Redirect to `/login?next=` ## RequireRole `internal/api/middleware.go` Applied as per-group middleware on routes that require a minimum role: ```go r.Use(server.RequireRole(auth.RoleEditor)) ``` Checks `auth.RoleSatisfies(user.Role, minimum)` against the hierarchy `admin > editor > viewer`. Returns: - `401 Unauthorized` if no user in context (should not happen after RequireAuth) - `403 Forbidden` with message `"Insufficient permissions: requires role"` if role is too low ## CSRFProtect `internal/api/middleware.go` Wraps the `justinas/nosurf` library: - Cookie: `csrf_token`, HttpOnly, SameSite=Lax, Secure when auth enabled - Exempt paths: `/api/*`, `/health`, `/ready` - Form field name: `csrf_token` - Failure: Returns `403 Forbidden` with `"CSRF token validation failed"` Templates inject the token via `{{.CSRFToken}}` which is populated from `nosurf.Token(r)`. ## CORS Configuration Configured in `internal/api/routes.go`: | Setting | Auth Disabled | Auth Enabled | |---------|---------------|--------------| | Allowed Origins | `*` | From `auth.cors.allowed_origins` config | | Allow Credentials | `false` | `true` (needed for session cookies) | | Allowed Methods | GET, POST, PUT, PATCH, DELETE, OPTIONS | Same | | Allowed Headers | Accept, Authorization, Content-Type, X-CSRF-Token, X-Request-ID | Same | | Max Age | 300 seconds | 300 seconds | FreeCAD uses direct HTTP (not browser), so CORS does not affect it. Browser-based tools on other origins need their origin in the allowed list. ## Request Flow Examples ### Browser Login ``` GET /projects -> RequestLogger -> CORS (pass) -> Session (loads empty session) -> RequireAuth: no token, no session user_id -> Redirect 303 /login?next=/projects POST /login (username=alice, password=...) -> RequestLogger -> CORS (pass) -> Session (loads) -> HandleLogin: auth.Authenticate(ctx, "alice", password) -> Session: put user_id, renew token -> Redirect 303 /projects GET /projects -> Session (loads, has user_id) -> RequireAuth: session -> GetUserByID -> inject alice -> CSRFProtect: GET request, passes -> HandleProjectsPage: renders with alice's info ``` ### API Token Request ``` GET /api/items Authorization: Bearer silo_a1b2c3d4... -> RequireAuth: Bearer token found -> ValidateToken: SHA-256 hash lookup, check expiry, check user active -> Inject user into context -> HandleListItems: returns JSON ```