# 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: ` | `actor_kind=user, actor_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__<32 base32 chars>` where `kind3` is one of `rep`, `con`, `adm`, `svc`. 160 bits of entropy. The first 8 characters (`irdb_`) 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) ```mermaid sequenceDiagram participant Agent as Reporter or Consumer participant API participant DB Agent->>API: GET /api/v1/blocklist
Authorization: Bearer irdb_con_… API->>DB: lookup token by hash
verify kind=consumer, not revoked, not expired DB-->>API: TokenRecord API->>API: build blocklist for consumer's policy
(30s cache) API-->>Agent: 200 text/plain
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 ```mermaid sequenceDiagram participant Script as Automation participant API participant DB Script->>API: GET /api/v1/admin/ips
Authorization: Bearer irdb_adm_… API->>DB: lookup token by hash
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=`, 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: ```http Authorization: Bearer X-Acting-User-Id: ``` ```mermaid sequenceDiagram participant Browser participant UI as ui (BFF) participant API as api participant DB Browser->>UI: GET /app/dashboard
(session cookie) UI->>UI: SessionMiddleware → user_id=7 UI->>API: GET /api/v1/admin/stats/dashboard
Authorization: Bearer
X-Acting-User-Id: 7 API->>DB: validate service token
load user 7 + role DB-->>API: User(role=admin) API->>API: RBAC: route requires Viewer → ok
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`](./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: ```mermaid sequenceDiagram participant Browser participant UI participant API Browser->>UI: POST /login/local
(username, password, csrf_token) UI->>UI: csrf check; argon2id verify against env hash UI->>API: POST /api/v1/auth/users/upsert-local
{"username":"admin"}
Authorization: Bearer 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: ```bash 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) ```mermaid sequenceDiagram participant Browser participant UI participant Entra participant API Browser->>UI: GET /login/oidc UI->>Browser: 302 → Entra authorize URL
(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)
extract sub, email, groups UI->>API: POST /api/v1/auth/users/upsert-oidc
{subject, email, display_name, groups} API->>API: resolve role via oidc_role_mappings
(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) ID** → `OIDC_CLIENT_ID` and the **Directory (tenant) ID**, which goes into `OIDC_ISSUER=https://login.microsoftonline.com//v2.0`. 2. **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. 3. **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. 4. **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. 5. **Map groups to roles** in IRDB. The dedicated admin UI for this is forthcoming; until then, write to the `oidc_role_mappings` table directly: ```sql 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. 6. **Set the UI env** (in the repo's `.env`): ```dotenv OIDC_ENABLED=true OIDC_ISSUER=https://login.microsoftonline.com//v2.0 OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= 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`](./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 - [`api-overview.md`](./api-overview.md) — endpoint groups, conventions, worked examples. - [`frontend-development.md`](./frontend-development.md) — three patterns for replacement UIs. - [`/api/v1/openapi.yaml`](http://localhost:8081/api/v1/openapi.yaml) — canonical endpoint shapes.