AGENT SKILL

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:

  1. You have an identity provider (Auth0, Okta, Keycloak, Amazon Cognito, or any OIDC IdP) — the IdP signs tokens; you point serve at its published JWKS. This is the multi-user / SSO posture. → §A below.
  2. You have no IdP, or you issue your own tokens — generate a keypair, point serve at the public half, mint tokens with the private half via harbor 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 session is a placeholder. The connection token authenticates who (tenant + user + scopes); the client picks which conversation per request with the X-Harbor-Session header (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 empty session claim is rejected the same as an empty tenant. The two-part token/header model is covered in use-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 iss is anything other than your configured identity.issuer → 401 auth_rejected.
  • A token whose aud does not equal (or contain) your configured identity.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.

  1. Register an application for the backend that holds the Harbor connection. For server-side clients this is an OAuth2 client-credentials app.
  2. Set the audience / API identifier to the value you will put in identity.audience (e.g. harbor). Many IdPs only emit aud when the token is requested for a registered API/resource — register one.
  3. Note the issuer. The IdP's issuer URL becomes identity.issuer; its JWKS endpoint (usually <issuer>/.well-known/jwks.json or <issuer>/protocol/openid-connect/certs) becomes identity.jwks_url.
  4. Add the Harbor custom claims — inject tenant, user, session, scopes and, when the client must address an agent, agent_reach as top-level claims on the access token. When the client must be pinned to a bounded set of conversations, add session_reach the same way — a client minted with it can select ONLY those sessions per request (X-Harbor-Session outside 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.
  5. Confirm the signing algorithm is one Harbor allows — RS256 (the OIDC default) or any of RS384/RS512/ES256/ES384/ES512. HS* and none are rejected. List it in identity.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

  1. Boot serve against your identity config:

    harbor serve --config serve.yaml
    

    It 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.

  2. Obtain a token the way a backend client will (client-credentials grant for on-ramp A, or harbor token mint for on-ramp B).

  3. 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, and scopes are present and exactly match serve.yaml. A self-consistent config that mints the wrong aud looks fine until the first 401.

  4. 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 200 with instance_id / protocol_version / capabilities means the token verified and the triple resolved. A 401 auth_rejected means verification failed (almost always iss/aud/exp/alg or a kid the JWKS doesn't hold); a 401 identity_required means a triple claim was missing or empty.

  5. Attach a real client. The same token + the X-Harbor-Session header drive every method — the same wire use-the-harbor-protocol walks. A complete, SDK-free worked OIDC client ships under examples/protocol-clients/, doing exactly this flow against serve.

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. The iss/aud exact-match contract (§3) — your token's strings don't equal serve.yaml's. Decode the token (step 3) and compare character-for-character.
  • serve exits non-zero at boot. issuer or audience is 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 empty session is rejected the same as an empty tenant.
  • alg rejected. Your IdP signs with HS256 (or the token uses none). Harbor allows asymmetric only — switch the signing key to RS*/ES* and list it in identity.jwt_algorithms.
  • kid not found. Your JWKS holds more than one key and the token's header kid doesn't select one — make sure the IdP stamps the kid and serve's JWKS carries the matching key.

See also

04 同仓库更多 Skills