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 .