auth-flows.md 13 KB

Auth flows

Audience: anyone wiring a client to IRDB. Covers all four token kinds, the BFF impersonation pattern, the OIDC and local-admin login flows, and the recommended extension point for SPA / native / mobile clients that don't use a BFF.

Overview

Caller Token kind Extra header Audited as
Web/IDS/fail2ban agent reporter actor_kind=reporter
Firewall / proxy consumer actor_kind=consumer
Automation script admin actor_kind=admin-token
UI BFF (current) service X-Acting-User-Id: <id> actor_kind=user, actor_id=<id>
Future SPA/native/mobile (not built) (would issue user-bound tokens) actor_kind=user

The api refuses requests with no token. It always returns the same 401 unauthorized envelope for any auth failure (bad token, expired, revoked, wrong-kind for the route) — callers can never tell which. RBAC failures (authenticated but role-denied) are 403; an audit row is not emitted on either.

Token format

Every IRDB token is irdb_<kind3>_<32 base32 chars> where kind3 is one of rep, con, adm, svc. 160 bits of entropy. The first 8 characters (irdb_<kind3>) form the token_prefix recorded for log triage; the full string is SHA-256 hashed at rest in api_tokens.token_hash.

The raw value is shown once at creation. After that the api can verify a token by hashing the presented value and looking it up — but nobody, including admins, can recover the raw form. Treat tokens like passwords.

Reporter / consumer flow (machine clients)

sequenceDiagram
    participant Agent as Reporter or Consumer
    participant API
    participant DB

    Agent->>API: GET /api/v1/blocklist<br/>Authorization: Bearer irdb_con_…
    API->>DB: lookup token by hash<br/>verify kind=consumer, not revoked, not expired
    DB-->>API: TokenRecord
    API->>API: build blocklist for consumer's policy<br/>(30s cache)
    API-->>Agent: 200 text/plain<br/>ETag: …

For reporters the path is identical except the agent sends POST /api/v1/report and the api validates kind=reporter. The token binds to a row in reporters / consumers, which carries the trust weight (reporters) or policy id (consumers). Audit records the FK on each successful state change.

Admin token flow

sequenceDiagram
    participant Script as Automation
    participant API
    participant DB

    Script->>API: GET /api/v1/admin/ips<br/>Authorization: Bearer irdb_adm_…
    API->>DB: lookup token by hash<br/>verify kind=admin, role=viewer/operator/admin
    DB-->>API: TokenRecord(role=admin)
    API->>API: RBAC: route requires Viewer → ok
    API-->>Script: 200 {items, page, …}

Admin tokens carry their own role (set at creation). The route's required role is checked against the token's role; same matrix as the UI uses. No impersonation — the audit row records actor_kind=admin-token, actor_id=<token id>, not a user.

Admin tokens are the recommended path for automation that doesn't go through a browser — CI scripts, Terraform providers, monitoring that creates tokens for new reporters automatically. Don't use a service token for those use cases; service tokens are for the BFF.

UI BFF flow (current)

The ui container holds the service token. On every admin-API call it adds two headers:

Authorization: Bearer <UI_SERVICE_TOKEN>
X-Acting-User-Id: <user-id-from-session>
sequenceDiagram
    participant Browser
    participant UI as ui (BFF)
    participant API as api
    participant DB

    Browser->>UI: GET /app/dashboard<br/>(session cookie)
    UI->>UI: SessionMiddleware → user_id=7
    UI->>API: GET /api/v1/admin/stats/dashboard<br/>Authorization: Bearer <svc><br/>X-Acting-User-Id: 7
    API->>DB: validate service token<br/>load user 7 + role
    DB-->>API: User(role=admin)
    API->>API: RBAC: route requires Viewer → ok<br/>audit context: user=7
    API-->>UI: 200 {stats}
    UI->>Browser: 200 HTML (Twig)

The api never trusts X-Acting-User-Id without a service token — on a reporter / consumer / admin token the header is silently ignored. On a service token without the header the api returns 400.

This pattern works because:

  1. The service token authenticates the caller (the BFF process).
  2. The header identifies the principal (the human in front of the browser).
  3. Audit records the principal, not the caller — so an audit row never reads as "the service token did this".

Building a different BFF (Node, Rails, Django) is mostly a matter of implementing this two-header pattern, plus session management. See frontend-development.md.

Local admin login

The local admin only exists if LOCAL_ADMIN_ENABLED=true. The UI validates the password against LOCAL_ADMIN_PASSWORD_HASH (Argon2id), then asks the api to upsert a user record:

sequenceDiagram
    participant Browser
    participant UI
    participant API

    Browser->>UI: POST /login/local<br/>(username, password, csrf_token)
    UI->>UI: csrf check; argon2id verify against env hash
    UI->>API: POST /api/v1/auth/users/upsert-local<br/>{"username":"admin"}<br/>Authorization: Bearer <svc>
    API-->>UI: {user_id, role: "admin", is_local: true}
    UI->>UI: regenerate session id; store user_id
    UI-->>Browser: 303 → /app/dashboard

The local admin is always role admin. Generate the password hash:

php -r "echo password_hash('your-password', PASSWORD_ARGON2ID);"

Then double every $ in the resulting hash before pasting into .env — Docker Compose's variable substitution otherwise eats them. For production deployments disable the local admin (LOCAL_ADMIN_ENABLED=false) once an OIDC user with admin role exists; it's a defence-in-depth recommendation, not a hard requirement.

OIDC login (Microsoft Entra ID)

sequenceDiagram
    participant Browser
    participant UI
    participant Entra
    participant API

    Browser->>UI: GET /login/oidc
    UI->>Browser: 302 → Entra authorize URL<br/>(PKCE code_challenge, state, nonce)
    Browser->>Entra: GET /authorize
    Entra-->>Browser: 302 → /oidc/callback?code=…&state=…
    Browser->>UI: GET /oidc/callback
    UI->>Entra: POST /token (code + verifier + secret)
    Entra-->>UI: id_token + access_token
    UI->>UI: validate id_token (sig/iss/aud/nonce/exp)<br/>extract sub, email, groups
    UI->>API: POST /api/v1/auth/users/upsert-oidc<br/>{subject, email, display_name, groups}
    API->>API: resolve role via oidc_role_mappings<br/>(else OIDC_DEFAULT_ROLE)
    API-->>UI: {user_id, role}
    UI-->>Browser: 303 → /app/dashboard

When role=none (no mapping matched and OIDC_DEFAULT_ROLE=none), the UI sends the user to /no-access instead of completing the session.

Entra setup walkthrough

Tested against an Entra workforce tenant. Replace example values with your own.

  1. Register an application in the Entra admin centre (entra.microsoft.com → App registrations → New registration):
    • Name: IRDB — UI (any human-readable label).
    • Account types: "Accounts in this organizational directory only" is fine for the common single-tenant case. Multi-tenant only if you genuinely need it.
    • Redirect URI: type Web, URI matching OIDC_REDIRECT_URI, e.g. http://localhost:8080/oidc/callback for dev, https://reputation.example.com/oidc/callback for prod.

Save the Application (client) IDOIDC_CLIENT_ID and the Directory (tenant) ID, which goes into OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0.

  1. Create a client secret (Certificates & secrets → Client secrets → New). Copy the secret Value (not the Secret ID) → OIDC_CLIENT_SECRET. The portal hides it after you leave the page.

  2. Configure the groups claim (Token configuration → Add groups claim → Security groups). For each token type (ID + Access), pick "Group ID" — the api expects Entra group object IDs. sAMAccountName or display names will not match.

If the user has more than ~150 groups assigned, Entra emits an _claim_names overage indicator instead of the raw groups array. The current implementation doesn't follow the indicator (would require a Graph call); keep test users under the threshold or reduce their group count.

  1. API permissions: the default User.Read is enough for openid profile email plus the groups claim. No additional scopes unless you call Graph elsewhere. Click Grant admin consent if your tenant requires it.

  2. Map groups to roles in IRDB. The dedicated admin UI for this is forthcoming; until then, write to the oidc_role_mappings table directly:

    INSERT INTO oidc_role_mappings (group_id, role) VALUES
       ('11111111-1111-1111-1111-111111111111', 'admin'),
       ('22222222-2222-2222-2222-222222222222', 'operator'),
       ('33333333-3333-3333-3333-333333333333', 'viewer');
    

Group IDs are GUID/UUID strings as Entra emits them. The api resolves the user's role on each login as the highest matching role across their groups.

  1. Set the UI env (in the repo's .env):

    OIDC_ENABLED=true
    OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
    OIDC_CLIENT_ID=<application-client-id>
    OIDC_CLIENT_SECRET=<value-from-step-2>
    OIDC_REDIRECT_URI=http://localhost:8080/oidc/callback
    

Restart ui. The login page now offers "Sign in with Microsoft".

Troubleshooting

  • AADSTS50011: redirect URI mismatch — the URI in the request doesn't match what's registered. /oidc/callback is case-sensitive; the host must match exactly.
  • groups claim missing in the ID token — re-check step 3 above, including selecting the claim for the ID token type (not just access token). Sign in again from a private window so a fresh token is issued.
  • Role always viewer — the matched group ID isn't in oidc_role_mappings. The api returns OIDC_DEFAULT_ROLE when no row matches.
  • OIDC handshake failed: state mismatch — the session cookie was lost between /login/oidc and /oidc/callback (third-party-cookie blocking on cross-site flows; session fixation reset). Confirm SameSite=Lax is set and that the redirect URI is on the same host the user reaches the UI through.

Future: direct user tokens

NOT IMPLEMENTED. This section sketches the recommended extension point for replacement frontends that don't want a BFF.

The current model assumes a server-side process that holds the service token. A native or single-page app can't do that — the service token would be exposed to the browser/device and would impersonate every user. Instead, the api would need to issue user-bound tokens directly, and a new endpoint group would handle the flow:

POST /api/v1/auth/oauth/start          → returns an OIDC redirect URL
GET  /api/v1/auth/oauth/callback       → exchanges code; issues user token
POST /api/v1/auth/oauth/refresh        → exchanges refresh token
POST /api/v1/auth/oauth/revoke         → revokes a user token

The user token would be a fifth kind=user row in api_tokens, bound to a user_id rather than a reporter/consumer. The same RBAC middleware would apply.

This is future work. If you're building an SPA today, the recommended path is option (b) in frontend-development.md: a thin BFF that mints short-lived signed cookies the SPA presents.

CSRF, sessions, CORS

  • CSRF — UI forms only. The UI middleware injects a per-session token into every state-changing form and validates on submit (constant-time compare, header or form-field). The api is stateless and Bearer-authenticated, so CSRF doesn't apply.
  • Sessions — PHP native, file-backed in the ui container's writable layer. 8 h idle, 24 h absolute (configurable via SESSION_IDLE_SECONDS / SESSION_ABSOLUTE_SECONDS). Tied to a specific UI replica — sticky sessions required if scaling UI horizontally. Session ids regenerate after every auth-state change (login success, logout) to defeat fixation.
  • CORS — the api emits CORS headers for the configured UI_ORIGIN with Access-Control-Allow-Credentials: true and X-Acting-User-Id on the allow-list. The current PHP UI calls server-to-server and doesn't trigger CORS; the headers exist for future browser-direct frontends.

See also