Reference for authentication, authorization, JWT issuance, and the request-time protections that wrap every endpoint.
AILA accepts two credential types that resolve to the same JWT-Bearer auth context:
- User accounts -- username + password (argon2id-hashed) → user JWT pair
(
typ=user_access+typ=user_refresh). Created by an admin viaPOST /usersor auto-provisioned by OIDC. First-boot admin is created fromAILA_ADMIN_PASSWORD. - API keys --
aila_sk_<32 hex>raw secrets (bcrypt-hashed) → API-key JWT pair (typ=access+typ=refresh). Issued viaPOST /auth/keys(admin), the CLI (aila create-api-key), or the optionalAILA_BOOTSTRAP_KEYfirst-boot path.
Both paths land in the same place: an HS256-signed JWT carried as
Authorization: Bearer <token>. The unified dependency require_user_or_api_key
(src/aila/api/auth.py) decodes either token type and returns an AuthContext
with user_id, role, auth_type ("user" or "api_key"), and team_id
(None for admin tokens, which see across teams).
OIDC providers (Microsoft, Google, generic) are layered on top of the user
account flow: a successful OIDC callback auto-provisions a UserRecord and
returns the same user JWT pair.
RBAC has three roles ordered by privilege:
admin (2) > operator (1) > reader (0)
Higher levels inherit lower-level access. require_role("operator") permits
operator and admin; require_role("reader") permits everyone.
User accounts live in user_records (UserRecord in
src/aila/storage/db_models.py). Each row carries:
UserRecord:
id: UUID (used as user_id in user JWTs)
username: unique, 3..64 chars
email: optional
hashed_password: argon2id hash, or NULL for OIDC-only accounts
role: "admin" | "operator" | "reader"
group_id: optional team-scoping label
is_active: soft-delete flag (false = locked out)
oidc_sub: OIDC `sub` claim, set on auto-provisioned accounts
last_login_at: timestamp of last successful /auth/login
created_at: creation timestamp
updated_at: last modification timestamp
On startup the lifespan hook checks user_records. If the table is empty:
- Read
AILA_ADMIN_PASSWORDfrom the environment. - If unset, startup fails with
RuntimeError-- no unprotected admin account is ever created automatically. - If set, create user
adminwith that password (argon2id-hashed viaargon2-cffi, OWASP defaults: time_cost=3, memory_cost=65536, parallelism=4, hash_len=32). - Any pre-existing
ApiKeyRecordrows with a NULLuser_idare attached to the new admin account (legacy-key migration path). - Log a notice instructing the operator to remove
AILA_ADMIN_PASSWORDfrom the environment.
Subsequent boots skip the hook because user_records is no longer empty.
POST /users (admin only) creates additional accounts:
- Password is validated against the HaveIBeenPwned k-anonymity range API (T-138-09 / D-19). Hits are rejected with 422.
- Password is hashed via
hash_user_password()(argon2id) before storage. rolemust be one ofadmin,operator,reader.- The creation event is dual-written to structlog and
AuditEventRecord.
PATCH /users/{user_id} updates role, email, is_active, or password.
Soft-delete is is_active=false; the record stays in the table.
Body: {"username": ..., "password": ...}. The handler:
- Looks up
UserRecordbyusername. - Returns 401 "Invalid credentials" on missing user,
is_active=false, OIDC-only account (no local password), or wrong password -- the response string is identical for every failure mode (T-138-10: no username enumeration). - On success: issues a user JWT pair, updates
last_login_at, writes alogin_successaudit event. - On failure: writes a
login_failedaudit event with the specific reason indetails_json(server-side only; never reflected to the client).
Response shape:
{
"data": {
"access_token": "...",
"refresh_token": "...",
"token_type": "bearer",
"expires_in": 31536000
}
}The refresh token is also persisted as SHA-256 in refresh_token_records
with the originating ip_address and user_agent, so refreshes can be
revoked server-side independent of JWT expiry.
| Endpoint | Purpose |
|---|---|
POST /auth/refresh/user?refresh_token=... |
Verify the refresh token row is not revoked and the user is still active, then issue a new access token. Refresh token is NOT rotated. |
POST /auth/logout?refresh_token=... |
Mark the RefreshTokenRecord row revoked. |
GET /auth/sessions |
List the caller's own non-revoked, non-expired refresh sessions (id, ip, user agent, timestamps -- never the token hash). |
DELETE /auth/sessions/{session_id} |
Revoke one of the caller's own sessions by row id. |
API keys are generated with the aila_sk_ prefix followed by 32 hex characters:
aila_sk_a3f1b2c4d5e6f7a8b9c0d1e2f3a4b5c6
The raw key is shown exactly once at creation time. It is never stored -- only
its bcrypt hash is persisted in the ApiKeyRecord table.
| Method | Command / Endpoint | Role Required |
|---|---|---|
| API | POST /auth/keys |
admin |
| CLI | aila create-api-key --role admin |
N/A (direct DB access) |
| Bootstrap | AILA_BOOTSTRAP_KEY env var |
N/A (first start only) |
ApiKeyRecord:
id: UUID (primary key, used as key_id in JWTs)
hashed_key: bcrypt hash of raw key
key_prefix: first 12 chars (e.g., "aila_sk_abcd") for identification
role: "admin" | "operator" | "reader"
label: optional human-readable label
created_by: key_id of the admin who created this key (or "system"/"bootstrap")
created_at: creation timestamp
revoked_at: null if active, timestamp if revoked
On POST /auth/token, the client sends the raw API key. The server:
- Iterates active (non-revoked)
ApiKeyRecordrows matching the key prefix - Verifies the raw key against each
hashed_keyusing bcrypt (pwdlib) - On match, issues a JWT access token and refresh token
- On no match, returns 401
DELETE /auth/keys/{key_id} sets revoked_at to the current timestamp. This
immediately invalidates:
- All access tokens issued from this key (blacklist check on every request)
- All refresh tokens issued from this key (same key_id blacklist)
- The API key itself (cannot issue new tokens)
Revocation is permanent and instant. There is no un-revoke operation.
Issued in two flavours depending on the credential path:
| Path | Endpoint | typ claim |
Default expiry |
|---|---|---|---|
| User account | POST /auth/login |
user_access |
1 year (_USER_ACCESS_EXPIRY in auth.py) |
| API key | POST /auth/token |
access |
30 days, configurable via platform.jwt_access_expiry_s |
Both tokens share the same envelope (HS256, AILA_JWT_SECRET_KEY-signed) and
both are accepted by require_user_or_api_key.
User-token claims (typ=user_access):
| Claim | Type | Purpose |
|---|---|---|
user_id |
str | UserRecord.id |
role |
str | admin / operator / reader |
team_id |
str | null | Team scope; null = admin (cross-team) |
typ |
str | "user_access" |
exp, iat |
int | Expiry, issued-at |
API-key claims (typ=access):
| Claim | Type | Purpose |
|---|---|---|
jti |
str | Unique token identifier (uuid4().hex) |
key_id |
str | Issuing ApiKeyRecord.id |
role |
str | Key's role |
typ |
str | "access" |
exp, iat |
int | Expiry, issued-at |
Used to obtain new access tokens without re-presenting the original credential.
| Path | Endpoint | typ claim |
Server-side row | Default expiry |
|---|---|---|---|---|
| User account | POST /auth/refresh/user?refresh_token=... |
user_refresh |
RefreshTokenRecord keyed by SHA-256 of the token |
1 year |
| API key | POST /auth/refresh (Bearer = refresh JWT) |
refresh |
None -- revocation rides on ApiKeyRecord.revoked_at blacklist |
90 days, platform.jwt_refresh_expiry_s |
The API-key refresh issues both a new access token AND a new refresh token. The
old refresh token stays valid until its own exp -- there is no single-use
enforcement.
The user refresh endpoint does NOT rotate the refresh token; revocation is
handled instead by setting revoked_at on the RefreshTokenRecord row
(POST /auth/logout or DELETE /auth/sessions/{id}).
- Algorithm: HS256 (symmetric HMAC-SHA256)
- Secret:
AILA_JWT_SECRET_KEYenvironment variable - Library: PyJWT
Production requirement: AILA_JWT_SECRET_KEY MUST be set. Without it, a random
secret is generated on each process start, invalidating all existing tokens on restart.
On every authenticated request, the decoder runs:
- Decode the JWT and verify the HS256 signature.
- Reject if
typdoes not match the expected type for the caller dependency. - For API-key tokens (
typ=access): look upApiKeyRecordbykey_idand reject if the row is missing orrevoked_atis set. - For user tokens (
typ=user_access): look upUserRecordbyuser_idand reject if missing oris_active=false. - Build
AuthContext(user_id, role, auth_type, team_id)for the handler.
API-key revocation is therefore zero-cache-window: setting revoked_at
immediately invalidates every outstanding JWT signed against that key,
regardless of the JWT's own exp.
User refresh tokens add a second revocation surface: the SHA-256 hash row in
refresh_token_records. A refresh token is rejected if its row is missing or
revoked_at is set, even when the JWT signature itself is still valid.
safe_exc_message() (src/aila/platform/workflows/log.py) governs how
exception text is persisted in the audit log table -- exceptions are redacted
to type(exc).__name__ by default, so credential strings or stack traces
cannot leak into workflowauditrecord rows. Handler crash text still lands
in structlog server-side; only the durable audit row is sanitized.
admin (level 2) > operator (level 1) > reader (level 0)
Higher roles inherit all permissions of lower roles. An operator token can access all reader endpoints. An admin token can access all endpoints.
| Role | Level | Typical permissions |
|---|---|---|
reader |
0 | Read-only access: GET endpoints for systems, findings, reports, tasks, sessions |
operator |
1 | Reader + write operations: POST scans, create sessions, submit tasks, invoke tools |
admin |
2 | Operator + administrative: create/revoke API keys, modify config, delete systems |
Router-level auth (require_api_key):
Every protected router is mounted with dependencies=[Depends(require_api_key)].
This ensures all endpoints under the router require a valid JWT. No per-endpoint
auth wiring is needed -- the platform applies it at mount time.
Endpoint-level role check (require_role):
Endpoints that need a specific role use the require_role() dependency factory:
@router.post("/keys")
async def create_key(
admin: ApiKeyRecord = Depends(require_role("admin")),
):
...require_role("admin") returns a dependency that:
- First runs
require_api_key(JWT decode + blacklist check) - Then checks
ROLE_LEVELS[key.role] >= ROLE_LEVELS["admin"] - Returns 403 if the caller's role level is too low
These endpoints do NOT require authentication:
| Endpoint | Purpose |
|---|---|
POST /auth/login |
Username/password login → user JWT pair |
POST /auth/refresh/user |
Exchange a user_refresh token for a new access token |
POST /auth/logout |
Revoke a refresh-token session |
POST /auth/token |
Exchange an API key for the API-key JWT pair |
POST /auth/refresh |
Refresh an API-key access token |
GET /auth/oidc/providers/public |
List enabled OIDC providers for the login page |
GET /auth/oidc/authorize |
Begin an OIDC login flow |
GET /auth/oidc/callback |
Complete an OIDC login flow |
GET /health, GET /health/comprehensive |
Liveness + per-module readiness |
GET /status |
Server uptime + version |
GET /metrics |
Prometheus scrape endpoint |
All other endpoints require a valid Bearer JWT.
The public auth endpoints are rate-limited per slowapi keys derived from the
caller's user_id/key_id claim when present, otherwise the remote IP:
POST /auth/login at 10/minute, POST /auth/token and POST /auth/refresh
at 5/minute, the OIDC admin and provider-mutation endpoints at 60/minute.
First boot needs an admin credential of some kind. Two independent paths exist:
1. Admin user (required when user_records is empty)
- Set
AILA_ADMIN_PASSWORD=<strong-password>before first start. - The lifespan hook creates user
adminwith that password (argon2id). - Startup raises
RuntimeErrorifuser_recordsis empty and the env var is missing -- there is no implicit default password. - Remove
AILA_ADMIN_PASSWORDfrom the environment after first boot. - Subsequent boots skip the hook because
user_recordsis no longer empty.
2. Legacy API key (optional)
- Set
AILA_BOOTSTRAP_KEY=<long-random-secret>before first start. - On startup, if
ApiKeyRecordhas zero rows, the value is bcrypt-hashed and stored as an admin key with labelbootstrap. - Exchange the raw value at
POST /auth/tokento receive the API-key JWT pair. - Remove
AILA_BOOTSTRAP_KEYfrom the environment afterwards. The hook is idempotent: once any API key exists, the env var is ignored on restart. - Pre-existing API keys with NULL
user_idare auto-attached to the bootstrap admin user the first time the user-bootstrap hook runs (legacy migration).
Both bootstrap mechanisms are first-boot-only. Operators MUST also set
AILA_JWT_SECRET_KEY (generate with openssl rand -hex 32); when unset the
process logs a warning and synthesizes a random secret per start, which
invalidates every issued JWT on restart.
Token expiry is configurable via ConfigRegistry (no restart needed):
| Setting | Default | ConfigRegistry Key | Env Var Override |
|---|---|---|---|
| Access token | 30 days | platform.jwt_access_expiry_s |
AILA_PLATFORM_JWT_ACCESS_EXPIRY_S |
| Refresh token | 90 days | platform.jwt_refresh_expiry_s |
AILA_PLATFORM_JWT_REFRESH_EXPIRY_S |
Change at runtime:
PUT /config/platform/jwt_access_expiry_s
{"value": "3600"}
New tokens issued after the change use the updated expiry. Existing tokens retain their original expiry.
src/aila/api/routers/oidc.py mounts an admin CRUD surface plus a public
login surface for OIDC-backed authentication:
| Route | Auth | Purpose |
|---|---|---|
GET /auth/oidc/providers/public |
none | Enabled providers for the login chooser (id, display name, type only) |
GET /auth/oidc/authorize |
none | Returns the upstream authorization URL; sets a short-lived signed oidc_state cookie (CSRF) |
GET /auth/oidc/callback |
none | Exchanges code, verifies id_token against the issuer JWKS, auto-provisions a UserRecord, returns the user JWT pair |
GET /auth/oidc/providers |
admin | Full provider list (still never returns client_secret) |
POST /auth/oidc/providers |
admin | Create a provider (microsoft / google / generic); client_secret is stored encrypted via SecretStore |
PUT /auth/oidc/providers/{id} |
admin | Partial update |
DELETE /auth/oidc/providers/{id} |
admin | Delete a provider and its SecretRecord |
Implementation notes:
- The
oidc_statecookie is a 10-minute signed JWT (AILA_JWT_SECRET_KEY). - Provider secrets are encrypted at rest and never returned by any read endpoint.
- Well-known and JWKS documents are cached in-process for one hour and invalidated on provider mutation.
- Microsoft providers use
msal; Google and generic providers build the authorize URL from the well-known document directly.
Every request flows through the same middleware chain (see create_app()
in src/aila/api/app.py):
| Layer | Effect |
|---|---|
CORSMiddleware |
Origins from AILA_CORS_ORIGINS (CSV). Dev default covers Vite ports 3000/4173/5173 on localhost and 127.0.0.1. |
IdempotencyMiddleware |
POSTs carrying an Idempotency-Key header are cached in Redis under IDEM:{key} for 24 h; duplicate requests replay the cached body with X-Idempotency-Replayed: true. Graceful degradation when Redis is down. |
CorrelationIdMiddleware |
Reads or generates X-Correlation-ID, binds it to structlog contextvars alongside path and method, and echoes the header in the response. |
_reject_oversized_requests |
Rejects any request with Content-Length > 10 MB with HTTP 413 + PAYLOAD_TOO_LARGE. |
_catch_unhandled_exceptions |
Last-resort 500 wrapper so unhandled exceptions never leak a stack trace to the client. |
_prometheus_request_middleware |
Counts requests and latency per (method, endpoint, status_code). |
slowapi Limiter |
Per-authenticated-user rate-limit buckets derived from the JWT user_id/key_id claim (signature unverified -- bucketing only) with IP fallback. Triggered limits return HTTP 429 via the slowapi exception handler. |
AuditEventRecord rows are immutable; query them via GET /audit/events
(filterable) or GET /audit/events/{run_id} (full trail for a single run).
The auth stage covers both credential paths:
| Action | Stage | Trigger |
|---|---|---|
login_success |
auth |
POST /auth/login accepts a credential |
login_failed |
auth |
POST /auth/login rejects a credential -- reason recorded server-side in details_json |
token_issue |
auth |
POST /auth/token issues an API-key JWT |
token_refresh |
auth |
POST /auth/refresh rotates an API-key JWT |
create_api_key |
auth |
POST /auth/keys |
revoke_api_key |
auth |
DELETE /auth/keys/{key_id} |
Other stages (config, scan, session, system, task, tool,
finding) carry the matching actions for their domain -- see
AUDIT_ACTION_* constants in src/aila/api/constants.py. Seals for LLM
calls live in AuditSealRecord, queryable via GET /audit/seals (admin).
| Decision | Rationale |
|---|---|
| HS256 over RS256 | Single-process deployment; no external token verifiers |
| Per-request blacklist check | Instant revocation without cache invalidation infrastructure |
| bcrypt for key hashing | Industry-standard, hardware-resistant, via pwdlib |
| No session cookies | Machine-to-machine auth; no browser dependency |
| Router-level Depends | Prevents per-endpoint auth drift (Pitfall 5) |
| key_prefix exposure | Operators can identify keys without seeing the raw value |
| jti on every token | Enables future per-token revocation if needed |
Source: src/aila/api/routers/auth.py, src/aila/api/routers/users.py,
src/aila/api/routers/oidc.py, src/aila/api/auth.py, src/aila/api/app.py,
src/aila/api/middleware/, src/aila/storage/db_models.py.