configure-production-identity
Get a real, verifiable JWT into a client and attach it to a production `harbor serve` — OIDC app registration, the (tenant, user, session) + scopes claim shape, the iss/aud exact-match contract, and the no-IdP `harbor token` self-issuing on-ramp. Use when moving off `harbor dev`'s ephemeral token to a production deployment, wiring an IdP (Auth0 / Okta / Keycloak / Cognito), or standing up your own issuer.
- License 许可
- Apache-2.0
- 所属仓库
- hurtener/Harbor
- 最近更新
- 2026-11-15
- Stars
- ★ 6
- Forks
- 1
01 前言 Front Matter
- name
- configure-production-identity
- license
- Apache-2.0
- metadata
- { "verbs": "serve, token, tui", "surface": "protocol", "framework": "harbor" }
- description
- Get a real, verifiable JWT into a client and attach it to a production `harbor serve` — OIDC app registration, the (tenant, user, session) + scopes claim shape, the iss/aud exact-match contract, and the no-IdP `harbor token` self-issuing on-ramp. Use when moving off `harbor dev`'s ephemeral token to a production deployment, wiring an IdP (Auth0 / Okta / Keycloak / Cognito), or standing up your own issuer.
03 SKILL.md 正文
Configure production identity
harbor dev hands you an ephemeral HARBOR_DEV_TOKEN and you never think about
auth. harbor serve is the opposite: it boots the headless Runtime behind a
production JWT verifier, mints no token of its own, and rejects every
/v1/* request that does not carry a bearer JWT signed by a key you explicitly
told it to trust. This skill is the operator playbook for crossing that gap —
getting a real, verifiable token into a client's hands and attaching it.
The authority for every claim below is the published production identity setup guide; this skill is the fast path through it. Read the guide when you need the full per-provider detail.
There are two honest on-ramps. They differ only in who issues the token —
serve's verifier is identical for both:
- You have an identity provider (Auth0, Okta, Keycloak, Amazon Cognito, or
any OIDC IdP) — the IdP signs tokens; you point
serveat its published JWKS. This is the multi-user / SSO posture. → §A below. - You have no IdP, or you issue your own tokens — generate a keypair, point
serveat the public half, mint tokens with the private half viaharbor token. A legitimate single-issuer / self-hosting posture. → §B below.
Both converge on one fact: a token Harbor accepts is one whose claims match
what serve.yaml declares, signed by a key in the JWKS serve loads.
1. What serve verifies — the contract
The production identity: stanza (full example:
examples/serve.yaml):
identity:
jwt_algorithms: [RS256, ES256] # asymmetric only — HS* and `none` are rejected
issuer: https://your-issuer.example.com # the `iss` every token MUST carry, verbatim
audience: harbor # the `aud` every token MUST carry (or contain)
jwks_url: https://your-issuer.example.com/.well-known/jwks.json # set EXACTLY ONE
# jwks_file: /etc/harbor/jwks.json
serve refuses to boot if issuer or audience is empty — they are
mandatory for the production profile. That has a direct consequence for your
tokens (the §3 exact-match contract).
The same contract binds external serving binaries (--with-server / sdk/server)
The stock harbor serve binary is not the only production Protocol server. A
scaffolded agent built with harbor scaffold --with-server — whose
cmd/<agent>/main.go reaches the Protocol through the public sdk/server
facade — is also a production Protocol server, and it enforces the exact
same identity contract: server.Open always builds the JWKS verifier from this
identity: stanza and fails loud (naming the missing field) when the JWKS
source is absent. There is no dev-signer and no mock knob on that path — the
posture is production-only by construction. So everything below (the claim
shape, the iss/aud exact-match rule, the harbor token self-issuing on-ramp)
applies verbatim whether you deploy harbor serve or your own
--with-server binary. See
scaffold-a-harbor-agent for the
serving scaffold and its harbor token local-dev loop.
2. The claim shape
Harbor's verifier reads a flat set of claims off the JWT. The multi-isolation triple is carried as three top-level custom claims — the single most important thing to get right, because most IdPs do not emit them by default.
| Claim | Required | Role |
|---|---|---|
tenant |
yes (non-empty) | The outermost isolation scope. |
user |
yes (non-empty) | The connection's user. |
session |
yes (non-empty) | A default session id — a placeholder, not "the conversation" (see below). |
scopes |
no | Elevated scopes — a closed set: admin, console:fleet, agent_config:user. Absent = authenticated but unprivileged. |
agent_reach |
required for agent-addressed data-plane calls | Unique JSON array of 1–128 nonblank registration IDs (each <=128 bytes). It grants resource authority only; it is not part of the isolation triple. Absent or empty grants no agent reach; malformed rejects the token. |
agent_read_reach |
no — read-only history entitlement | Same shape as agent_reach. Lets the bearer READ those agents' rows on the session history surfaces (sessions.turns.list / sessions.turns.get) and their App resources and tool listings (mcp.servers.read_resource, mcp.servers.resources, tools.list, tools.describe; D-510) without selecting them. With agent_read_session_apps: true it also allows a render-admission-backed mcp.apps.call_tool for an App a turn of the caller's own session recorded, and agent_config.user.reconcile_live_profile for the caller's own profile. Start, other agent configuration and every other agent-addressed write still require agent_reach. Steering controls are authorized by run authorship, not reach. A non-empty value requires a non-empty session_reach, or the token is rejected. Use it to show a person their own session after it moved to another agent, or after the agent stopped being available to them. |
agent_read_session_apps |
no — boolean opt-in | true lets the agent_read_reach agents be used for render-admission App callbacks on Apps a turn of the caller's own session recorded, and for the caller's own live-profile restore (D-510). Requires a non-empty agent_read_reach; a non-boolean value rejects the token. Leave it out on history-only bearers. |
session_owner |
no — session participation | A user id of the same tenant, different from user. The bearer acts as a participant inside that owner's session: storage stays in the owner's partition, each turn records the bearer's user as its writer, and user-keyed outbound calls (credential exchange, MCP credentials, user-layer configuration) act as the writer. Requires session_reach naming exactly the token's session and no admin/console:fleet scope. Only start (existing sessions only), steering of the bearer's own runs, GET /v1/events, sessions.turns.list/get, artifacts.get/put and the shared session's own Apps (mcp.servers.read_resource, mcp.apps.call_tool with a render admission, mcp.apps.tool_context; D-510) are allowed; everything else is 403 scope_mismatch. |
speaker |
no — writer label | Printable single-line display label (<=128 bytes) of the bearer's user. Labels the turns it writes on turn rows (participant.speaker) and in the model context. Valid with or without session_owner. |
session_reach |
no — a session-bounding entitlement | Unique JSON array of 0–128 nonblank session IDs (each <=128 bytes). Absent = D-171 dynamic per-request session selection preserved (the default). Present = the effective session (from X-Harbor-Session, the SSE ?session= projection, or the token's session default) MUST be a member or the request fails 403 scope_mismatch before any side effect; an explicitly empty array grants no session. Bearer authority only — never a storage filter or request-body member. Malformed rejects the token. |
iss |
yes | Must equal identity.issuer exactly. |
aud |
yes | Must equal (or, for an array, contain) identity.audience. |
exp |
yes | A token with no exp is rejected as expired. Keep it short. |
Why
sessionis a placeholder. The connection token authenticates who (tenant+user+ scopes); the client picks which conversation per request with theX-Harbor-Sessionheader (the per-request session selector). So mint a stable, non-empty placeholder ("default"is conventional) — the real per-conversation id rides the header. The triple must still be complete in the token: an emptysessionclaim is rejected the same as an emptytenant. The two-part token/header model is covered inuse-the-harbor-protocol§1.
A worked token payload:
{
"iss": "https://your-issuer.example.com",
"aud": "harbor",
"sub": "auth0|6630f1c0a1b2c3d4e5f6a7b8",
"exp": 1799999999,
"tenant": "tenant-acme",
"user": "user-12345",
"session": "default",
"scopes": ["console:fleet"]
}
3. The iss / aud exact-match contract (the most common production 401)
Because serve mandates a non-empty issuer and audience, the verifier
enforces both exactly, fail-closed:
- A token whose
issis anything other than your configuredidentity.issuer→401 auth_rejected. - A token whose
auddoes not equal (or contain) your configuredidentity.audience→401 auth_rejected.
This is the failure operators hit most often: a token minted with a leftover
default issuer/audience signs fine but 401s against any real serve.yaml,
because the strings don't match. When you configure the IdP and when you
mint, iss/aud in the token MUST equal issuer/audience in serve.yaml,
character for character.
A. Real IdP — the OIDC on-ramp
The flow is the same for every provider; only the "add custom claims" step differs.
- Register an application for the backend that holds the Harbor connection. For server-side clients this is an OAuth2 client-credentials app.
- Set the audience / API identifier to the value you will put in
identity.audience(e.g.harbor). Many IdPs only emitaudwhen the token is requested for a registered API/resource — register one. - Note the issuer. The IdP's issuer URL becomes
identity.issuer; its JWKS endpoint (usually<issuer>/.well-known/jwks.jsonor<issuer>/protocol/openid-connect/certs) becomesidentity.jwks_url. - Add the Harbor custom claims — inject
tenant,user,session,scopesand, when the client must address an agent,agent_reachas top-level claims on the access token. When the client must be pinned to a bounded set of conversations, addsession_reachthe same way — a client minted with it can select ONLY those sessions per request (X-Harbor-Sessionoutside the set is refused 403). This is the per-provider step the guide's snippets cover: an Auth0 Client-Credentials Action, Okta authorization-server Claims, a Keycloak client-scope Mapper, or a Cognito Pre-Token-Generation Lambda. Auth0 namespaces claims by URL by default — Harbor reads unprefixed top-level claims, so emit them bare. - Confirm the signing algorithm is one Harbor allows — RS256 (the OIDC
default) or any of RS384/RS512/ES256/ES384/ES512.
HS*andnoneare rejected. List it inidentity.jwt_algorithms.
The four worked provider snippets (Auth0 / Okta / Keycloak / Cognito) live in the setup guide.
B. No IdP — the harbor token self-issuing on-ramp
Standing up an IdP is not a prerequisite for harbor serve. The cliff was
never "you must buy an IdP" — it was that the self-issuing path was undocumented.
harbor token closes it: generate a keypair, point serve's
identity.jwks_file at the public half, mint Harbor JWTs with the private half.
# 1. Generate a keypair + the matching public JWK Set.
harbor token keygen --out ./identity --alg ES256
# 2. Point serve at the public JWKS (instead of jwks_url):
# identity.jwks_file: ./identity/jwks.json
# identity.issuer: https://harbor.internal (your chosen values)
# identity.audience: harbor
# 3. Mint a token whose iss/aud MATCH serve.yaml exactly.
harbor token mint --key ./identity/private.pem \
--tenant tenant-acme --user user-1 --session default \
--issuer https://harbor.internal --audience harbor \
--scopes console:fleet --agent-reach support-agent --ttl 1h
--issuer / --audience are mandatory and must equal identity.issuer /
identity.audience. Mint with no --scopes for a least-privileged token.
Mint with no --agent-reach unless the bearer must use control.start, the
agent-config session/user data plane, or tools.describe with an explicit
agent_id; tenant-local configuration never grants this authority.
Mint with no --session-reach unless the bearer must be pinned to a bounded
set of conversations: --session-reach narrows the bearer to exactly those
session IDs per request (X-Harbor-Session outside the set, or an SSE
?session= outside it, is refused 403), while omission preserves D-171's
dynamic per-request session selection.
Honesty note — know what grade this is. Self-issued tokens are signed by a key you manage — protect the private key (it is the entire trust root), keep it out of version control, rotate it deliberately. This is legitimate for an eval, a single-tenant deployment, or a self-hosting operator who is their own issuer. It is not multi-user SSO — no central revocation, no login UI, no per-user lifecycle. When you need those, graduate to on-ramp A; the two share
serve's verifier exactly, so moving between them is a config change, not a re-architecture.
4. Mint-and-test — prove the loop before shipping a client
-
Boot
serveagainst your identity config:harbor serve --config serve.yamlIt prints that it mints no token and verifies against your JWKS. A misconfigured
issuer/audience/JWKS exits non-zero with a named-field error — fix that first. -
Obtain a token the way a backend client will (client-credentials grant for on-ramp A, or
harbor token mintfor on-ramp B). -
Decode and eyeball it — catches the
iss/aud/claim-shape mismatch before the network does:# JWT payloads are base64url; translate before decoding. echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d | jq .Confirm
iss,aud,tenant,user,session, andscopesare present and exactly matchserve.yaml. A self-consistent config that mints the wrongaudlooks fine until the first401. -
Handshake with
runtime.info— the first call any client makes:curl -sS -X POST https://your-harbor-host:8080/v1/control/runtime.info \ -H "Authorization: Bearer $TOKEN" \ -H "X-Harbor-Session: default" \ -H "Content-Type: application/json" \ -d '{"identity": {}}'A
200withinstance_id/protocol_version/capabilitiesmeans the token verified and the triple resolved. A401 auth_rejectedmeans verification failed (almost alwaysiss/aud/exp/algor akidthe JWKS doesn't hold); a401 identity_requiredmeans a triple claim was missing or empty. -
Attach a real client. The same token + the
X-Harbor-Sessionheader drive every method — the same wireuse-the-harbor-protocolwalks. A complete, SDK-free worked OIDC client ships underexamples/protocol-clients/, doing exactly this flow againstserve.
Co-launch the TUI from harbor serve --tui
The native terminal client can co-launch from the serve binary itself — one
process, one terminal, no separate harbor tui --attach step. The serve
posture is unchanged: the TUI attaches through authenticated REST/SSE using the
operator's JWT, receives no Runtime handle, and quitting drains the owned
server.
export HARBOR_TOKEN='<your-signed-jwt>'
harbor serve --config serve.yaml --bind 127.0.0.1:0 --tui
The same token + iss/aud exact-match contract above applies: serve
verifies the JWT against your JWKS, the TUI resolves it from HARBOR_TOKEN or
~/.harbor/token, and every REST request + SSE reconnect re-reads the
credential. Runtime logs go to a captured sink so Bubble Tea frames are never
overwritten. See drive-the-harbor-tui
§2 "Co-launch from harbor serve --tui" for the keyboard surface and the
co-launch lifecycle.
Common failure modes
- Every call 401s with
auth_rejected. Theiss/audexact-match contract (§3) — your token's strings don't equalserve.yaml's. Decode the token (step 3) and compare character-for-character. serveexits non-zero at boot.issueroraudienceis empty, or the JWKS is unreachable — the production profile mandates both. The error names the field.401 identity_required. A triple claim (tenant/user/session) is missing or empty in the token. Most often the IdP isn't emitting the custom claims — re-check the per-provider claim step in §A.4. An emptysessionis rejected the same as an emptytenant.algrejected. Your IdP signs withHS256(or the token usesnone). Harbor allows asymmetric only — switch the signing key to RS*/ES* and list it inidentity.jwt_algorithms.kidnot found. Your JWKS holds more than one key and the token's headerkiddoesn't select one — make sure the IdP stamps thekidandserve's JWKS carries the matching key.
See also
- Production identity setup guide
— the full manual this skill operationalizes (per-provider snippets, the scope
vocabulary, the complete
harbor tokenwalkthrough). use-the-harbor-protocol— once you have a token, this is the wire:runtime.info,start, the events stream.run-the-dev-loop— theharbor devephemeral-token path you're graduating from.validate-and-package— the rest of the production checklist.examples/serve.yaml— the annotated productionserveconfig.
04 同仓库更多 Skills
define-the-agent-yaml
Walk every field in `harbor.yaml` — REQUIRED (identity + llm), COMMON (planner / memory / state / tools / skills / governance), ADVANCED (server / telemetry / artifacts / events / sessions / tasks / distributed). Use when editing the agent config beyond the scaffolded defaults.
use-the-harbor-protocol
Build a chat UI (or any other client) against the Harbor Protocol directly — auth headers, the typed wire surface, events.subscribe SSE, the topology_snapshot capability, artifact upload. Use when shipping a frontend that talks to the runtime WITHOUT the bundled Console — a custom chatbot, a messaging bot, a TUI, an IDE plugin.
configure-memory-and-skills
Wire multi-turn memory + the runtime skill catalog. Use when the agent needs context across turns (chatbots, multi-step research), or when you want token-savvy DB-backed skills (Skills.md importer / in-runtime generator) the planner can search and inject.