Skip to content

Choreography 1 — Auth & identity

How a Protocol client authenticates, which scopes exist, and how the multi-isolation triple (tenant, user, session) flows through every request.

Methods demonstrated: start, sessions.list, auth.rotate_token

The two-part model: the token says WHO, the header says WHICH conversation

The connection token is a per-backend credential, like an API key. It authenticates (tenant, user) plus the connection's scopes. It does not pin a session. The session is dynamic — chosen per-request via the X-Harbor-Session header, always scoped under the token's verified (tenant, user):

text
Authorization: Bearer <connection-token>
X-Harbor-Session: <conversation-session-id>

Rules the Runtime enforces at the auth middleware, fail-closed:

  • X-Harbor-Session present and non-empty → it replaces the token's session claim. Tenant and user stay token-verified.
  • Header absent → the token's session claim is used as a back-compat default (the dev token carries session: dev for exactly this).
  • Neither a header nor a default claim → 401 identity_required. The session is mandatory; identity fails closed — there is no silent default.
  • The header can only ever set the session. A client can never widen its tenant or user — there is no header override for those on the authenticated path.

New conversation = new session id = create-on-first-use. Pick a fresh id (a UUID is recommended; any non-empty string works) and issue a start with it — the Runtime materialises the session row on the first turn. There is no explicit "open session" call. A start on an already-open session is the normal second-and-later turn (not an error); a start on a closed session is rejected invalid_request — reopen-after-close is forbidden, start a new conversation instead.

Many sessions coexist under one token, fully isolated: every storage read and event delivery filters by the complete (tenant, user, session) triple. One user driving five concurrent conversations gets five disjoint worlds.

The JWT

Asymmetric algorithms only: RS256 / RS384 / RS512 / ES256 / ES384 / ES512. HS* and none are rejected at the algorithm allowlist before the keyfunc is ever consulted. The verified claims:

ClaimRole
tenantverified — the connection's tenant
userverified — the connection's user
sessiondefault only — used iff X-Harbor-Session is absent
scopesthe connection's elevated scopes (may be empty)
iss / aud / exp / nbf / kidstandard JWT validation against the Runtime's identity: config

The body of a request may also carry an identity object (IdentityScope). The simplest correct client sends "identity": {} and relies on the header — the Runtime backfills the body from the verified request identity, and rejects any non-empty body component that contradicts it (401 identity_required). The body's run and scope fields are independent of the token and survive the backfill (they parameterise steering — see task control).

The scope vocabulary (closed set)

Two elevated scopes exist. There is no third; per-surface scopes (tools.admin, events.crosstenant, …) are deliberately not minted:

ScopeGrants
adminCross-tenant fan-in on every read surface that has one; the admin-gated mutations (artifacts.delete, memory.put / memory.delete, the mcp.servers.* and tools.* admin verbs, the agents.* fleet-control verbs, flows.run, auth.rotate_token); admin impersonation.
console:fleetFleet observation: cross-tenant fan-in on events.subscribe / events.aggregate, the five search.* methods, sessions.list, artifacts.list, memory.list, and the seven posture reads (runtime.*, metrics.snapshot, governance.posture, llm.posture). It is observation-only — it satisfies no mutation gate, and the admin-only fan-ins (tasks.list, pause.list, topology.snapshot, flows.list / flows.runs.list) do not consult it. The per-method Auth column in the methods reference is the authoritative row-level map.

Distinct from both: the steering scope (session_user / owner_user / admin) — the privilege tier each control is checked against per its RFC §6.3 minimum. It is derived from your verified token, never read from a request body: the Runtime compares your token's (tenant, user) and admin claim against the target run (admin → cross-tenant + every control; you own the run → owner_user, which covers inject_context / user_message / cancel / pause / resume / redirect / approve / reject; otherwise no authority). A prioritize or any cross-tenant control needs admin. The body's scope field is ignored. See task control.

Dev bootstrap vs production posture

Devharbor dev mints an ephemeral ES256 keypair at boot and serves POST /v1/dev/bootstrap.json (loopback-only — non-loopback peers get a flat 403 regardless of headers). The minted token carries tenant=dev / user=dev / session=dev (default) and the full scope set ["admin", "console:fleet"]. It is printed at boot as HARBOR_DEV_TOKEN= and returned by the bootstrap envelope. The quickstart uses it.

To mint a lesser-privileged token for testing the steering authorization contract — a token for a chosen identity with no admin scope — POST an optional override body to the same loopback endpoint:

bash
curl -sS -X POST http://127.0.0.1:18080/v1/dev/bootstrap.json \
  -d '{"tenant":"dev","user":"dev","session":"dev","scopes":[]}'

An explicit empty scopes array mints a token with no scopes (a non-admin token); a full (tenant, user, session) triple overrides the identity. An empty {} body keeps the default admin token, so the one-click attach flow is unchanged. This override is dev-only — harbor serve does not mint tokens.

Production — the Runtime validates tokens against your OIDC provider via the identity: config block (issuer, audience, jwks_url, jwt_algorithms). Your identity provider mints the tokens; the bootstrap endpoint does not exist on harbor serve. An admin-scoped operator can rotate their own token via auth.rotate_token (one-time reveal; every rotation emits a redacted audit event).

What 401 / 403 mean (branch on code, not on the status alone)

Four distinct rejections, one error envelope each (errors reference):

HTTPcodeMeaningFix
401identity_requiredNo identity resolved: missing bearer, missing session, or a body identity contradicting the token.Attach the token / the X-Harbor-Session header; send "identity": {}.
401auth_rejectedA token was present but failed verification (bad alg / signature / expiry / kid / audience / issuer).Obtain a fresh, correctly-issued token.
403identity_scope_requiredAuthenticated and identified, but the requested cross-tenant fan-in or admin verb needs admin / console:fleet.Re-authenticate with a scope-bearing token.
403scope_mismatchA steering control's body scope claim is below the control's RFC §6.3 minimum, or cross-tenant steering without admin.Use a sufficient steering scope.

A useful diagnostic habit: 401 means "the wire doesn't know who you are"; 403 means "it knows exactly who you are, and that's the problem."

Listing what a connection can see

sessions.list returns every session under the connection's (tenant, user) — all conversations, open and closed, surviving Runtime restarts (the session catalog persists in the StateStore):

bash
curl -sS -X POST "$HARBOR_BASE_URL/v1/sessions/list" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Harbor-Session: any" \
  -H "Content-Type: application/json" \
  -d '{"identity": {}, "filter": {}, "limit": 50}'

A cross-tenant filter.tenant_ids requires admin; without it the call is rejected with scope_mismatch. Existence is never revealed across tenants — a foreign session id behaves exactly like a missing one (not_found).

Apache-2.0 licensed — see LICENSE.