Identity provider: Zitadel¶
Bronzeward is a generic OIDC relying party. Zitadel is the reference provider, at the release
#281 checked live: v4.19.5, pinned by digest as
ZITADEL_IMAGE in fixtures/versions.env. The
live checks are the evidence for
every behaviour below. Another release, or another provider, needs those checks repeated. This page
sets no credential policy: the lifetimes are yours to choose, within the limits stated.
Host names are synthetic: the site is https://bronzeward.example.com and the issuer
https://idp.example.com. Every value in angle brackets is your own.
What Bronzeward needs from a provider¶
From client contracts §9.1 and §15:
- JWT access tokens whose
audholds the server'sauth.oidc.audience. They also need ajti, and a lifetime no longer thanauth.oidc.maxTokenLifetime(15 minutes by default). - A
groupsclaim that is an array of strings. The server refuses a token whose groups claim is anything else. - Two public clients, one for the CLI and one for the web interface, each with the
authorization-code grant, PKCE
S256and no client secret. The CLI client also has the device-authorization grant (decision D4 (a)). - Refresh tokens that rotate on every use and are bound to the client that received them.
- Cross-origin calls to the token endpoint from the web origin.
1. Instance OIDC settings¶
Under the instance's OIDC settings (PUT /admin/v1/settings/oidc), the values the fixture runs
with (fixtures/dev-idp):
{"accessTokenLifetime": "300s", "idTokenLifetime": "300s",
"refreshTokenIdleExpiration": "600s", "refreshTokenExpiration": "3600s"}
accessTokenLifetimemust not exceedauth.oidc.maxTokenLifetime. It also bounds how long the server accepts a token after sign-out (see the consequences below).refreshTokenExpirationis the absolute limit on renewal, however active the session is. Keep it short. It is the only bound on a copied refresh token (consequence 1). #281 saw it enforced: at 600 s, renewal every 60 s worked to t+550 s and was refused from t+610 s.refreshTokenIdleExpirationends a session nobody renewed: refused after 130 s idle at 120 s.
2. The project and its roles¶
Create a project with projectRoleAssertion on. Add one role per group that auth.roles maps,
and grant each person the roles they hold. The fixture's role keys are bw-viewers,
bw-authors, bw-publishers, bw-approvers and bw-recovery, as
examples/bronzeward.yaml maps them. The project's ID is the
audience.
3. The two applications¶
In the project, register the CLI as a Native OIDC application:
{"name": "bronzewardctl", "appType": "OIDC_APP_TYPE_NATIVE", "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"redirectUris": ["http://127.0.0.1/callback"], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN", "OIDC_GRANT_TYPE_DEVICE_CODE"],
"accessTokenType": "OIDC_TOKEN_TYPE_JWT"}
Register the web interface as a User Agent application:
{"name": "bronzeward-web", "appType": "OIDC_APP_TYPE_USER_AGENT", "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"redirectUris": ["https://bronzeward.example.com/callback"],
"postLogoutRedirectUris": ["https://bronzeward.example.com/signed-out"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"accessTokenType": "OIDC_TOKEN_TYPE_JWT"}
accessTokenType JWT is required: the server verifies tokens locally and cannot use opaque ones.
Leave devMode off. The fixture turns it on only so that the web client can use a plain-http
redirect. Zitadel returns each application's client ID.
4. The groups claim¶
Zitadel's own role claim is an object, and no scope adds a groups array, so a token from it alone is refused. An Actions v2 target adds the claim:
- Run a small HTTP service that answers Zitadel's
preaccesstokencall. It readsuser_grants[](each withproject_idandroles; absent for a user with no grant), and answers{"append_claims":[{"key":"groups","value":[<role keys in your project>]}]}. Before answering, it checksZitadel-Signature: t=<unix>,v1=<hex>: the HMAC-SHA256 of<t>.<body>with the target's signing key. It refuses a bad signature or a stale timestamp.fixtures/oidc/groupsis a tested implementation of this contract, built for the fixture. It is not a shipped component. - Create the target (
POST /v2/actions/targets) withrestCall.interruptOnErrorset to true. Keep the signing key that Zitadel returns. - Bind it to the function (
PUT /v2/actions/executions, condition{"function":{"name":"preaccesstoken"}}).
5. The server and the clients¶
In the server's configuration (Configure):
auth:
oidc:
issuer: https://idp.example.com
audience: "<project ID>"
groupsClaim: groups
maxTokenLifetime: 15m
bronzeward-client.json at the web root (hosting):
{"issuer": "https://idp.example.com", "audience": "<project ID>",
"cliClientId": "<CLI client ID>", "webClientId": "<web client ID>",
"scopes": "urn:zitadel:iam:org:project:id:<project ID>:aud"}
Zitadel puts the project ID into aud only when the client asks for that scope. Without it, the
server refuses every token.
bronzewardctl context add --from does not import scopes yet, and the CLI's own sign-in is still
being implemented (ginsys/bronzeward#254). Until
both land, add a CLI context by hand:
bronzewardctl context add prod --server https://bronzeward.example.com --issuer https://idp.example.com \
--client-id "<CLI client ID>" --audience "<project ID>" --scopes "urn:zitadel:iam:org:project:id:<project ID>:aud"
Consequences of Zitadel's behaviour¶
Each line is a #281 observation, with what it means for a deployment.
- A replayed refresh token does not end the session (check 3). Zitadel refuses a token that
was already used (
400 invalid_request, notinvalid_grant) but revokes nothing. Whoever renews a copied token first keeps that session until the idle or absolute limit. The legitimate client is refused and must sign in again. Only a shortrefreshTokenExpirationbounds this. - Any origin is allowed cross-origin (check 10). The token endpoint echoes every
Origin, with credentials, and Zitadel has no per-application origin list. The protection is PKCE and the binding of each refresh token to its client: the CLI's token is refused at the web client ID and the reverse (400 invalid_client, check 4). Do not count on the provider to limit origins. - Sign-out does not reach the server at once (check 11). Revocation and end-session end
Zitadel's session. The server verifies tokens locally, though, so it accepts an access token
until its
expplus 60 s of leeway (internal/auth/auth.go:95): up to 6 minutes with 300 s tokens. To lock a person out at once, arecovery-adminrevokes the identity in Bronzeward (persistence and API §10.4). List the subject inauth.deniedSubjectsbefore that, so a database restore cannot undo it. - End-session leaves the refresh token valid. The web interface's sign-out therefore revokes the refresh token itself (§9.7 item 2). Never block the revocation endpoint.
- The idle and absolute limits are enforced (check 5). The absolute limit ends renewal, not the last access token issued before it, which lives its full lifetime.
- The groups target is on the sign-in path (check 7). With
interruptOnError, a target that is down makes the token endpoint answer500 server_error, and no token is issued, so nobody signs in or renews. That is safer than a token without groups, but the target needs the same availability as Zitadel. - Zitadel refuses a target on a loopback or private address (
Errors.Target.DeniedURL). It also refuses one whose host name does not resolve yet. Its defaultHTTPClient.DenyListcovers every loopback and private range. The fixture replaces it to reach its own container. That is a test-only override: in a deployment, give the target an address the default allows, and start it before creating the target. - The CLI's loopback redirect works on any port (check 9). Registering
http://127.0.0.1/callbackonce covers every port, withdevModeon or off. Another path is refused. - A device code lives 5 minutes, polled every 5 s (check 1). An unapproved code answers
authorization_pendinguntil it expires (expired_token).
Limits of this evidence: the checks ran on one host, over plain http on loopback, under the
external name localhost. Behaviour behind TLS or under another external name was not observed.