# 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)

```mermaid
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

```mermaid
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:

```http
Authorization: Bearer <UI_SERVICE_TOKEN>
X-Acting-User-Id: <user-id-from-session>
```

```mermaid
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`](./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<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:

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

#### Brute-force lockout (M14)

The local-admin sign-in carries a per-process throttle: failures are
bucketed by `(username, source_ip)` so a single attacker can't lock
out the legitimate admin from another address. Progression:

| Failures | Lockout |
|----------|---------|
| 1–4      | none    |
| 5        | 60 s    |
| 10       | 300 s   |
| 15+      | 1800 s  |

A successful login resets the bucket for that pair. The store is
in-memory in the UI container, so:

- **To clear all locks**: restart the `ui` container
  (`docker compose restart ui`). This is the documented unlock
  path — there is intentionally no API for clearing a lockout
  (adding one would be a new attack surface).
- **Multi-replica caveat**: each UI replica has its own counter.
  Sticky sessions or single-replica are the documented topologies.

OIDC sign-in has no equivalent throttle in the UI — Entra rate-limits
failed auth at the IdP, which is the right layer for SSO.

### 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<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) ID** → `OIDC_CLIENT_ID` and the
   **Directory (tenant) ID**, which goes into
   `OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/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/<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`](./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.
