Auth Service
The Auth Service handles authentication and identity for AEGIS. It verifies email/password credentials, issues JWT tokens, resolves roles and permissions fresh from the database (R42a), and owns the user lifecycle (admin CRUD, invites, password resets).
Overview
AEGIS uses a two-step authentication flow:
- The client authenticates with an email and password via the auth service, receiving a JWT token (the response also server-sets the
aegis_tokencookie since R42a) - All subsequent requests include the JWT token in the
Authorization: Bearerheader (or theaegis_tokencookie) - The API gateway validates the JWT locally (using the shared secret) and extracts user identity
The JWT carries identity; authorization state (roles, permissions, is_active, token version) is re-resolved from the database at the perimeter via POST /auth/resolve — one codepath (resolve_user()) backs both /auth/resolve and /auth/forward-auth, with a ~5s per-user cache. Role changes and deactivation therefore take effect on live traffic within seconds, not at next login.
Users are stored in the PostgreSQL users table, with role grants in user_roles → roles (R42e dropped the legacy users.roles shadow array — grants live solely in user_roles). Passwords are hashed with bcrypt. Accounts are admin-provisioned — there is no self-serve signup — and deactivate-only: there is no hard-delete path. In production the JWT secret would come from HashiCorp Vault.
Port & Language
| Property | Value |
|---|---|
| Port | 8009 |
| Language | Python 3.12 |
| Framework | FastAPI |
| Entry point | src/auth_service/main.py |
Key Endpoints
| Method | Path | Description |
|---|---|---|
POST | /auth/token | Exchange email + password for a JWT token. Since R42a also sets the aegis_token cookie (HttpOnly/Secure env-gated until the R42b cutover) while keeping access_token in the body. |
POST | /auth/resolve | Internal (NotFound’d at the gateway in R42b): JWT → {valid, user_id, email, display_name, tenant_id, roles, permissions} or {valid: false, reason}. DB-fresh: enforces is_active + token_version (tv claim, missing = 0) behind a ~5s per-user cache. |
GET | /auth/me | Caller’s identity + roles + permissions (cookie or Bearer). The UI’s identity source once the cookie is httpOnly (R42b). |
POST | /auth/refresh | Sliding refresh: token valid and >1h old → fresh 24h token + Set-Cookie; younger tokens → 204. |
POST | /auth/logout | Expires the cookie. Unauthenticated and idempotent. |
POST | /auth/logout-all | Bumps the caller’s token_version — all outstanding tokens resolve revoked within the cache window. |
POST | /auth/admin/users | Create an invited user (empty password hash) → one-time invite URL (7d). Requires users.manage. |
GET | /auth/admin/users | Admin directory with role grants + status (active / invited / disabled). Requires users.read. |
PATCH | /auth/admin/users/{id} | Update display_name / is_active / roles (roles additionally require roles.grant). Deactivation bumps token_version; a last-admin guard returns 409. |
POST | /auth/admin/users/{id}/reset-password | Voids open invite/reset tokens, bumps token_version, mints a one-time reset URL (1h). Requires users.manage. |
GET | /auth/invites/{token} | Public: validate an invite/reset token → email, purpose, expiry. 404 for unknown/used/expired alike. |
POST | /auth/invites/redeem | Public: {token, password} → sets the password (single-use, row-locked) and logs the user in (Set-Cookie + body token). |
POST | /auth/validate | Validate a JWT token (legacy claims-only check). Returns user_id and roles if valid. |
* | /auth/forward-auth | Method-agnostic check of the aegis_token cookie or Bearer header (used by Caddy). Since R42a rides resolve_user() — DB-fresh, so deactivated users and revoked tokens get 401 even with a valid JWT. Returns X-User-Id / X-Roles / X-Tenant-Id (unchanged) plus X-Permissions (R42b) and X-User-Email (R42d) response headers for Caddy copy_headers. |
GET | /auth/users | Tenant user directory (id, email, display_name, roles, is_active). Requires users.read (admin) — resolution is DB-fresh since R42a. Feeds the Platform Settings page’s user picker. Proxied at /api/v1/auth/users. |
GET | /health | Health check. |
Architecture
src/auth_service/
├── __init__.py
├── main.py # App wiring, /auth/token, /auth/resolve, forward-auth, directory
├── migrate.py # Boot-time idempotent migration runner (also `python -m auth_service.migrate`)
├── migrations/ # 001_users, 002_user_tenant, 003_rbac_substrate (all catalog-guarded)
├── rbac.py # SYSTEM_ROLES + Appendix C PERMISSION_MATRIX + seeds + backfill
├── system_users.py # R42e: system-user + identity_map seeds, users.roles column drop
├── resolve.py # Resolver: JWT → DB-fresh identity/roles/permissions (5s cache)
├── tokens.py # JWT mint/decode (tv claim) + cookie helpers (env-gated attributes)
├── deps.py # get_repo / get_resolver / require_permission dependencies
├── session_routes.py # /auth/me, refresh, logout(-all), public invite validate/redeem
├── admin_routes.py # /auth/admin/users CRUD + invite/reset issuance
├── users.py # UserRepo (users + user_roles)
├── invites.py # InviteRepo (sha256-at-rest one-time tokens)
├── security.py # bcrypt hash/verify
└── create_user.py # CLI for provisioning/rotating users (+ --deactivate/--reactivate)Migration runner (R42a)
The service applies every file in src/auth_service/migrations/ in order on every boot — there is no applied-tracking, so every DDL statement is catalog-predicate-guarded and every data migration converges to a no-op on re-run. Seeds (4 system roles, the permission matrix, a one-time backfill of user_roles from the legacy users.roles array, and — R42e — the four system users plus the identity_map seed rows) run after the SQL; once the backfill has run, the runner drops the users.roles shadow column (R42e). The CI job auth-service-tests runs ci/auth_double_boot.sh (boot → boot again → schema + seed state must be identical) to guard the idempotency invariant.
Authentication Flow
1. Client sends POST /auth/token with { "email": "...", "password": "..." }
2. Auth service looks up the user by LOWER(email)
3. Verifies the password against the stored bcrypt hash
4. If valid and active, generates a JWT with user_id, email, roles, and 24-hour expiry
5. Returns the token, expiry, and user info
6. Client stores the token and uses it for all subsequent requestsInvalid credentials or an inactive user return 401 Unauthorized with {"detail": "Invalid email or password"}. Email matching is case-insensitive.
JWT Token Structure
The JWT payload contains:
{
"user_id": "9b2f1c4e-...",
"email": "admin@aegis.local",
"display_name": "Bootstrap Admin",
"roles": ["admin", "operator", "reviewer"],
"tenant_id": "00000000-0000-0000-0000-000000000001",
"tv": 0,
"iss": "aegis-auth",
"iat": 1712793600,
"exp": 1712880000
}| Field | Description |
|---|---|
user_id | The authenticated user’s identifier |
email | The authenticated user’s email address |
display_name | Human-readable name for the UI header |
roles | Array of role keys, read from user_roles at mint time (used for RBAC in the gateway) |
tenant_id | The user’s tenant (forwarded as X-Tenant-Id) |
tv | Token version (R42a). Compared against users.token_version at resolve time; a bump (logout-all, deactivation, password reset/rotation) revokes all outstanding tokens. Missing claim (pre-R42a token) = 0. |
iss | Issuer (aegis-auth) |
iat | Issued-at timestamp |
exp | Expiration timestamp (24 hours after issuance) |
Tokens are signed with HS256 using the JWT_SECRET environment variable.
Users Table
Users live in the PostgreSQL users table:
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key, uuid_generate_v4() |
email | VARCHAR(255) | Not null; case-insensitive unique via idx_users_email_lower |
password_hash | TEXT | Not null; bcrypt hash. Empty string = invited or system user (login fails closed) |
display_name | VARCHAR(200) | Optional |
is_active | BOOLEAN | Not null, default TRUE. Deactivation is the removal path — no hard delete |
token_version | INT | R42a. Bumped to revoke all outstanding tokens (tv claim mismatch → revoked) |
password_set_at | TIMESTAMPTZ | R42a. NULL = invited, not-yet-redeemed account |
created_at / updated_at | timestamps |
The RBAC companion tables (R42a, owned by this service’s migration runner — no other service may FK to them):
| Table | Purpose |
|---|---|
roles | The 4 seeded system roles (admin, power_user, reviewer, operator; is_system=true, tenant_id NULL); schema is data-extensible for future tenant-defined roles |
role_permissions | resource.action permission strings per role — the signed-off R42 Appendix C matrix, seeded from rbac.PERMISSION_MATRIX |
user_roles | Role grants (user_id, role_id, granted_by, granted_at) — the source of truth for a user’s roles |
user_invites | One-time invite/reset tokens: sha256 hash at rest, used_at IS NULL + row lock for single-use, 7d (invite) / 1h (reset) expiry |
The users table is created in infrastructure/docker/postgres/init.sql for fresh databases; the auth-service migration runner re-ships it idempotently (migrations/001 / 002) so a fresh DB converges on boot without manual psql.
System users (R42e): the boot seed converges four non-loginable accounts — system@aegis.local (the nil UUID, the SYSTEM_USER literal seeds have always stamped), legacy@aegis.local (backfill target for unmappable pre-R42 identity strings), eval-runner@aegis.local (only inserted if the email is absent), and demo-seed@aegis.local. Empty password_hash fails closed at login; is_active=true so they satisfy the R42e FKs and appear in attribution joins. It also converges the identity_map rows that resolve legacy TEXT actor ids at read time.
Provisioning Users
There is no signup endpoint. The primary path is the Users & Roles admin page at /admin/users (R42d — see the user guide), which drives the admin API: POST /auth/admin/users returns a one-time invite URL (7-day expiry); the invitee opens it, chooses a password, and is logged in. The CLI remains as an ops fallback:
cd services/auth-service
poetry run python -m auth_service.create_user \
--email alice@example.com \
--roles admin,operator,reviewerThe command prompts for a password, or reads it from AEGIS_NEW_USER_PASSWORD. Re-running it for an existing email rotates that user’s password (and bumps token_version, revoking live sessions). --deactivate / --reactivate toggle account status — accounts are deactivate-only, never deleted; the CLI refuses to deactivate the last active admin.
Bootstrap Admin
On startup the service seeds an initial admin from BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD if a user with that email does not already exist. For local development these default to admin@aegis.local / aegis-dev-admin.
| Password | Roles | |
|---|---|---|
admin@aegis.local | aegis-dev-admin | admin, operator, reviewer |
The default bootstrap credentials must not be used in any deployed environment. Set strong BOOTSTRAP_ADMIN_* values via environment variables.
Token Validation
The /auth/validate endpoint decodes and verifies a JWT token:
- Strip the
Bearerprefix if present - Decode using PyJWT with the
HS256algorithm and the sharedJWT_SECRET - Return
valid: truewith the user’s identity, orvalid: falseif the token is invalid or expired
This endpoint is used internally by the API gateway, though the gateway also validates tokens locally using its own Go JWT library. The /auth/forward-auth endpoint performs the same check but is method-agnostic and reads the aegis_token cookie or a Bearer header, for use by the Caddy reverse proxy.
Dependencies
Python Packages
| Package | Version | Purpose |
|---|---|---|
fastapi | ^0.115 | Web framework |
uvicorn | ^0.34 | ASGI server |
pyjwt | ^2.8 | JWT encoding and decoding |
bcrypt | * | Password hashing and verification |
asyncpg | * | PostgreSQL access (via aegis-shared) |
python-multipart | ^0.0.22 | Form data support |
Infrastructure Dependencies
PostgreSQL — the auth service reads and writes the users table.
Configuration
| Environment Variable | Default | Description |
|---|---|---|
JWT_SECRET | aegis-local-dev-secret-change-in-production | HMAC secret for JWT signing |
DATABASE_URL | postgresql://aegis:aegis_local@localhost:5432/aegis | PostgreSQL connection for the users table |
BOOTSTRAP_ADMIN_EMAIL | admin@aegis.local | Seeded admin email on startup |
BOOTSTRAP_ADMIN_PASSWORD | aegis-dev-admin | Seeded admin password on startup |
The service binds to 0.0.0.0:8009 by default (hardcoded in __main__).
The JWT_SECRET must be identical across the auth service and the API gateway. If they differ, tokens issued by the auth service will fail validation at the gateway, and vice versa.
Running Locally
cd services/auth-service
poetry install
poetry run uvicorn auth_service.main:app --reload --port 8009Testing the Auth Flow
# Step 1: Exchange email + password for a JWT token
curl -X POST http://localhost:8009/auth/token \
-H "Content-Type: application/json" \
-d '{"email": "admin@aegis.local", "password": "aegis-dev-admin"}'
# Response:
# {
# "access_token": "eyJhbGciOiJIUzI1NiIs...",
# "token_type": "bearer",
# "expires_in": 86400,
# "user_id": "dev-user",
# "roles": ["admin", "operator", "reviewer"]
# }
# Step 2: Use the token in subsequent requests
curl http://localhost:8000/api/v1/conversations \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."