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

MethodPathActionDescription
POST/auth/logoutnoneInvalidate the client-side session (no-op server-side today)
GET/auth/checknoneCheck if the current request is authenticated
GET/auth/whoaminoneGet current caller identity/role
POST/auth/keysKeyManagementCreate an API key
GET/auth/keysKeyManagementList API keys
DELETE/auth/keys/{id}KeyManagementRevoke a key
POST/auth/keys/{id}/rotateKeyManagementRotate 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:

ModeBehavior
"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.mode is "local" or "oidc", or
  • [auth] allow_insecure_no_auth = true is explicitly set — this is accepted but logs a SECURITY: 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

FieldTypeRequiredDescription
namestringYes1-64 characters
rolestringYesOne of admin, operator, developer, viewer, service, auditor
expires_instringNoDuration 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 secret field (the actual bearer token) is returned only once, at creation time. It is not recoverable afterward — only prefix (for identification) is retained. expires_at is 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

FieldTypeRequiredDescription
expires_instringNoOverrides 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"
}

  • security.md — security status, audit trail, credential vault
  • README.md — RBAC role table and error envelope