Answers net-kingdom's NK-WP-0056-T14 review request (message dc359034). Field rules confirmed: the rendered record passes keycape check-config, and the token shape was observed on a local synthetic run (assurance is a fixed aal1/client_secret block). One correction: an excess scope returns invalid_profile_usage (HTTP 400, feature scope), not invalid_scope. Notes on the bind-env restart and its Secret precondition under Recreate, and on the rotation window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
8.4 KiB
Review: SecurityCaseType oidc-service-client 0.1
Reviewed 2026-10-31 by key-cape, the owner of the client field rules, at net-kingdom's
request (NK-WP-0056-T14, message dc359034, hub decision f8044f81). Sources:
net-kingdom security-case-types/oidc-service-client/type.yaml, its README, the
example case examples/nk-bind-executor-approval.yaml and the renderer
sso-mfa/k8s/keycape/keycape-client.py (service_client_from_params), all at
net-kingdom 5d5f15d. Compared with KeyCape src/internal/config/validate.go,
src/internal/config/config.go, src/internal/domain/model.go,
src/internal/server/oidc/token.go and src/cmd/keycape/{main,checkconfig}.go
at key-cape a140a4a.
Verdict: the field rules are correct; one correction to the type (R3) and two notes for its attended steps (R4, R5). No finding blocks KeyCape from starting.
How this was checked (no live system)
- The renderer turned the example case into a client record. That record went into
a copy of
config/dev-config.yamlas the only client. A locally builtkeycape check-configaccepted it (valid: true,required_env: [KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET]). With--env-namesmissing that name, check-config refused it. - The renderer reproduces
config/service-clients.example.yaml'ssecrets-engine-approvalrecord exactly from the equivalent parameters. - A local KeyCape ran on the dev config with a synthetic secret in the env variable. Three client_credentials exchanges were made against it. The results are in R2 and R3.
- The same binary, started without the env variable, exited with
client "nk-bind-executor-approval" secret environment variable ... is empty/failed to build client registry.
Findings
R1: the rendered client record matches KeyCape's model and passes check-config.
(confirmed)
For the example case the renderer emits clientId, displayName, audience,
allowedScopes (a list), grantTypes: [client_credentials], clientType: confidential, secretRef: env:KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET,
serviceSubject, tenant, roles (a list, omitted when empty) and tokenLifetime.
Every key is a field of config.ClientConfig. KeyCape requires these for
client_credentials: confidential, an env: secretRef, a non-empty serviceSubject
and tenant, and a lifetime between 1m and 1h. Redirect URIs are not required. The
type is stricter than KeyCape in each respect, which is allowed:
- resource scopes only;
service:andtenant:prefixes;- one audience token without whitespace;
- 5m, 10m or 15m only.
The env name maps
-to_and admits no_inclient_id, so no two ids share an env name. The patternservice:[a-z0-9-]+does not fit two existing clients (secrets-engine-requesterusessecrets-engine, andcodex-railiance-platformusesservice:codex:railiance-platform). That only matters if those clients are adopted later. The README already limits the fit claim tosecrets-engine-approval.
R2: shape of a client_credentials token. (confirmed; this answers the assurance question) Observed from the local exchange for the example case:
{
"iss": "<issuer>", "sub": "service:nk-bind-executor", "aud": "approval-engine",
"iat": 1793479725, "exp": 1793480625,
"tenant": "tenant:platform", "tenant_source": "registration",
"principal_type": "service", "groups": [], "roles": ["bounded-executor"],
"scope": "approval:create approval:read approval:consume",
"assurance": {"level": "aal1", "methods": ["client_secret"], "mfa": false,
"source": "key-cape", "at": 1793479725}
}
audis a single string: the registeredaudience, or theclient_idwhen no audience is registered. The request cannot select it.scopeis a space-separated string. With noscopeparameter the token carries all registered scopes; with one, exactly the requested subset.exp - iatand the responseexpires_inboth equal the client'stokenLifetime(900 for 15m). No refresh token is issued.assuranceis a fixed literal for service tokens:levelis alwaysaal1andmethodsalways["client_secret"];mfais always false;atis the mint time, since a service has no separate authentication event. No configuration raises it. approval-engine'sidentity_from_claimsneeds only an object here, so the token is admissible. Any approval-engine rule that requires aal2 will, correctly, never be met by a service token. A consumer must useprincipal_type: service, not the assurance level, to tell service tokens from human ones.
jtiappears only when token status is enabled.tenant_rolesappears only when tenant-engine is configured and answers for the tenant. Neither is guaranteed.
R3: an excess scope returns invalid_profile_usage, not invalid_scope.
(correction to the type)
KeyCape refuses any requested scope outside allowedScopes with HTTP 400 and the
body {"error":"invalid_profile_usage","description":"requested scope is not allowed","feature":"scope"} (token.go, serveClientCredentials; the response was
observed for scope=approval:approve). The verification step excess-scope-refused
("KeyCape returns invalid_scope") and the README's threat model ("KeyCape refuses an
excess scope (invalid_scope)") name the wrong code. A literal check against them
would fail on a correct refusal. Fix: expect HTTP 400 with error: invalid_profile_usage and feature: scope, and correct the README. The refusal
itself is real, and the whole request fails: no token is issued for the allowed
subset. KeyCape follows its profile error taxonomy here rather than RFC 6749's
invalid_scope. Changing that is a separate key-cape decision and is not needed for
this type.
R4: bind-env restarts KeyCape on its own, and its Secret must exist first.
(note for the attended steps)
The type's order and its stated reason are correct. KeyCape resolves every env:
secretRef at start and exits if one is empty (buildClientRegistryWith). Running
register-client before bind-env therefore takes the only issuer down for every
client. The renderer guards against that: keycape-client.py --check-config passes
the Deployment's env names to keycape check-config --env-names, which refuses a
registry naming an unbound variable. Two points are missing from the attended
justification:
- Patching the Deployment env is itself a rollout. The Deployment has one replica
and
strategy: Recreate, so bind-env and register-client each cause a restart that drops in-flight logins for every client. That is two restarts per case unless both changes go in one rollout. - The env entry references Secret
sso/keycape-<client_id>-clientwith nooptional. If deliver-secret is not Ready when bind-env runs, the new pod fails with CreateContainerConfigError. Under Recreate the old pod is already gone, so sign-in fails for every client. deliver-secret's ready_check must pass immediately before bind-env in the same session, not just at some point beforehand.
R5: rotation breaks the consumer until KeyCape restarts. (note on Known limits) The README says that a rotation also restarts KeyCape. The order matters:
- The consumer reads the new value from OpenBao on its next call.
- KeyCape keeps comparing against the value in its environment at start, until both
of these happen: ESO refreshes
sso/keycape-<client_id>-client, and KeyCape restarts. - In between, every exchange by the consumer fails with 401 (
invalid client authentication). Rotation should therefore be: mint, wait for the ESO refresh to report Ready, restart KeyCape, and only then expect the consumer's exchanges to succeed. The window will show up as consumer errors. KeyCape has no dual-secret overlap.
Not a finding
- Identity scopes (
openid,profile,email,groups) are accepted by KeyCape on a client_credentials client, but they add nothing to the token. Excluding them in the type is correct and costs nothing. service-token-claimschecks sub, aud, tenant, scope and lifetime, and R2 confirms each. Addingprincipal_type: serviceto the expected claims would be cheap and matches what approval-engine checks.- The client secret format is opaque to KeyCape. KeyCape form-decodes both halves of
client_secret_basic, so a base64url secret needs no escaping. - Rollback order (registration removed before the env binding) mirrors the apply order correctly. Removing the env first would hit the same start-up exit as in R4.