key-cape/docs/native-authentication.md
tegwick 2a8735173b
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s
Ship the live-registration check both blocked tasks depend on
KEY-WP-0013-T02 and KEY-WP-0014-T04 stay blocked on custody and on ops-warden,
but each contains a KeyCape-owned piece that had been left as prose. T02 requires
proving "live JWKS verification and denied excess scopes without logging values";
T04 step 4 requires verifying a rotated secret, refusing its predecessor and
refusing excess scope. Both were describable and neither was runnable, so the
proof would have been improvised by hand against production at the moment custody
lands -- the worst possible time for it.

keycape verify-client does it in one command. Per registration it checks
discovery origin, the client_credentials exchange and its RS256 signature against
the deployed JWKS, exact sub/tenant/roles/principal_type, that every -deny-scope
is refused, and that the token carries no scope that was not requested. That last
check is a real gap: the caller commands prove every requested scope was granted,
never that nothing extra came back. -previous-secret-env additionally requires
the predecessor to be refused, and treats an unchanged secret as a rotation that
did not happen.

Nothing is written to disk and no value is printed. Failures name the claim, not
the observed value, so running this against production cannot turn a verification
into a disclosure. Every check runs before it reports, so one failure does not
hide the rest.

The exact invocation for each approval client is recorded in the verification
block of the provisioning packet, so custody admission hands back a command
rather than a description.

Tests cover the passing case, an over-broad registration, a live predecessor, an
identical rotation and output non-disclosure; neutering mustFail makes the suite
fail, so the checks have teeth.

Also recorded in KEY-WP-0014: read from ops-warden's catalog rather than waiting
for a reply, key-cape-oidc-login is asked-and-waiting on us since 2026-08-28 and
is a pointer lane with no programmatic consumers, and the rapp-qonto-keycape-client
blocker citing an absent native exchange command went stale when service-token
shipped on 2026-09-05.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 14:30:43 +02:00

6.5 KiB

Native caller authentication

KeyCape owns the keycape service-token and keycape login commands. They produce issuer-verified JWTs in a new mode-0600 JSON file outside Git worktrees. Existing files and symlinks are refused. Token responses and provider error bodies are never printed. Commands exit nonzero on verification or delivery failure and remove incomplete output files.

Service exchange

Have the existing custody mechanism inject the registered client secret into a named environment variable, then run:

keycape service-token --issuer https://kc.coulomb.social \
  --client-id rapp-qonto --scope qonto:read \
  --secret-env KEYCAPE_RAPP_QONTO_CLIENT_SECRET \
  --out /private/runtime/qonto-token.json

The output directory must already exist. No secret belongs on argv. The command uses client_secret_basic with OAuth form encoding. The server must include the matching decoding fix for secrets containing reserved characters. The Qonto client ID and scope above follow the deployed KEY-WP-0006 registration.

For approval-engine clients, set --audience approval-engine explicitly; the expected audience otherwise defaults to the client ID. Both requested scopes and audience must match the issuer's static registration. The output contains access_token, token_type, and expires_in; a service exchange has no ID token. Re-exchange before expiry for renewal. There is no refresh token or credential cache. Client disablement stops new issuance; already-issued JWTs expire normally.

Browser login

Use a registered public PKCE client with an exact literal-loopback callback:

keycape login --issuer https://kc.coulomb.social \
  --client-id APPROVER_CLIENT_ID --audience approval-engine \
  --scope 'openid approval:approve' \
  --redirect-uri http://127.0.0.1:REGISTERED_PORT/callback \
  --out /private/runtime/approver-token.json

Replace the client ID and port with the approved registration. This command prints a browser authorization URL and waits up to five minutes. The provider handles password and MFA; KeyCape CLI never receives those credentials. Configure mfaRequired: true on the approver registration. State, nonce and S256 PKCE are fresh for each attempt. The listener binds before the URL is printed, validates callback host/path/state, and closes when the attempt ends.

Both tokens are verified against same-origin HTTPS discovery/JWKS. Access tokens bind the expected resource audience and requested scopes. ID tokens bind the client ID, nonce and matching subject. There is no insecure TLS switch and no HTTP redirect following during credential exchange. Cross-origin discovery endpoints and confidential browser clients are intentionally unsupported.

OpenBao login boundary

The existing ops-warden login route runs bao login -no-print -method=oidc -path=netkingdom role=<domain>. That produces an OpenBao token, unlike KeyCape's new issuer JWT output. These commands are not interchangeable. KeyCape owns browser authentication and issuer JWTs; OpenBao's role mapping, token store and enforcement remain platform-owned. Do not replace that route with keycape login without adapting and verifying its consumer contract. No ops-warden route is retired by this change.

Qonto rotation boundary

Native exchange is implemented. Rotation still needs a coordinated custody and provider update, not a wrapper around a raw KV read. The reviewed sequence is:

  1. Resolve exact existing authority for platform/workloads/rapp-qonto/keycape-client, field client_secret, and sso/keycape-rapp-qonto-client, key client-secret.
  2. Generate the successor inside the approved custody execution transport; retain the predecessor only there for bounded rollback and denial checks.
  3. CAS-update that KV field while preserving siblings, update the matching Kubernetes delivery reference, and restart KeyCape in the agreed window.
  4. Verify the new qonto:read exchange, predecessor rejection and excess-scope denial without exposing tokens; verify downstream readiness.
  5. On failure, reconcile both custodians to the same version and reload KeyCape before declaring rollback complete. Record versions and outcomes, never values.

No general rotation command is shipped until that cross-owner transaction has an admitted execution and rollback contract. Calling secrets-engine's KV rotation alone would leave the provider and consumers inconsistent.

Steps 1-3 are custody's and are not automated here. Step 4, and the denial checks step 5 depends on, are KeyCape's and now have a command.

Verifying a live registration

keycape verify-client proves a deployed registration behaves as its contract says, without writing a token file or printing any value. It is the evidence for a rollout (KEY-WP-0013-T02) and for step 4 of the rotation sequence above.

keycape verify-client \
  -issuer https://kc.coulomb.social \
  -client-id secrets-engine-approval \
  -audience approval-engine \
  -scope "approval:read approval:consume" \
  -secret-env KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET \
  -expect-subject service:secrets-engine \
  -expect-tenant tenant:platform \
  -expect-roles secrets-engine \
  -deny-scope "approval:approve approval:revoke"

It checks, and prints one PASS/FAIL line per check:

  • discovery resolves over HTTPS and every endpoint shares the issuer origin;
  • the client_credentials exchange succeeds and its access token verifies RS256 against the discovered JWKS, with exact issuer, audience and validity window;
  • principal_type is service, and sub, tenant and roles match what the registration declares — exact comparison, no alias;
  • the token carries no scope that was not requested, which the caller commands do not check: they prove every requested scope was granted, not that nothing extra came back;
  • every -deny-scope is refused. A success there is the failure.

Add -previous-secret-env after a rotation to require that the predecessor is refused. It also fails if the predecessor and current values are identical, which means no rotation occurred.

Every check runs before the command reports, so one failure does not hide the rest; the exit status is non-zero if any failed. Failures name the claim, never the observed value — running this against production must not turn a verification into a disclosure. Nothing is written to disk.

The command needs the client secret in the named environment variable, so it runs wherever custody already delivers that value. It never reads OpenBao or Kubernetes itself.