Skip to content

Policy: clients, resources and self-described clients

The clients, exchange_limits, resources and client_documents tables of the policy. Decided in ADR 0001, 0033, 0039, 0040.

Clients

A client's id is the aud, unless the request named a resource.

Rule Value
requires mandatory; an empty list means nobody; sluis refuses to start on one
A caller in no requires group refused before a token exists
Token exchange target's requires decides, and an exchange limit can narrow it; each proof is checked against its own issuer's keys
Kind Used by Secret
public kubelogin, sluisctl, Kargo, local-dev none
confidential ArgoCD, Grafana, a console behind oauth2-proxy named, or generated
exchange AWS roles reached by token exchange none
Key Meaning
kind public, confidential or exchange
secret required for confidential: a name, or {generate: true}
redirects paths that start a sign-in
signed_out pages allowed after RP-initiated logout; an address in both lists fails the load; an exchange client may declare none
requires internal groups, any one admits
ttl_cap upper bound on token lifetime
session interactive (default) or agent; refused on exchange; see agent-class sessions
pkce required or optional; see PKCE per client
harness true marks a client the conformance test account may sign in to (conformance.enabled); refused on exchange. Not gitops's harness (no portal card): gitops must pass it into the sluis policy
sign_in_exchange true lets a sign-in be a token-exchange proof (access_token) while its session lives; public only
display_name, description sign-in page text
backchannel_logout_uri receives a signed logout+jwt naming the sid when a sign-in ends; unusable behind oauth2-proxy
groups groups override
groups_delimiter rewrites :
signing_alg pins the algorithm

PKCE

kind pkce absent pkce: required pkce: optional
public required accepted (the same) refused at load
confidential optional required optional
exchange none refused at load refused at load
Where A required client
/authorize without an S256 code_challenge invalid_request before any sign-in page, as for a public client
/authorize with code_challenge_method=plain, or a code_challenge with no method (which is plain, RFC 7636 4.3) invalid_request, for every client
Log each /authorize leaves one info line, authorization request, with pkce (true or false), pkce_method when there is a challenge, and client_id; never the challenge
/token for a code issued against a challenge, without code_verifier invalid_grant
Client document public, so always required
Confidential client that sent a challenge held to the verifier whatever its pkce (RFC 7636 4.6)
Absent on a confidential client optional: the OpenID Basic OP conformance clients send no challenge
Digest pkce is in the policy digest
Advice required on every confidential client that can send a challenge: OAuth 2.1, RFC 9700 2.1.1

A generated secret

secret shape Meaning
a string input clients/<id>/secret, delivered by the installation
{generate: true} issuer makes 32 random bytes (base64url, no padding)
Fact Value
Record credentials/oidc-client/<id>/secret in the Secrets port: current, previous, previous-valid-until
Write create-only; never overwritten; an existing input clients/<id>/secret is adopted
Read order record first, input only while the store has no record
Cache 30 s; a record read earlier is served up to 5 min when the store fails
Compare current and previous on every request, constant time
Corrupt or unreadable record authenticates nobody
Refused at start generate: false; public or exchange client; Secrets adapter legacy; State not shared by replicas (memory excepted)
Older binary refuses the object form; roll every replica first
Client no longer generated record reported once as orphan; never deleted
Delivery oidc/v1 document at external/oidc/<client>
Rotation sluisctl clients

How: let the issuer generate a secret, rotate a client secret.

What the sign-in page calls a client

The row declares The page says
display_name that name
no name, id k8s:<cluster> Kubernetes, <cluster>
no name the client id
Fact Value
Host shown from the matched redirect URI; text, never a link
Loopback redirect a program on this computer, no port
Visibility both fields are public before sign-in
Refused name over 80 characters; description over 200; blank; control, line-break, tab or bidirectional character
Output HTML-escaped
Older build refuses both keys as unknown; deploy the service first

Exchange limits

exchange_limits:
  - match: { aws: { account: "111122223333", role: "sluis-*", path: "/" } }
    clients: [otlp]
  - match: { service_account: { cluster: east, namespace: sluis, name: sluis } }
    clients: [otlp, grafana]
Rule Value
Effect a proof match matches is exchanged for its clients and no other audience
match one matcher, any kind; read against the proof, never the directory
clients at least one declared client id, each once
Several limits match the union of their clients
No limit matches unchanged: the client's requires decides
A listed client still needs its requires to admit the proof; a limit grants nothing
Refused exchange invalid_target, the same answer as an unknown audience; audit reason_code exchange_limited
Applies to the token exchange and the console mint; not a browser sign-in
Refused at load an undeclared client; an empty clients; a client twice; a matcher a group would refuse
Across files limits add up; an identical limit is kept once
Rationale why an identity can be limited

Resources

resources:
  https://mcp.example/:
    requires: [prod:k8s:admin]
    ttl_cap: 15m
    read_only: true       # lets absolute_cap exceed the service's lifetimes.absolute
    absolute_cap: 168h

A client names a resource with the resource parameter (RFC 8707); aud is then the resource.

Rule Value
Keys requires, ttl_cap, display_name, description, groups, groups_delimiter, signing_alg, read_only, absolute_cap
Gates caller satisfies the client's requires and the resource's
Caps both apply; the shorter wins
Id matched literally; absolute URI, no fragment; requires names declared groups
Recorded with the session; re-checked on every refresh
A client asks Result
nothing token for the client
declared, entitled resource aud is the resource, capped by both, same on every refresh
undeclared resource invalid_target
resource not entitled refused at sign-in, naming the resource and group
several resources refused
relative URI or fragment refused

Absolute session of a read-only resource

Deprecated: use agent-class sessions.

Field Meaning
absolute_cap this resource's absolute limit in place of lifetimes.absolute; at most 168h
read_only declarer's claim that the token changes nothing; unverified by the service
Access document readOnly, absoluteCap
Rule Value
Default limit lifetimes.absolute, 24 h from auth_time
Refused at load absolute_cap above 168h, zero or negative
Refused at start absolute_cap above lifetimes.absolute without read_only: true
Cap below lifetimes.absolute needs no read_only
Warning at start names resources above lifetimes.absolute; a later minor release refuses them
Chain limit shortest across resources used; client audience and cap-less resources count as lifetimes.absolute
Enforced at refresh, silent /authorize, access token exp
Chain ends earlier of now + lifetimes.refresh and auth_time + limit
lifetimes.refresh default 12h; raise to 168h to survive a closed laptop
Ended by sign-out, removal, suspension, refresh-token reuse
Migrate set session: agent on those clients; lower or remove absolute_cap

Agent-class sessions

Declare session: agent for software that holds its own refresh token. Concepts: sessions.

clients:
  mcp-host:
    kind: public
    session: agent
    redirects: [/callback]
    requires: [rung:engineering]

client_documents:
  origins: [agents.example]
  requires: [rung:engineering]
  session: agent                  # every document client gets the class
Key under lifetimes.agent Default Ceiling Meaning
refresh 336h absolute idle limit
absolute 720h 2160h limit from auth_time
access 30m 1h longest access or ID token

All three are positive; shortening is allowed. Set them in the service configuration.

Rule Value
Shortening tokens absolute_cap only ceilings an agent chain; ttl_cap of client, client_documents and resource still apply
Class source installation policy only, never a fetched document
client_documents.session applies to every document client; declare a mixed set as clients rows
Refused at load session on exchange; session: agent with sign_in_exchange: true
Warned at start session: agent with signed_out or backchannel_logout_uri
Consent page once per authorization; names client, origin, return host, class, deadline
prompt=none consent_required
Own sign-out (/logout, /end_session) ends interactive sessions, keeps agent sessions
/end_session naming an agent client also ends that client's sessions
Ends agent sessions sign out everywhere; per-client revoke; operator revoke; directory removal; refresh-token reuse
Keeps every live chain a sign-in past its own limit; another person signing in in the same browser
Removing a client or origin stops chains (unknown at refresh) but does not end them; revoke sessions first

Groups override

groups on a client, resource or client_documents block sets which held groups a token carries beyond the pairs in its audience's requires. Rule: groups in a token.

Value Carries
absent the pairs of the audience's requires, any role
all every group the caller holds
[thing, ...] also every held group of each thing, any scope
[rung] or [emp] also every held name of that family
[rung:sre] also that one two-segment name
clients:
  console:
    kind: confidential
    secret: console-oidc
    requires: [devel:grafana:viewer]
    groups: [shop]
Validation Rule
No declared vocabulary value is all or a list of names
Declared vocabulary bare word must be a declared thing, rung or emp
Entry with a separator exact two-segment name; valid either way
groupsScoping service config key: off, report (default), enforce (configuration)

groups_delimiter

clients:
  ssh-fleet:
    kind: public
    requires: [devel:ssh:user]
    signing_alg: RS256
    groups_delimiter: "."   # devel:ssh:user -> devel.ssh.user
Rule Value
Effect rewrites every : in each name of the audience's groups claim, after scoping
Default empty: claim unchanged
Applies to ID token, access token, /userinfo, token exchange, console mint
Row read as for signing_alg
Unchanged which groups a caller carries; requires and groups: use real names
Refused at load empty; :; whitespace, " or ,; a character in [A-Za-z0-9-]
Refused at load a delimiter that makes two declared groups the same string; the message names both
Never use on an audience whose tokens this installation reads back: the service's own two roles split on :

Rationale: why a groups delimiter exists.

signing_alg

clients:
  eks-cluster: { kind: exchange, requires: [prod:k8s:admin], signing_alg: RS256 }
resources:
  https://legacy.example/: { requires: [prod:k8s:admin], signing_alg: RS256 }

Accepts RS256, ES256 or ES384; others are refused at load. A row without it gets the signing key's algorithm (ES384 in the chart).

Token Row read
ID token the client's
access token the named resource, else the client
token exchange (sluisctl kube-token, CI job) the granted target, never the presenting client
Back-Channel Logout token the receiving client
console mint (MintFor) that call's target

A pin with no matching key is refused at start. Extra keys: signingKey.additional (configuration). Rationale: why an audience can pin a signing algorithm.

Clients that describe themselves

client_documents:
  origins: [clients.example]       # hosts that may serve a document; empty (default) is off
  requires: [rung:engineering]     # gate for ANY such client; mandatory with origins
  ttl_cap: 5m                      # optional
  groups: [shop]                   # optional, as for a client row

A client presents an HTTPS URL as client_id. sluis fetches its OAuth Client ID Metadata Document and treats the client as public.

Rule Value
Precedence a declared client always wins
Gate one shared gate, client_documents.requires
Ignored in a document kind, ttl_cap, requires
Cache 10 min; one retry on timeout, reset or 5xx
Stale-while-error last good copy up to 1 h past expiry, transport failures only; warning logged each time
Never served stale refused answer: invalid document, redirect, 4xx, oversized body
Display name cleaned as a declared name is held to (no line breaks, controls, bidirectional overrides or formatting characters), cut to 64 characters on a character boundary; host shown when absent
Fetch public addresses only, no redirects, a failure remembered for 1 minute, at most 30 fetches a minute per host, which can delay a legitimate uncached document of a busy host (endpoints)
Audit records the URL
Discovery client_id_metadata_document_supported in discovery and /.well-known/oauth-authorization-server only while an origin is named
Refused Limit
document client_id differs from its URL
origin not allow-listed decided before any dial
response over 64 KiB or slower than 10 s per attempt
redirect elsewhere
no redirect_uris
token_endpoint_auth_method other than none, or a client_secret a document client is public
origins entry with a scheme, path or * origins are hosts
requires or ttl_cap without origins; origins without requires

Rationale: why a self-described client is proportionate.