[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M723DKM09XW7VF7S6QVP95JE":3,"skill-same:01M723DKM09XW7VF7S6QVP95JE":49},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":28,"commitSha":29,"skillDirectory":30,"forksCount":31,"repositoryHtmlUrl":32,"repositoryHomepageUrl":6,"ownerHtmlUrl":33,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":34,"install":35,"resources":48},"01M723DKM09XW7VF7S6QVP95JE","configure-production-identity",null,"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\u002Faud 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 \u002F Okta \u002F Keycloak \u002F Cognito), or standing up your own issuer.","hurtener\u002FHarbor","hurtener","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F117486687?v=4","01M723BZXGKZDHCZ9JHRV24W4D","Santi Benvenuto","01M723BZY77XR795N9PK1BS4GX",6,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fhurtener\u002FHarbor\u002Fblob\u002Fmain\u002Fdocs\u002Fskills\u002Fconfigure-production-identity\u002FSKILL.md",[],[],"2026-11-10 18:39:20",false,"---\nname: configure-production-identity\ndescription: \"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\u002Faud 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 \u002F Okta \u002F Keycloak \u002F Cognito), or standing up your own issuer.\"\nlicense: Apache-2.0\nmetadata:\n  framework: harbor\n  surface: protocol\n  verbs: \"serve, token, tui\"\n---\n\n# Configure production identity\n\n`harbor dev` hands you an ephemeral `HARBOR_DEV_TOKEN` and you never think about\nauth. `harbor serve` is the opposite: it boots the headless Runtime behind a\n**production JWT verifier**, mints **no token of its own**, and rejects every\n`\u002Fv1\u002F*` request that does not carry a bearer JWT signed by a key you explicitly\ntold it to trust. This skill is the operator playbook for crossing that gap —\ngetting a real, verifiable token into a client's hands and attaching it.\n\nThe authority for every claim below is the published\n[production identity setup guide](https:\u002F\u002Fhurtener.github.io\u002FHarbor\u002Fprotocol\u002Fproduction-identity-setup);\nthis skill is the fast path through it. Read the guide when you need the full\nper-provider detail.\n\nThere are **two honest on-ramps**. They differ only in *who issues the token* —\n`serve`'s verifier is identical for both:\n\n1. **You have an identity provider** (Auth0, Okta, Keycloak, Amazon Cognito, or\n   any OIDC IdP) — the IdP signs tokens; you point `serve` at its published\n   JWKS. This is the multi-user \u002F SSO posture. → §A below.\n2. **You have no IdP, or you issue your own tokens** — generate a keypair, point\n   `serve` at the public half, mint tokens with the private half via\n   `harbor token`. A legitimate single-issuer \u002F self-hosting posture. → §B below.\n\nBoth converge on one fact: **a token Harbor accepts is one whose claims match\nwhat `serve.yaml` declares, signed by a key in the JWKS `serve` loads.**\n\n## 1. What `serve` verifies — the contract\n\nThe production `identity:` stanza (full example:\n[`examples\u002Fserve.yaml`](..\u002F..\u002F..\u002Fexamples\u002Fserve.yaml)):\n\n```yaml\nidentity:\n  jwt_algorithms: [RS256, ES256]   # asymmetric only — HS* and `none` are rejected\n  issuer:   https:\u002F\u002Fyour-issuer.example.com   # the `iss` every token MUST carry, verbatim\n  audience: harbor                            # the `aud` every token MUST carry (or contain)\n  jwks_url: https:\u002F\u002Fyour-issuer.example.com\u002F.well-known\u002Fjwks.json   # set EXACTLY ONE\n  # jwks_file: \u002Fetc\u002Fharbor\u002Fjwks.json\n```\n\n`serve` **refuses to boot** if `issuer` or `audience` is empty — they are\nmandatory for the production profile. That has a direct consequence for your\ntokens (the §3 exact-match contract).\n\n### The same contract binds external serving binaries (`--with-server` \u002F `sdk\u002Fserver`)\n\nThe stock `harbor serve` binary is not the only production Protocol server. A\nscaffolded agent built with `harbor scaffold --with-server` — whose\n`cmd\u002F\u003Cagent>\u002Fmain.go` reaches the Protocol through the public `sdk\u002Fserver`\nfacade — is **also** a production Protocol server, and it enforces the exact\nsame identity contract: `server.Open` always builds the JWKS verifier from this\n`identity:` stanza and **fails loud** (naming the missing field) when the JWKS\nsource is absent. There is no dev-signer and no mock knob on that path — the\nposture is production-only by construction. So everything below (the claim\nshape, the iss\u002Faud exact-match rule, the `harbor token` self-issuing on-ramp)\napplies verbatim whether you deploy `harbor serve` or your own\n`--with-server` binary. See\n[`scaffold-a-harbor-agent`](..\u002Fscaffold-a-harbor-agent\u002FSKILL.md) for the\nserving scaffold and its `harbor token` local-dev loop.\n\n## 2. The claim shape\n\nHarbor's verifier reads a flat set of claims off the JWT. The multi-isolation\ntriple is carried as **three top-level custom claims** — the single most\nimportant thing to get right, because most IdPs do **not** emit them by default.\n\n| Claim | Required | Role |\n|---|---|---|\n| `tenant` | **yes** (non-empty) | The outermost isolation scope. |\n| `user` | **yes** (non-empty) | The connection's user. |\n| `session` | **yes** (non-empty) | A **default** session id — a placeholder, not \"the conversation\" (see below). |\n| `scopes` | no | Elevated scopes — a closed set: `admin`, `console:fleet`, `agent_config:user`. Absent = authenticated but unprivileged. |\n| `agent_reach` | required for agent-addressed data-plane calls | Unique JSON array of 1–128 nonblank registration IDs (each \u003C=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. |\n| `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` \u002F `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. |\n| `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. |\n| `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`\u002F`console:fleet` scope. Only `start` (existing sessions only), steering of the bearer's own runs, `GET \u002Fv1\u002Fevents`, `sessions.turns.list\u002Fget`, `artifacts.get\u002Fput` 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`. |\n| `speaker` | no — writer label | Printable single-line display label (\u003C=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`. |\n| `session_reach` | no — a session-bounding entitlement | Unique JSON array of 0–128 nonblank session IDs (each \u003C=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. |\n| `iss` | **yes** | Must equal `identity.issuer` **exactly**. |\n| `aud` | **yes** | Must equal (or, for an array, contain) `identity.audience`. |\n| `exp` | **yes** | A token with no `exp` is rejected as expired. Keep it short. |\n\n> **Why `session` is a placeholder.** The connection token authenticates *who*\n> (`tenant` + `user` + scopes); the client picks *which conversation* per request\n> with the `X-Harbor-Session` header (the per-request session selector). So mint\n> a stable, non-empty placeholder (`\"default\"` is conventional) — the real\n> per-conversation id rides the header. The triple must still be **complete** in\n> the token: an empty `session` claim is rejected the same as an empty `tenant`.\n> The two-part token\u002Fheader model is covered in\n> [`use-the-harbor-protocol`](..\u002Fuse-the-harbor-protocol\u002FSKILL.md) §1.\n\nA worked token payload:\n\n```json\n{\n  \"iss\": \"https:\u002F\u002Fyour-issuer.example.com\",\n  \"aud\": \"harbor\",\n  \"sub\": \"auth0|6630f1c0a1b2c3d4e5f6a7b8\",\n  \"exp\": 1799999999,\n  \"tenant\": \"tenant-acme\",\n  \"user\": \"user-12345\",\n  \"session\": \"default\",\n  \"scopes\": [\"console:fleet\"]\n}\n```\n\n## 3. The `iss` \u002F `aud` exact-match contract (the most common production 401)\n\nBecause `serve` mandates a non-empty `issuer` and `audience`, the verifier\nenforces both **exactly, fail-closed**:\n\n- A token whose `iss` is anything other than your configured `identity.issuer` →\n  `401 auth_rejected`.\n- A token whose `aud` does not equal (or contain) your configured\n  `identity.audience` → `401 auth_rejected`.\n\nThis is the failure operators hit most often: a token minted with a leftover\ndefault issuer\u002Faudience signs fine but **401s against any real `serve.yaml`**,\nbecause the strings don't match. When you configure the IdP **and** when you\nmint, `iss`\u002F`aud` in the token MUST equal `issuer`\u002F`audience` in `serve.yaml`,\ncharacter for character.\n\n## A. Real IdP — the OIDC on-ramp\n\nThe flow is the same for every provider; only the \"add custom claims\" step\ndiffers.\n\n1. **Register an application** for the backend that holds the Harbor connection.\n   For server-side clients this is an OAuth2 **client-credentials** app.\n2. **Set the audience \u002F API identifier** to the value you will put in\n   `identity.audience` (e.g. `harbor`). Many IdPs only emit `aud` when the token\n   is requested for a registered API\u002Fresource — register one.\n3. **Note the issuer.** The IdP's issuer URL becomes `identity.issuer`; its JWKS\n   endpoint (usually `\u003Cissuer>\u002F.well-known\u002Fjwks.json` or\n   `\u003Cissuer>\u002Fprotocol\u002Fopenid-connect\u002Fcerts`) becomes `identity.jwks_url`.\n4. **Add the Harbor custom claims** — inject `tenant`, `user`, `session`,\n   `scopes` and, when the client must address an agent, `agent_reach` as\n   **top-level** claims on the access token. When the client must be\n   pinned to a bounded set of conversations, add `session_reach` the same\n   way — a client minted with it can select ONLY those sessions per\n   request (`X-Harbor-Session` outside the set is refused 403). This is the\n   per-provider step the guide's snippets cover: an Auth0\n   **Client-Credentials Action**, Okta **authorization-server Claims**, a\n   Keycloak **client-scope Mapper**, or a Cognito **Pre-Token-Generation\n   Lambda**. Auth0 namespaces claims by URL by default — Harbor reads\n   **unprefixed** top-level claims, so emit them bare.\n5. **Confirm the signing algorithm** is one Harbor allows — RS256 (the OIDC\n   default) or any of RS384\u002FRS512\u002FES256\u002FES384\u002FES512. `HS*` and `none` are\n   rejected. List it in `identity.jwt_algorithms`.\n\nThe four worked provider snippets (Auth0 \u002F Okta \u002F Keycloak \u002F Cognito) live in\nthe [setup guide](https:\u002F\u002Fhurtener.github.io\u002FHarbor\u002Fprotocol\u002Fproduction-identity-setup#on-ramp-a-register-an-oidc-app).\n\n## B. No IdP — the `harbor token` self-issuing on-ramp\n\nStanding up an IdP is **not** a prerequisite for `harbor serve`. The cliff was\nnever \"you must buy an IdP\" — it was that the self-issuing path was undocumented.\n`harbor token` closes it: generate a keypair, point `serve`'s\n`identity.jwks_file` at the public half, mint Harbor JWTs with the private half.\n\n```bash\n# 1. Generate a keypair + the matching public JWK Set.\nharbor token keygen --out .\u002Fidentity --alg ES256\n\n# 2. Point serve at the public JWKS (instead of jwks_url):\n#    identity.jwks_file: .\u002Fidentity\u002Fjwks.json\n#    identity.issuer:    https:\u002F\u002Fharbor.internal     (your chosen values)\n#    identity.audience:  harbor\n\n# 3. Mint a token whose iss\u002Faud MATCH serve.yaml exactly.\nharbor token mint --key .\u002Fidentity\u002Fprivate.pem \\\n  --tenant tenant-acme --user user-1 --session default \\\n  --issuer https:\u002F\u002Fharbor.internal --audience harbor \\\n  --scopes console:fleet --agent-reach support-agent --ttl 1h\n```\n\n`--issuer` \u002F `--audience` are mandatory and **must equal** `identity.issuer` \u002F\n`identity.audience`. Mint with no `--scopes` for a least-privileged token.\nMint with no `--agent-reach` unless the bearer must use `control.start`, the\nagent-config session\u002Fuser data plane, or `tools.describe` with an explicit\n`agent_id`; tenant-local configuration never grants this authority.\nMint with no `--session-reach` unless the bearer must be pinned to a bounded\nset of conversations: `--session-reach` narrows the bearer to exactly those\nsession IDs per request (`X-Harbor-Session` outside the set, or an SSE\n`?session=` outside it, is refused 403), while omission preserves D-171's\ndynamic per-request session selection.\n\n> **Honesty note — know what grade this is.** Self-issued tokens are signed by a\n> key **you** manage — protect the private key (it is the entire trust root),\n> keep it out of version control, rotate it deliberately. This is legitimate for\n> an eval, a single-tenant deployment, or a self-hosting operator who is their\n> own issuer. It is **not** multi-user SSO — no central revocation, no login UI,\n> no per-user lifecycle. When you need those, graduate to on-ramp A; the two\n> share `serve`'s verifier exactly, so moving between them is a config change,\n> not a re-architecture.\n\n## 4. Mint-and-test — prove the loop before shipping a client\n\n1. **Boot `serve`** against your identity config:\n\n   ```bash\n   harbor serve --config serve.yaml\n   ```\n\n   It prints that it mints no token and verifies against your JWKS. A\n   misconfigured `issuer`\u002F`audience`\u002FJWKS exits non-zero with a named-field error\n   — fix that first.\n\n2. **Obtain a token** the way a backend client will (client-credentials grant\n   for on-ramp A, or `harbor token mint` for on-ramp B).\n\n3. **Decode and eyeball it** — catches the `iss`\u002F`aud`\u002Fclaim-shape mismatch\n   before the network does:\n\n   ```bash\n   # JWT payloads are base64url; translate before decoding.\n   echo \"$TOKEN\" | cut -d. -f2 | tr '_-' '\u002F+' | base64 -d | jq .\n   ```\n\n   Confirm `iss`, `aud`, `tenant`, `user`, `session`, and `scopes` are present\n   and exactly match `serve.yaml`. A self-consistent config that mints the wrong\n   `aud` looks fine until the first `401`.\n\n4. **Handshake with `runtime.info`** — the first call any client makes:\n\n   ```bash\n   curl -sS -X POST https:\u002F\u002Fyour-harbor-host:8080\u002Fv1\u002Fcontrol\u002Fruntime.info \\\n     -H \"Authorization: Bearer $TOKEN\" \\\n     -H \"X-Harbor-Session: default\" \\\n     -H \"Content-Type: application\u002Fjson\" \\\n     -d '{\"identity\": {}}'\n   ```\n\n   A `200` with `instance_id` \u002F `protocol_version` \u002F `capabilities` means the\n   token verified and the triple resolved. A `401 auth_rejected` means\n   verification failed (almost always `iss`\u002F`aud`\u002F`exp`\u002F`alg` or a `kid` the JWKS\n   doesn't hold); a `401 identity_required` means a triple claim was missing or\n   empty.\n\n5. **Attach a real client.** The same token + the `X-Harbor-Session` header drive\n   every method — the same wire [`use-the-harbor-protocol`](..\u002Fuse-the-harbor-protocol\u002FSKILL.md)\n   walks. A complete, SDK-free worked OIDC client ships under\n   `examples\u002Fprotocol-clients\u002F`, doing exactly this flow against `serve`.\n\n### Co-launch the TUI from `harbor serve --tui`\n\nThe native terminal client can co-launch from the serve binary itself — one\nprocess, one terminal, no separate `harbor tui --attach` step. The serve\nposture is unchanged: the TUI attaches through authenticated REST\u002FSSE using the\noperator's JWT, receives no Runtime handle, and quitting drains the owned\nserver.\n\n```bash\nexport HARBOR_TOKEN='\u003Cyour-signed-jwt>'\nharbor serve --config serve.yaml --bind 127.0.0.1:0 --tui\n```\n\nThe same token + `iss`\u002F`aud` exact-match contract above applies: `serve`\nverifies the JWT against your JWKS, the TUI resolves it from `HARBOR_TOKEN` or\n`~\u002F.harbor\u002Ftoken`, and every REST request + SSE reconnect re-reads the\ncredential. Runtime logs go to a captured sink so Bubble Tea frames are never\noverwritten. See [`drive-the-harbor-tui`](..\u002Fdrive-the-harbor-tui\u002FSKILL.md)\n§2 \"Co-launch from `harbor serve --tui`\" for the keyboard surface and the\nco-launch lifecycle.\n\n## Common failure modes\n\n- **Every call 401s with `auth_rejected`.** The `iss`\u002F`aud` exact-match contract\n  (§3) — your token's strings don't equal `serve.yaml`'s. Decode the token (step\n  3) and compare character-for-character.\n- **`serve` exits non-zero at boot.** `issuer` or `audience` is empty, or the\n  JWKS is unreachable — the production profile mandates both. The error names the\n  field.\n- **`401 identity_required`.** A triple claim (`tenant` \u002F `user` \u002F `session`) is\n  missing or empty in the token. Most often the IdP isn't emitting the custom\n  claims — re-check the per-provider claim step in §A.4. An empty `session` is\n  rejected the same as an empty `tenant`.\n- **`alg` rejected.** Your IdP signs with `HS256` (or the token uses `none`).\n  Harbor allows asymmetric only — switch the signing key to RS*\u002FES* and list it\n  in `identity.jwt_algorithms`.\n- **`kid` not found.** Your JWKS holds more than one key and the token's header\n  `kid` doesn't select one — make sure the IdP stamps the `kid` and `serve`'s\n  JWKS carries the matching key.\n\n## See also\n\n- [Production identity setup guide](https:\u002F\u002Fhurtener.github.io\u002FHarbor\u002Fprotocol\u002Fproduction-identity-setup)\n  — the full manual this skill operationalizes (per-provider snippets, the scope\n  vocabulary, the complete `harbor token` walkthrough).\n- [`use-the-harbor-protocol`](..\u002Fuse-the-harbor-protocol\u002FSKILL.md) — once you\n  have a token, this is the wire: `runtime.info`, `start`, the events stream.\n- [`run-the-dev-loop`](..\u002Frun-the-dev-loop\u002FSKILL.md) — the `harbor dev`\n  ephemeral-token path you're graduating from.\n- [`validate-and-package`](..\u002Fvalidate-and-package\u002FSKILL.md) — the rest of the\n  production checklist.\n- [`examples\u002Fserve.yaml`](..\u002F..\u002F..\u002Fexamples\u002Fserve.yaml) — the annotated\n  production `serve` config.\n","# Configure production identity\n\n`harbor dev` hands you an ephemeral `HARBOR_DEV_TOKEN` and you never think about\nauth. `harbor serve` is the opposite: it boots the headless Runtime behind a\n**production JWT verifier**, mints **no token of its own**, and rejects every\n`\u002Fv1\u002F*` request that does not carry a bearer JWT signed by a key you explicitly\ntold it to trust. This skill is the operator playbook for crossing that gap —\ngetting a real, verifiable token into a client's hands and attaching it.\n\nThe authority for every claim below is the published\n[production identity setup guide](https:\u002F\u002Fhurtener.github.io\u002FHarbor\u002Fprotocol\u002Fproduction-identity-setup);\nthis skill is the fast path through it. Read the guide when you need the full\nper-provider detail.\n\nThere are **two honest on-ramps**. They differ only in *who issues the token* —\n`serve`'s verifier is identical for both:\n\n1. **You have an identity provider** (Auth0, Okta, Keycloak, Amazon Cognito, or\n   any OIDC IdP) — the IdP signs tokens; you point `serve` at its published\n   JWKS. This is the multi-user \u002F SSO posture. → §A below.\n2. **You have no IdP, or you issue your own tokens** — generate a keypair, point\n   `serve` at the public half, mint tokens with the private half via\n   `harbor token`. A legitimate single-issuer \u002F self-hosting posture. → §B below.\n\nBoth converge on one fact: **a token Harbor accepts is one whose claims match\nwhat `serve.yaml` declares, signed by a key in the JWKS `serve` loads.**\n\n## 1. What `serve` verifies — the contract\n\nThe production `identity:` stanza (full example:\n[`examples\u002Fserve.yaml`](..\u002F..\u002F..\u002Fexamples\u002Fserve.yaml)):\n\n```yaml\nidentity:\n  jwt_algorithms: [RS256, ES256]   # asymmetric only — HS* and `none` are rejected\n  issuer:   https:\u002F\u002Fyour-issuer.example.com   # the `iss` every token MUST carry, verbatim\n  audience: harbor                            # the `aud` every token MUST carry (or contain)\n  jwks_url: https:\u002F\u002Fyour-issuer.example.com\u002F.well-known\u002Fjwks.json   # set EXACTLY ONE\n  # jwks_file: \u002Fetc\u002Fharbor\u002Fjwks.json\n```\n\n`serve` **refuses to boot** if `issuer` or `audience` is empty — they are\nmandatory for the production profile. That has a direct consequence for your\ntokens (the §3 exact-match contract).\n\n### The same contract binds external serving binaries (`--with-server` \u002F `sdk\u002Fserver`)\n\nThe stock `harbor serve` binary is not the only production Protocol server. A\nscaffolded agent built with `harbor scaffold --with-server` — whose\n`cmd\u002F\u003Cagent>\u002Fmain.go` reaches the Protocol through the public `sdk\u002Fserver`\nfacade — is **also** a production Protocol server, and it enforces the exact\nsame identity contract: `server.Open` always builds the JWKS verifier from this\n`identity:` stanza and **fails loud** (naming the missing field) when the JWKS\nsource is absent. There is no dev-signer and no mock knob on that path — the\nposture is production-only by construction. So everything below (the claim\nshape, the iss\u002Faud exact-match rule, the `harbor token` self-issuing on-ramp)\napplies verbatim whether you deploy `harbor serve` or your own\n`--with-server` binary. See\n[`scaffold-a-harbor-agent`](..\u002Fscaffold-a-harbor-agent\u002FSKILL.md) for the\nserving scaffold and its `harbor token` local-dev loop.\n\n## 2. The claim shape\n\nHarbor's verifier reads a flat set of claims off the JWT. The multi-isolation\ntriple is carried as **three top-level custom claims** — the single most\nimportant thing to get right, because most IdPs do **not** emit them by default.\n\n| Claim | Required | Role |\n|---|---|---|\n| `tenant` | **yes** (non-empty) | The outermost isolation scope. |\n| `user` | **yes** (non-empty) | The connection's user. |\n| `session` | **yes** (non-empty) | A **default** session id — a placeholder, not \"the conversation\" (see below). |\n| `scopes` | no | Elevated scopes — a closed set: `admin`, `console:fleet`, `agent_config:user`. Absent = authenticated but unprivileged. |\n| `agent_reach` | required for agent-addressed data-plane calls | Unique JSON array of 1–128 nonblank registration IDs (each \u003C=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. |\n| `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` \u002F `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. |\n| `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. |\n| `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`\u002F`console:fleet` scope. Only `start` (existing sessions only), steering of the bearer's own runs, `GET \u002Fv1\u002Fevents`, `sessions.turns.list\u002Fget`, `artifacts.get\u002Fput` 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`. |\n| `speaker` | no — writer label | Printable single-line display label (\u003C=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`. |\n| `session_reach` | no — a session-bounding entitlement | Unique JSON array of 0–128 nonblank session IDs (each \u003C=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. |\n| `iss` | **yes** | Must equal `identity.issuer` **exactly**. |\n| `aud` | **yes** | Must equal (or, for an array, contain) `identity.audience`. |\n| `exp` | **yes** | A token with no `exp` is rejected as expired. Keep it short. |\n\n> **Why `session` is a placeholder.** The connection token authenticates *who*\n> (`tenant` + `user` + scopes); the client picks *which conversation* per request\n> with the `X-Harbor-Session` header (the per-request session selector). So mint\n> a stable, non-empty placeholder (`\"default\"` is conventional) — the real\n> per-conversation id rides the header. The triple must still be **complete** in\n> the token: an empty `session` claim is rejected the same as an empty `tenant`.\n> The two-part token\u002Fheader model is covered in\n> [`use-the-harbor-protocol`](..\u002Fuse-the-harbor-protocol\u002FSKILL.md) §1.\n\nA worked token payload:\n\n```json\n{\n  \"iss\": \"https:\u002F\u002Fyour-issuer.example.com\",\n  \"aud\": \"harbor\",\n  \"sub\": \"auth0|6630f1c0a1b2c3d4e5f6a7b8\",\n  \"exp\": 1799999999,\n  \"tenant\": \"tenant-acme\",\n  \"user\": \"user-12345\",\n  \"session\": \"default\",\n  \"scopes\": [\"console:fleet\"]\n}\n```\n\n## 3. The `iss` \u002F `aud` exact-match contract (the most common production 401)\n\nBecause `serve` mandates a non-empty `issuer` and `audience`, the verifier\nenforces both **exactly, fail-closed**:\n\n- A token whose `iss` is anything other than your configured `identity.issuer` →\n  `401 auth_rejected`.\n- A token whose `aud` does not equal (or contain) your configured\n  `identity.audience` → `401 auth_rejected`.\n\nThis is the failure operators hit most often: a token minted with a leftover\ndefault issuer\u002Faudience signs fine but **401s against any real `serve.yaml`**,\nbecause the strings don't match. When you configure the IdP **and** when you\nmint, `iss`\u002F`aud` in the token MUST equal `issuer`\u002F`audience` in `serve.yaml`,\ncharacter for character.\n\n## A. Real IdP — the OIDC on-ramp\n\nThe flow is the same for every provider; only the \"add custom claims\" step\ndiffers.\n\n1. **Register an application** for the backend that holds the Harbor connection.\n   For server-side clients this is an OAuth2 **client-credentials** app.\n2. **Set the audience \u002F API identifier** to the value you will put in\n   `identity.audience` (e.g. `harbor`). Many IdPs only emit `aud` when the token\n   is requested for a registered API\u002Fresource — register one.\n3. **Note the issuer.** The IdP's issuer URL becomes `identity.issuer`; its JWKS\n   endpoint (usually `\u003Cissuer>\u002F.well-known\u002Fjwks.json` or\n   `\u003Cissuer>\u002Fprotocol\u002Fopenid-connect\u002Fcerts`) becomes `identity.jwks_url`.\n4. **Add the Harbor custom claims** — inject `tenant`, `user`, `session`,\n   `scopes` and, when the client must address an agent, `agent_reach` as\n   **top-level** claims on the access token. When the client must be\n   pinned to a bounded set of conversations, add `session_reach` the same\n   way — a client minted with it can select ONLY those sessions per\n   request (`X-Harbor-Session` outside the set is refused 403). This is the\n   per-provider step the guide's snippets cover: an Auth0\n   **Client-Credentials Action**, Okta **authorization-server Claims**, a\n   Keycloak **client-scope Mapper**, or a Cognito **Pre-Token-Generation\n   Lambda**. Auth0 namespaces claims by URL by default — Harbor reads\n   **unprefixed** top-level claims, so emit them bare.\n5. **Confirm the signing algorithm** is one Harbor allows — RS256 (the OIDC\n   default) or any of RS384\u002FRS512\u002FES256\u002FES384\u002FES512. `HS*` and `none` are\n   rejected. List it in `identity.jwt_algorithms`.\n\nThe four worked provider snippets (Auth0 \u002F Okta \u002F Keycloak \u002F Cognito) live in\nthe [setup guide](https:\u002F\u002Fhurtener.github.io\u002FHarbor\u002Fprotocol\u002Fproduction-identity-setup#on-ramp-a-register-an-oidc-app).\n\n## B. No IdP — the `harbor token` self-issuing on-ramp\n\nStanding up an IdP is **not** a prerequisite for `harbor serve`. The cliff was\nnever \"you must buy an IdP\" — it was that the self-issuing path was undocumented.\n`harbor token` closes it: generate a keypair, point `serve`'s\n`identity.jwks_file` at the public half, mint Harbor JWTs with the private half.\n\n```bash\n# 1. Generate a keypair + the matching public JWK Set.\nharbor token keygen --out .\u002Fidentity --alg ES256\n\n# 2. Point serve at the public JWKS (instead of jwks_url):\n#    identity.jwks_file: .\u002Fidentity\u002Fjwks.json\n#    identity.issuer:    https:\u002F\u002Fharbor.internal     (your chosen values)\n#    identity.audience:  harbor\n\n# 3. Mint a token whose iss\u002Faud MATCH serve.yaml exactly.\nharbor token mint --key .\u002Fidentity\u002Fprivate.pem \\\n  --tenant tenant-acme --user user-1 --session default \\\n  --issuer https:\u002F\u002Fharbor.internal --audience harbor \\\n  --scopes console:fleet --agent-reach support-agent --ttl 1h\n```\n\n`--issuer` \u002F `--audience` are mandatory and **must equal** `identity.issuer` \u002F\n`identity.audience`. Mint with no `--scopes` for a least-privileged token.\nMint with no `--agent-reach` unless the bearer must use `control.start`, the\nagent-config session\u002Fuser data plane, or `tools.describe` with an explicit\n`agent_id`; tenant-local configuration never grants this authority.\nMint with no `--session-reach` unless the bearer must be pinned to a bounded\nset of conversations: `--session-reach` narrows the bearer to exactly those\nsession IDs per request (`X-Harbor-Session` outside the set, or an SSE\n`?session=` outside it, is refused 403), while omission preserves D-171's\ndynamic per-request session selection.\n\n> **Honesty note — know what grade this is.** Self-issued tokens are signed by a\n> key **you** manage — protect the private key (it is the entire trust root),\n> keep it out of version control, rotate it deliberately. This is legitimate for\n> an eval, a single-tenant deployment, or a self-hosting operator who is their\n> own issuer. It is **not** multi-user SSO — no central revocation, no login UI,\n> no per-user lifecycle. When you need those, graduate to on-ramp A; the two\n> share `serve`'s verifier exactly, so moving between them is a config change,\n> not a re-architecture.\n\n## 4. Mint-and-test — prove the loop before shipping a client\n\n1. **Boot `serve`** against your identity config:\n\n   ```bash\n   harbor serve --config serve.yaml\n   ```\n\n   It prints that it mints no token and verifies against your JWKS. A\n   misconfigured `issuer`\u002F`audience`\u002FJWKS exits non-zero with a named-field error\n   — fix that first.\n\n2. **Obtain a token** the way a backend client will (client-credentials grant\n   for on-ramp A, or `harbor token mint` for on-ramp B).\n\n3. **Decode and eyeball it** — catches the `iss`\u002F`aud`\u002Fclaim-shape mismatch\n   before the network does:\n\n   ```bash\n   # JWT payloads are base64url; translate before decoding.\n   echo \"$TOKEN\" | cut -d. -f2 | tr '_-' '\u002F+' | base64 -d | jq .\n   ```\n\n   Confirm `iss`, `aud`, `tenant`, `user`, `session`, and `scopes` are present\n   and exactly match `serve.yaml`. A self-consistent config that mints the wrong\n   `aud` looks fine until the first `401`.\n\n4. **Handshake with `runtime.info`** — the first call any client makes:\n\n   ```bash\n   curl -sS -X POST https:\u002F\u002Fyour-harbor-host:8080\u002Fv1\u002Fcontrol\u002Fruntime.info \\\n     -H \"Authorization: Bearer $TOKEN\" \\\n     -H \"X-Harbor-Session: default\" \\\n     -H \"Content-Type: application\u002Fjson\" \\\n     -d '{\"identity\": {}}'\n   ```\n\n   A `200` with `instance_id` \u002F `protocol_version` \u002F `capabilities` means the\n   token verified and the triple resolved. A `401 auth_rejected` means\n   verification failed (almost always `iss`\u002F`aud`\u002F`exp`\u002F`alg` or a `kid` the JWKS\n   doesn't hold); a `401 identity_required` means a triple claim was missing or\n   empty.\n\n5. **Attach a real client.** The same token + the `X-Harbor-Session` header drive\n   every method — the same wire [`use-the-harbor-protocol`](..\u002Fuse-the-harbor-protocol\u002FSKILL.md)\n   walks. A complete, SDK-free worked OIDC client ships under\n   `examples\u002Fprotocol-clients\u002F`, doing exactly this flow against `serve`.\n\n### Co-launch the TUI from `harbor serve --tui`\n\nThe native terminal client can co-launch from the serve binary itself — one\nprocess, one terminal, no separate `harbor tui --attach` step. The serve\nposture is unchanged: the TUI attaches through authenticated REST\u002FSSE using the\noperator's JWT, receives no Runtime handle, and quitting drains the owned\nserver.\n\n```bash\nexport HARBOR_TOKEN='\u003Cyour-signed-jwt>'\nharbor serve --config serve.yaml --bind 127.0.0.1:0 --tui\n```\n\nThe same token + `iss`\u002F`aud` exact-match contract above applies: `serve`\nverifies the JWT against your JWKS, the TUI resolves it from `HARBOR_TOKEN` or\n`~\u002F.harbor\u002Ftoken`, and every REST request + SSE reconnect re-reads the\ncredential. Runtime logs go to a captured sink so Bubble Tea frames are never\noverwritten. See [`drive-the-harbor-tui`](..\u002Fdrive-the-harbor-tui\u002FSKILL.md)\n§2 \"Co-launch from `harbor serve --tui`\" for the keyboard surface and the\nco-launch lifecycle.\n\n## Common failure modes\n\n- **Every call 401s with `auth_rejected`.** The `iss`\u002F`aud` exact-match contract\n  (§3) — your token's strings don't equal `serve.yaml`'s. Decode the token (step\n  3) and compare character-for-character.\n- **`serve` exits non-zero at boot.** `issuer` or `audience` is empty, or the\n  JWKS is unreachable — the production profile mandates both. The error names the\n  field.\n- **`401 identity_required`.** A triple claim (`tenant` \u002F `user` \u002F `session`) is\n  missing or empty in the token. Most often the IdP isn't emitting the custom\n  claims — re-check the per-provider claim step in §A.4. An empty `session` is\n  rejected the same as an empty `tenant`.\n- **`alg` rejected.** Your IdP signs with `HS256` (or the token uses `none`).\n  Harbor allows asymmetric only — switch the signing key to RS*\u002FES* and list it\n  in `identity.jwt_algorithms`.\n- **`kid` not found.** Your JWKS holds more than one key and the token's header\n  `kid` doesn't select one — make sure the IdP stamps the `kid` and `serve`'s\n  JWKS carries the matching key.\n\n## See also\n\n- [Production identity setup guide](https:\u002F\u002Fhurtener.github.io\u002FHarbor\u002Fprotocol\u002Fproduction-identity-setup)\n  — the full manual this skill operationalizes (per-provider snippets, the scope\n  vocabulary, the complete `harbor token` walkthrough).\n- [`use-the-harbor-protocol`](..\u002Fuse-the-harbor-protocol\u002FSKILL.md) — once you\n  have a token, this is the wire: `runtime.info`, `start`, the events stream.\n- [`run-the-dev-loop`](..\u002Frun-the-dev-loop\u002FSKILL.md) — the `harbor dev`\n  ephemeral-token path you're graduating from.\n- [`validate-and-package`](..\u002Fvalidate-and-package\u002FSKILL.md) — the rest of the\n  production checklist.\n- [`examples\u002Fserve.yaml`](..\u002F..\u002F..\u002Fexamples\u002Fserve.yaml) — the annotated\n  production `serve` config.\n",{"name":5,"license":15,"metadata":24,"description":7},{"verbs":25,"surface":26,"framework":27},"serve, token, tui","protocol","harbor","main","d5e4bebc2516f8ae118156a73921760e3089c169","docs\u002Fskills\u002Fconfigure-production-identity",1,"https:\u002F\u002Fgithub.com\u002Fhurtener\u002FHarbor","https:\u002F\u002Fgithub.com\u002Fhurtener","2026-11-15 04:22:13",{"source":8,"skillName":5,"commands":36,"prompt":46,"zipAvailable":47},[37,40,43],{"tool":38,"command":39},"npx","npx skills add hurtener\u002FHarbor --skill configure-production-identity",{"tool":41,"command":42},"bunx","bunx skills add hurtener\u002FHarbor --skill configure-production-identity",{"tool":44,"command":45},"pnpm","pnpm dlx skills add hurtener\u002FHarbor --skill configure-production-identity","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fhurtener\u002FHarbor\u002Fblob\u002Fmain\u002Fdocs\u002Fskills\u002Fconfigure-production-identity\u002FSKILL.md",true,[],[50,57,64],{"skillNo":51,"name":52,"nameZh":6,"description":53,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":54,"categories":55,"keywords":56,"publishedAt":19},"01M723DQGY2XG21CKBXW4WT1XZ","define-the-agent-yaml","Walk every field in `harbor.yaml` — REQUIRED (identity + llm), COMMON (planner \u002F memory \u002F state \u002F tools \u002F skills \u002F governance), ADVANCED (server \u002F telemetry \u002F artifacts \u002F events \u002F sessions \u002F tasks \u002F distributed). Use when editing the agent config beyond the scaffolded defaults.","https:\u002F\u002Fgithub.com\u002Fhurtener\u002FHarbor\u002Fblob\u002Fmain\u002Fdocs\u002Fskills\u002Fdefine-the-agent-yaml\u002FSKILL.md",[],[],{"skillNo":58,"name":59,"nameZh":6,"description":60,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":61,"categories":62,"keywords":63,"publishedAt":19},"01M723EKT6XDRQ2S3DME9R9564","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.","https:\u002F\u002Fgithub.com\u002Fhurtener\u002FHarbor\u002Fblob\u002Fmain\u002Fdocs\u002Fskills\u002Fuse-the-harbor-protocol\u002FSKILL.md",[],[],{"skillNo":65,"name":66,"nameZh":6,"description":67,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":68,"categories":69,"keywords":70,"publishedAt":19},"01M723DFPMG9520YQ86NGW492G","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 \u002F in-runtime generator) the planner can search and inject.","https:\u002F\u002Fgithub.com\u002Fhurtener\u002FHarbor\u002Fblob\u002Fmain\u002Fdocs\u002Fskills\u002Fconfigure-memory-and-skills\u002FSKILL.md",[],[]]