# ADR-004 — JWT + API Key Dual Authentication

**Date:** 2026-04-26
**Status:** Accepted
**Deciders:** Architecture Team

---

## Context

MW-Core serves two fundamentally different caller types:

1. **Human sessions** — web browsers, mobile apps; need short-lived credentials tied to an
   identity provider (IdP); token refresh must be seamless.
2. **Machine-to-machine (M2M)** — internal services, batch jobs, partner integrations; need
   long-lived stable credentials; no user session; must survive application restarts.

These two use cases have conflicting requirements: short-lived vs long-lived, stateful vs
stateless verification, user claims vs service claims.

---

## Decision

Implement **two authentication strategies** in `mw_auth`, selected automatically by the
`Authorization` header scheme:

| Header Prefix | Strategy | Module |
|---------------|----------|--------|
| `Bearer <token>` | JWT (Joken) | `MwAuth.JWT` |
| `ApiKey <key>` | HMAC API Key | `MwAuth.ApiKey` |

`MwAuth.Plug` detects the scheme and delegates. Both strategies set the same
`%MwAuth.Identity{}` struct into `context.user` on success.

---

## JWT Strategy (Human Sessions)

**Library:** `Joken` (Elixir-native, pluggable signers)

- **Algorithm:** HS256 for internal issuance; RS256/EdDSA for external IdP (configurable)
- **Expiry:** 15-minute access token + 7-day refresh token
- **Claims required:** `sub` (user ID), `tenant_id`, `roles`, `iat`, `exp`
- **Revocation:** Token ID (`jti`) stored in revocation ETS cache (loaded from DB on boot).
  Revocation propagates across cluster nodes via PubSub.
- **Refresh:** `POST /api/v1/auth/refresh` — validates refresh token, issues new pair

```elixir
defmodule MwAuth.JWT do
  use Joken.Config

  def token_config do
    default_claims(skip: [:aud])
    |> add_claim("tenant_id", nil, &is_binary/1)
    |> add_claim("roles", nil, &is_list/1)
  end
end
```

---

## API Key Strategy (M2M)

**Storage:** API key is issued as `mwk_<base62_32bytes>`. Only the Argon2 hash is stored in DB.
The raw key is shown once at creation time.

**Verification flow:**
1. Extract key from `ApiKey <key>` header
2. Look up by key prefix (first 8 chars) in ETS → get hashed record
3. `Argon2.verify_pass(raw_key, stored_hash)`
4. Check `active: true`, `expires_at > now()`
5. Load associated `service_id`, `roles`, `tenant_id` into `%MwAuth.Identity{}`

**Rotation:** Keys have optional expiry. Rotation creates a new key; old key remains valid
for a configurable grace period (default: 24h) to allow zero-downtime rotation.

---

## Role-Based Access Control (RBAC)

Both strategies resolve to the same role set. Roles are checked by the route table and
can be enforced at the plug level or per-controller action.

| Role | Access |
|------|--------|
| `admin` | All routes, admin UI, routing config changes |
| `operator` | Transaction query, audit log view, adapter health |
| `service` | M2M only — specific allowed message types per API key |
| `readonly` | Query-only, no mutations |

RBAC enforcement is in `MwAuth.RBAC.authorize!(context, required_roles)` — raises
`MwKernel.Error.unauthorized()` if the context roles do not satisfy the requirement.

---

## Security Controls

| Control | Implementation |
|---------|----------------|
| Token expiry | JWT `exp` claim; enforced by Joken verify |
| Token revocation | JTI blocklist in ETS; synced to DB |
| Key hashing | Argon2id, memory-hard |
| Key exposure | Raw key shown only at creation; never logged |
| Transport | TLS required in production; enforced at Bandit config |
| Brute force | Rate limiter (ex_rated) on auth endpoints |
| Audit | Every auth attempt (success + failure) written to audit log |

---

## Consequences

### Positive
- Single `MwAuth.Plug` handles both strategies transparently
- Downstream pipeline code sees only `%MwAuth.Identity{}` — never cares which auth method was used
- Argon2 ensures API keys at rest are useless if DB is compromised
- JWT revocation list allows immediate session termination without key rotation

### Negative
- Argon2 verification is intentionally slow (~100ms). API key verification on every M2M request
  adds latency — mitigated by ETS caching of verified keys with a short TTL (60s)
- ETS revocation cache must be hydrated on node start; large revocation lists slow boot
  — mitigated by pruning expired JTI records via scheduled task
