Skip to Content

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:

  1. The client authenticates with an email and password via the auth service, receiving a JWT token (the response also server-sets the aegis_token cookie since R42a)
  2. All subsequent requests include the JWT token in the Authorization: Bearer header (or the aegis_token cookie)
  3. 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_rolesroles (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

PropertyValue
Port8009
LanguagePython 3.12
FrameworkFastAPI
Entry pointsrc/auth_service/main.py

Key Endpoints

MethodPathDescription
POST/auth/tokenExchange 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/resolveInternal (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/meCaller’s identity + roles + permissions (cookie or Bearer). The UI’s identity source once the cookie is httpOnly (R42b).
POST/auth/refreshSliding refresh: token valid and >1h old → fresh 24h token + Set-Cookie; younger tokens → 204.
POST/auth/logoutExpires the cookie. Unauthenticated and idempotent.
POST/auth/logout-allBumps the caller’s token_version — all outstanding tokens resolve revoked within the cache window.
POST/auth/admin/usersCreate an invited user (empty password hash) → one-time invite URL (7d). Requires users.manage.
GET/auth/admin/usersAdmin 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-passwordVoids 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/redeemPublic: {token, password} → sets the password (single-use, row-locked) and logs the user in (Set-Cookie + body token).
POST/auth/validateValidate a JWT token (legacy claims-only check). Returns user_id and roles if valid.
*/auth/forward-authMethod-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/usersTenant 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/healthHealth 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 requests

Invalid 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 }
FieldDescription
user_idThe authenticated user’s identifier
emailThe authenticated user’s email address
display_nameHuman-readable name for the UI header
rolesArray of role keys, read from user_roles at mint time (used for RBAC in the gateway)
tenant_idThe user’s tenant (forwarded as X-Tenant-Id)
tvToken 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.
issIssuer (aegis-auth)
iatIssued-at timestamp
expExpiration 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:

ColumnTypeNotes
idUUIDPrimary key, uuid_generate_v4()
emailVARCHAR(255)Not null; case-insensitive unique via idx_users_email_lower
password_hashTEXTNot null; bcrypt hash. Empty string = invited or system user (login fails closed)
display_nameVARCHAR(200)Optional
is_activeBOOLEANNot null, default TRUE. Deactivation is the removal path — no hard delete
token_versionINTR42a. Bumped to revoke all outstanding tokens (tv claim mismatch → revoked)
password_set_atTIMESTAMPTZR42a. NULL = invited, not-yet-redeemed account
created_at / updated_attimestamps

The RBAC companion tables (R42a, owned by this service’s migration runner — no other service may FK to them):

TablePurpose
rolesThe 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_permissionsresource.action permission strings per role — the signed-off R42 Appendix C matrix, seeded from rbac.PERMISSION_MATRIX
user_rolesRole grants (user_id, role_id, granted_by, granted_at) — the source of truth for a user’s roles
user_invitesOne-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,reviewer

The 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.

EmailPasswordRoles
admin@aegis.localaegis-dev-adminadmin, 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:

  1. Strip the Bearer prefix if present
  2. Decode using PyJWT with the HS256 algorithm and the shared JWT_SECRET
  3. Return valid: true with the user’s identity, or valid: false if 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

PackageVersionPurpose
fastapi^0.115Web framework
uvicorn^0.34ASGI server
pyjwt^2.8JWT encoding and decoding
bcrypt*Password hashing and verification
asyncpg*PostgreSQL access (via aegis-shared)
python-multipart^0.0.22Form data support

Infrastructure Dependencies

PostgreSQL — the auth service reads and writes the users table.

Configuration

Environment VariableDefaultDescription
JWT_SECRETaegis-local-dev-secret-change-in-productionHMAC secret for JWT signing
DATABASE_URLpostgresql://aegis:aegis_local@localhost:5432/aegisPostgreSQL connection for the users table
BOOTSTRAP_ADMIN_EMAILadmin@aegis.localSeeded admin email on startup
BOOTSTRAP_ADMIN_PASSWORDaegis-dev-adminSeeded 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 8009

Testing 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..."
Last updated on