Auth API
Authentication mode, session identity, and API key lifecycle. Auth mode is configured platform-wide via [auth] mode in config.toml ("disabled", "local", or "oidc") — it is not a per-endpoint choice.
Endpoints
| Method | Path | Action | Description |
|---|---|---|---|
| POST | /auth/logout | none | Invalidate the client-side session (no-op server-side today) |
| GET | /auth/check | none | Check if the current request is authenticated |
| GET | /auth/whoami | none | Get current caller identity/role |
| POST | /auth/keys | KeyManagement | Create an API key |
| GET | /auth/keys | KeyManagement | List API keys |
| DELETE | /auth/keys/{id} | KeyManagement | Revoke a key |
| POST | /auth/keys/{id}/rotate | KeyManagement | Rotate a key |
KeyManagement is admin-only (PERMISSION_MATRIX, hoziron-core-types/src/auth/permissions.rs) — no other role can create, list, revoke, or rotate keys, including operator. /auth/check and /auth/whoami have no RBAC gate (any authenticated — or, when auth is disabled, anonymous — caller can read them).
Authentication Modes
Set via [auth] mode in config.toml:
| Mode | Behavior |
|---|---|
"disabled" | Every request gets an anonymous AuthContext with role admin. Boot-time gate applies — see below. |
"local" | Bearer API keys (hzn_sk_...), validated against the on-disk key store (keys.db). |
"oidc" | Bearer JWTs from an external IdP, validated by OidcValidator (issuer/audience/signature). Optionally combine with allow_local_service_keys = true to also accept local API keys for CI/service accounts. |
API Key Auth (mode = "local")
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_a1b2c3d4..." \
http://localhost:4200/agents
Create the first key via the CLI (which also works for scripted bootstrap):
hoziron-cli auth create-key --role operator --name ci-deploy-bot --expires-in 90d --json
OIDC Device-Code Login (mode = "oidc")
The CLI implements RFC 8628 (OAuth 2.0 Device Authorization Grant) — no browser required on the machine running the CLI, though it opens one automatically when available:
hoziron-cli login
# To sign in, visit: https://idp.example.com/device
# And enter code: ABCD-EFGH
# Waiting for approval...
The resulting id_token (falling back to access_token) is cached at ~/.hoziron/token.json (mode 0600) per named context, refreshed transparently using the cached refresh_token when it's close to expiry. hoziron-cli logout clears the cached session for the active context. The server-side OidcValidator performs the real signature/issuer/audience verification on every request — the CLI only decodes the JWT payload locally (unverified) to display identity and check local expiry.
Bootstrap Bypass
/auth/keys is a bootstrap path: a POST to it is allowed unauthenticated exactly when the key store is currently empty (mode local, or mode oidc with allow_local_service_keys = true). This lets a fresh deployment mint its first admin key without a chicken-and-egg problem. Once any key exists, the bypass closes and normal auth applies. (Known TOCTOU race on concurrent first-boot requests — issue #198 — acceptable for single-operator bootstrap, not hardened for automated concurrent bootstrapping.)
GET /health is the only genuinely public path (bypasses auth unconditionally, for orchestrator liveness probes). GET /metrics is deliberately not public (issue #514) — it exposes provider health and request/spend volume, a reconnaissance surface.
Boot-Time Auth Posture Gate (Issue #512, #521)
The platform refuses to boot the api or mcp surface with auth.mode = "disabled" while bound to a non-loopback address:
Refusing to start api: auth.mode="disabled" while bound to network-reachable
address '0.0.0.0:4200'. Set auth.mode to "local" or "oidc", bind to loopback
for dev, or set auth.allow_insecure_no_auth=true to accept the risk (warns
on every boot).
This check (hoziron_core::enforce_auth_posture, crates/platform/hoziron-core/src/auth/posture.rs) is a hard boot error unless one of:
- the bind address is loopback (
127.0.0.1/::1), or auth.modeis"local"or"oidc", or[auth] allow_insecure_no_auth = trueis explicitly set — this is accepted but logs aSECURITY:warning on every boot.
# Local/dev-only fixture — accepts the risk explicitly (see test/docker/config.toml)
[auth]
mode = "disabled"
allow_insecure_no_auth = true
Never set allow_insecure_no_auth = true on anything but a loopback-bound dev/test fixture.
POST /auth/logout
curl -X POST -H "Api-Version: v1" http://localhost:4200/auth/logout
Response (200)
{"status": "logged_out"}
Purely a client-facing convention today — the server holds no session state to invalidate for API-key/OIDC bearer auth (unlike the CLI's local token cache, which hoziron-cli logout clears).
GET /auth/check
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_a1b2c3d4..." \
http://localhost:4200/auth/check
Response (200)
{
"authenticated": true,
"auth_required": true,
"identity": "ci-deploy-bot",
"role": "operator"
}
auth_required reflects whether a real key was used (key_id.is_some()) — false when auth is disabled and the caller is anonymous. An invalid/missing bearer token never reaches the handler at all — the shared auth middleware returns 401 before routing.
GET /auth/whoami
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_a1b2c3d4..." \
http://localhost:4200/auth/whoami
Response (200)
{
"identity": "ci-deploy-bot",
"role": "operator",
"key_id": "a1b2c3d4-...-uuid"
}
key_id is null when auth is disabled (anonymous context).
POST /auth/keys
Create a new API key. Admin-only.
curl -X POST -H "Api-Version: v1" http://localhost:4200/auth/keys \
-H "Authorization: Bearer hzn_sk_admin_key..." \
-H "Content-Type: application/json" \
-d '{
"name": "grafana-scraper",
"role": "viewer",
"expires_in": "365d"
}'
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1-64 characters |
role | string | Yes | One of admin, operator, developer, viewer, service, auditor |
expires_in | string | No | Duration like "90d", "24h", "30m". Omit for no expiration |
Response (200)
{
"id": "e5f6a7b8-...-uuid",
"name": "grafana-scraper",
"role": "viewer",
"secret": "hzn_sk_e5f6a7b8c9d0...",
"prefix": "hzn_sk_e5f6",
"created_at": "2026-06-04T10:00:00Z",
"expires_at": "2027-06-04T10:00:00Z"
}
The
secretfield (the actual bearer token) is returned only once, at creation time. It is not recoverable afterward — onlyprefix(for identification) is retained.expires_atis omitted entirely when no expiration was requested.
Response (503 — auth not enabled)
{"error": "Auth is not enabled"}
Returned when mode != "local" (no key store) — key management requires local-mode auth to be configured, even under OIDC (unless allow_local_service_keys also stood up a key store).
GET /auth/keys
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_admin_key..." \
http://localhost:4200/auth/keys
Response (200)
{
"keys": [
{
"id": "a1b2c3d4-...-uuid",
"name": "ci-deploy-bot",
"role": "operator",
"prefix": "hzn_sk_a1b2",
"created_at": "2026-06-04T10:00:00Z",
"revoked": false,
"expired": false,
"expires_at": "2026-09-02T00:00:00Z"
}
]
}
Secrets are never returned by list — only prefix. expired is computed server-side from expires_at vs. now.
DELETE /auth/keys/{id}
curl -X DELETE -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_admin_key..." \
http://localhost:4200/auth/keys/a1b2c3d4-...-uuid
Response (200)
{"status": "revoked", "id": "a1b2c3d4-...-uuid"}
Note: this returns 200 with a status body, not 204 No Content.
POST /auth/keys/{id}/rotate
Revoke the old key and mint a new one with the same name/role. Optionally overrides the expiry.
curl -X POST -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_admin_key..." \
-H "Content-Type: application/json" \
-d '{"expires_in": "90d"}' \
http://localhost:4200/auth/keys/e5f6a7b8-...-uuid/rotate
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
expires_in | string | No | Overrides expiry for the new key. Body itself is optional — omit entirely to keep the inherited expiry policy |
Response (200)
{
"id": "c9d0e1f2-...-uuid",
"name": "grafana-scraper",
"role": "viewer",
"secret": "hzn_sk_c9d0e1f2a3b4...",
"prefix": "hzn_sk_c9d0",
"created_at": "2026-06-04T11:00:00Z",
"previous_key_id": "e5f6a7b8-...-uuid"
}
Related
- security.md — security status, audit trail, credential vault
- README.md — RBAC role table and error envelope