0094. Ichor users, its own OIDC issuer, roles and per-user kubeconfigs

Status: Accepted · Date: 2026-10-27 · Design section: Trust, Secrets, UI backend, Deployment · Extends: 0058 · Touches: 0084, 0091, 0065 (override) Decided: Ichor keeps its own users as IchorUser CRs (namespace ichor-system, spec = display name, e-mail, roles[], disabled; never a password) with argon2id hashes in one Secret ichor-user-credentials, and ichor-ui becomes a minimal stateless OIDC issuer (hand-written on go-jose/v4, RS256, authorization code + PKCE for the browser and a loopback redirect for the CLI, 5 minute ID tokens, 12 hour non-rotating refresh tokens, signing keys kept and rotated by the controller in a Secret); the controller renders both Ichor's issuer (reached by the kube-apiserver through discoveryURL on a ClusterIP with an Ichor-made CA) and the existing external IdP as two jwt[] entries of Talos's KubeAuthenticationConfig document (Talos v1.14 contract and later), usernames ichor:<name> and oidc:<name>; permissions are plain Kubernetes RBAC: three built-in ClusterRoles ichor-viewer, ichor-operator, ichor-admin bound to the groups ichor:viewer|operator|admin, with new verbs for destructive operation starts, user administration and the trust configuration; a user's kubeconfig holds no secret and runs ichor auth token as a client-go exec plugin; no user ever receives a talosconfig, and the two paths that hand out Talos credentials (ADR 0091's CLI hand-off, ADR 0093's UI download) stay admin-only; the whole feature is off by default and the secrets-bundle admin certificate stays as override

Context

ADR 0058 lets ichor-ui verify an ID token from one external IdP and call Kubernetes as that user. It has no users of its own, so a site without an IdP has no way to give two people different rights, and nothing makes the cluster's kube-apiserver accept those people: the user's own kubectl still needs a certificate or a token someone minted by hand. The decision asked for: Ichor manages its own users and the external OIDC login keeps working beside them; permissions are roles; each user can get a kubeconfig with no secret in it; users never receive a talosconfig; one cluster only (self-managed, no hub mode).

Everything below was checked against the module cache: Talos pkg/machinery v1.14.3, k8s.io/apiserver v0.36.0, k8s.io/client-go v0.36.0, github.com/coreos/go-oidc/v3 v3.21.0, golang.org/x/crypto v0.58.0. Paths are relative to each module root.

Decision 1: users

CR. IchorUser (ichor.maesh.dev/v1alpha1, namespaced, only in ichor-system, scaffolded with kubebuilder create api). The name is the identity: lower-case DNS label, at most 63 characters, immutable, and it becomes sub and the Kubernetes user ichor:<name>. Spec: displayName, email (informational), roles []string (each ^[a-z][a-z0-9-]{0,30}$, at most 16), disabled bool. Status (written by the controller only): conditions Ready, PasswordSet. There is no password, hash, token or session field, so a user CR can live in Git next to the other CRs. A CR from Git without a password is a user who cannot log in (PasswordSet=False) until an admin sets one; that is the GitOps story, not a gap.

Hash. One Secret ichor-user-credentials in ichor-system, key = user name, value = JSON {"v":1,"hash":"$argon2id$v=19$m=65536,t=3,p=4$…","notBefore":"RFC3339","mustChange":bool}. argon2id with the parameters golang.org/x/crypto/argon2 itself gives as RFC 9106's second recommendation (argon2/argon2.go:29-33: time=3, memory=64 MiB, threads=4; IDKey at argon2.go:101). golang.org/x/crypto v0.58.0 is already in go.mod, as // indirect (go.mod:161); it becomes a direct requirement, nothing new to download. The PHC string carries the parameters, so a login with older parameters rehashes. 64 MiB per hash means ichor-ui admits at most two hashes at once (a semaphore; the rest wait), and a wrong or unknown user costs the same (verify against a dummy hash) so response time does not reveal which users exist. Minimum password length 12, maximum 128 bytes, no composition rules.

Who writes what. The UI backend and the CLI write the CR (as the caller, so Kubernetes RBAC decides) and the Secret entry (as ichor-ui's ServiceAccount, after a SelfSubjectAccessReview as the caller, ADR 0084's pattern). The controller never writes spec; it writes IchorUser.status, deletes Secret entries whose CR is gone, and owns the signing Secret (Decision 2). ichor-ui's Role gets get, update, patch on exactly the Secret named ichor-user-credentials (resourceNames); it cannot create other Secrets. A Secret entry changes through a JSON merge patch of one key, so two replicas do not overwrite each other.

Operations. UserService (Connect, proto/ichor/v1alpha1/user.proto): ListUsers, GetUser, CreateUser, UpdateUser (roles, disabled, display data), DeleteUser, SetPassword (admin, optional generated password returned once, sets mustChange), ChangeOwnPassword (old and new password; needs no RBAC, it is the user's own account), RevokeSessions (sets notBefore to now), GetMyKubeconfig (Decision 5), WhoAmI. RBAC for the admin ones: ichorusers verbs through the impersonated client; SetPassword and RevokeSessions additionally need verb update on subresource ichorusers/password. Changing a password sets notBefore, which kills every refresh token issued earlier. Disable is spec.disabled: true; nothing else is needed. Refusing to disable, delete or demote the last enabled admin is a guard in the RPC, not a security boundary (the override certificate exists).

First admin. ichor init --admin-user NAME (default admin; the flag --admin-password-file reads one instead) runs after Ichor is installed. It writes the IchorUser (role admin) and the Secret entry with mustChange:true, using the same package (pkg/ichorauth) ichor-ui uses, with the operator's own kubeconfig. A generated password (24 characters, crypto/rand) is printed once to stdout and stored nowhere; the first login forces a change. Without --ui-url (Decision 3) init creates nothing and users stay off.

Override. The secrets-bundle admin client certificate (O=system:masters, what ichor init writes into ~/.ichor/clusters/<name>/kubeconfig) and the Talos os:admin identity are untouched. They do not depend on any JWT authenticator, so a broken issuer, a stopped ichor-ui or a deleted user cannot lock the operator out of Kubernetes, and the controller dials Talos with the bundle, not through Kubernetes. The docs call this the override credential and say to keep it offline once users exist.

Decision 2: the issuer

Which component. ichor-ui, because it already authenticates browsers, is stateless, runs two replicas and has the login UI. It gets a second package internal/oidcissuer and two listeners: the existing one (port 8080) gains /.well-known/openid-configuration, /oidc/jwks, /oidc/authorize, /oidc/login, /oidc/token; a new HTTPS listener (port 8444) serves only the discovery document and the JWKS for the kube-apiserver (Decision 3). The login page is a route of the preactcn SPA (/login), not server-rendered HTML.

Library: hand-written on github.com/go-jose/go-jose/v4, not zitadel/oidc. go-jose v4.1.4 is already a direct requirement (go.mod:10) and go-oidc v3.21.0 (client side, unchanged) already uses it. zitadel/oidc v3 is the credible OP library, but it is not in go.mod or the module cache, and its OP is built on a Storage that persists authorization requests, codes, access tokens and refresh tokens and resolves them by lookup (CreateAuthRequest, AuthRequestByCode, SaveAuthCode, CreateAccessToken, TokenRequestByRefreshToken, RevokeToken, TerminateSession; docs: https://github.com/zitadel/oidc/blob/main/_autodocs/api-reference/op-storage.md). Behind two replicas that means a shared store, so every login and every five-minute token refresh would write to etcd, and the stateless backend of ADR 0058 would stop being stateless. The surface needed here is small and fixed (two clients, one flow, one refresh grant), so the hand-written issuer is stateless by design:

  • authorization code = a JWS signed by the issuer key (typ ichor-code+jwt, 60 s) carrying client_id, redirect_uri, sub, nonce, code_challenge, audience; the token endpoint checks PKCE S256 (plain is refused) and the exact redirect URI. Single use is enforced per replica from an in-memory jti set only; a replay to the other replica within 60 s still needs the code_verifier, which only the client holds. This is a stated weakening of strict OAuth, accepted.
  • the authorize request is carried to the login form as a signed 5 minute ticket, so no cookies and no server session exist and a cross-site POST has no ambient authority.
  • refresh token = a JWS (typ ichor-refresh+jwt) with sub, client, iat (session start) and exp (absolute lifetime). It is valid while the user exists, is not disabled and iat >= notBefore. It is not rotated: nothing has to be stored, and concurrent kubectl processes cannot race each other into a revoked token.
  • no implicit or hybrid flow, no password grant, no client secret, no dynamic registration, no userinfo, introspection, revocation or end-session endpoint (logout discards the tokens; "sign out everywhere" is RevokeSessions).
  • the risk of hand-writing a security component is covered by tests that run the real consumers against it: go-oidc's NewProvider and Verify, and k8s.io/apiserver/plugin/pkg/authenticator/token/oidc.New (the code the kube-apiserver runs; oidc.go:269) in an httptest TLS server, plus negative tests (wrong audience, expired, tampered, replayed code, wrong PKCE verifier, unknown redirect URI, disabled user, iat < notBefore).

Clients (fixed, configured in the chart, not CRs). ichor-ui: public, redirect URI exactly <externalURL>/auth/callback. ichor-cli: public, redirect http://127.0.0.1:<any port>/callback (RFC 8252 section 7.3: the server MUST allow any port for loopback redirects; the literal 127.0.0.1, not localhost, section 8.3). Both use response_type=code, S256, state, nonce, scope openid (+ offline_access for a refresh token).

Audiences. The ID token's aud is chosen by the request: ichor-ui for the SPA, and for the CLI either ichor-kube (the one the kube-apiserver accepts) or ichor-ui, asked for by an audience parameter on the refresh-token request. The kube-apiserver trusts only ichor-kube, so the token that sits in the browser's sessionStorage (ADR 0058 point 6) is not a Kubernetes credential, which keeps ADR 0058's statement true.

Claims and prefixes. iss = the external URL, sub = user name, aud, exp, iat, groups = the user's roles (for example ["admin"]). The kube-apiserver maps sub with prefix ichor: and groups with prefix ichor:, giving user ichor:alice and group ichor:admin. An external IdP is mapped with prefix oidc: (ADR 0058's default), so the two cannot collide, and system: is blocked by a user validation rule on both. The webhook refuses an external prefix that equals ichor:, starts with it, or is empty.

TTLs. ID token 5 minutes (chart ichorUI.users.tokenTTL, 1 to 15 min). Refresh token absolute lifetime 12 hours (refreshLifetime, 1 hour to 30 days). The kube-apiserver does not check anything against Ichor after a token is issued, so the token TTL is the worst-case delay for "disable".

Disable, role change, password change. ichor-ui re-reads the IchorUser for every RPC (an in-memory cache of at most 10 s), refuses a disabled user, and impersonates with the roles from the CR, not from the token. So in the UI a disable or a role change takes effect within 10 s. For kubectl it takes effect at the next token refresh, at most 5 minutes later, because the token's groups claim is old until then and the refresh endpoint re-reads the CR and notBefore. That is "promptly" and is stated as a number rather than "immediately".

Signing keys. RSA-3072, RS256 only. RS256 is the one algorithm OIDC requires and the kube-apiserver authenticator defaults to (k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go:281-286); whether structured configuration widens that list is a claim in Talos's own doc comment (config/types/k8s/authentication.go:59-61) that was not verified in kube-apiserver source, so nothing here depends on it. Keys live in the Secret ichor-oidc-signing (keys.json: kid, PEM, createdAt, activeFrom, retireAfter). The controller (leader-elected, so one writer) creates the first key, adds a new key every 30 days, makes it the signing key 10 minutes after it is published (covering the kubelet's Secret-volume sync lag in ichor-ui), and drops a key 2 hours after the last token signed with it can have expired. ichor-ui mounts the Secret read-only and reloads on change; it never writes keys. The kube-apiserver does not need to be told about a rotation: go-oidc's key set caches keys in memory and re-fetches the JWKS when it sees an unknown kid (oidc/jwks.go:57-58, 168-174), so no machine-config change and no restart follow a rotation.

Decision 3: making the kube-apiserver trust both issuers

What Talos can carry (verified). In the v1.14 contract Talos has a typed document KubeAuthenticationConfig (config/types/k8s/authentication.go) whose configuration is the literal Kubernetes AuthenticationConfiguration, i.e. a list jwt[] of authenticators, each with its own issuer. Its doc comment says kube-apiserver refuses any --oidc-* flag when --authentication-config is set "and Talos always sets it", and KubeAPIServerConfig.extraArgs rejects oidc-*, anonymous-auth and authentication-config (config/types/k8s/apiserver.go:178-180). Talos's generator adds the default document with an empty jwt: [] and anonymous access limited to /livez, /readyz, /healthz (config/generate/kubernetes.go:120, authentication.go:84-107). It is used only with the multi-document form: the legacy v1alpha1 shim returns UseAuthenticationConfig() == false (config/types/v1alpha1/v1alpha1_k8s_bridge.go:141-144), the modern document returns true (apiserver.go:293-296), and multi-document Kubernetes configuration is generated when the contract is greater than 1.13 (config/contract.go:282-285). So:

  • contract v1.14 and later: multiple issuers, typed. This is the only supported mode for Ichor's issuer.
  • earlier contracts: only the legacy --oidc-* flag set, i.e. exactly one issuer, no discoveryURL, no per-issuer CA. Ichor's issuer cannot be used there (the apiserver would need a CA file and a reachable external URL), and a cluster below v1.14 keeps ADR 0058 as it is. The webhook refuses spec.authentication unless versionContract >= v1.14.

How Ichor stays typed. KubeAuthenticationConfig is not on the mirror generator's kind allow-list (internal/tools/mirrorgen/main.go, roots) and its field is a meta.Unstructured, so no TalosConfigPatch can set it (ADR 0003). That is right: the document is owned by the renderer, as the control-plane metrics documents are (pkg/render/controlplanemetrics.go). The renderer builds k8s.io/apiserver/pkg/apis/apiserver/v1.AuthenticationConfiguration (a typed Go struct, types.go:56, already a dependency through the ADR 0003 overrides), validates it with the apiserver's own validation.ValidateAuthenticationConfiguration (pkg/apis/apiserver/validation/validation.go:47) before it ever reaches a node (an invalid file makes kube-apiserver fail to start, and this is the single document whose breakage affects every control plane), converts it through the existing mirrorToMachineryUnstructured helper and applies it as a strategic merge patch. The field is merge:"replace" (authentication.go:70), so the rendered configuration must contain the default anonymous block as well, or health probes lose anonymous access. No raw YAML is accepted anywhere.

CR. TalosCluster.spec.authentication (immutable-name fields, CEL-validated; changes to it need update on talosclusters/authentication, Decision 4):

  • ichor: {enabled, url} — url is the external https URL of ichor-ui and is the issuer (iss).
  • external[]: {issuerURL, audiences[], usernameClaim (email), groupsClaim (groups), usernamePrefix ("oidc:"), groupsPrefix ("oidc:"), certificateAuthority (PEM, public) } — at most 3. The ichor-ui ichorUI.oidc.* values (ADR 0058) are what the UI uses; this list is what the apiserver uses; ichor init and the chart docs say to keep the two equal, and the UI shows a warning when they differ. Each entry becomes one jwt[] element: issuer URL, audiences, claim mappings with the prefixes above, userValidationRules: [!user.username.startsWith('system:')] and !user.groups.exists(g, g.startsWith('system:')). Issuer URLs must be unique across entries (validation.go:61-70).

Reaching the issuer from a host-network kube-apiserver. The apiserver fetches discovery and JWKS from the issuer URL, but the spec allows discoveryURL for "the well-known and jwks endpoints hosted at a different location than the issuer (such as locally in the cluster)", with certificateAuthority to verify that connection, and requires the document's issuer to equal url (apiserver/v1/types.go:139-166; discoveryURL must be https and different from url, validation.go:149-154, 170). Ichor uses it:

  • url = https://ichor.example.com (external, what browsers and the CLI use, and the iss of every token).
  • discoveryURL = https://<ClusterIP of ichor-ui-service>:8444/.well-known/openid-configuration, served by the internal listener, whose discovery document says issuer = the external URL and jwks_uri = the internal address. The cluster does not need the external URL to be reachable from the control planes, nor DNS, nor a load balancer that itself runs on the cluster it authenticates.
  • certificateAuthority = an Ichor-made CA (RSA, 10 years) kept by the controller in ichor-oidc-ca with a leaf (1 year, renewed at two thirds) in ichor-oidc-tls, SANs: the ClusterIP and ichor-ui-service.ichor-system.svc. The controller publishes the public parts (discoveryURL, the CA PEM) in the ConfigMap ichor-oidc-trust, which is a render input (ADR 0061 watches render inputs), so a changed CA or ClusterIP is a desired-state change like any other. A leaf renewal touches nothing in machine config. It is not the Kubernetes or Talos CA, so ADR 0063/0091 rotations do not interact with it.
  • egressSelectorType stays unset (no custom dialer; types.go:190-200). That a control plane's host network can reach a Service ClusterIP is not verified (see "Not verified").

Bootstrap and when ichor-ui is down. The authenticator is built per entry asynchronously and polls every 10 s until discovery works; "this enables self-hosted providers, providers that run on top of Kubernetes itself" (oidc.go:423-430). The apiserver starts without the issuer, and until discovery succeeds, tokens of that issuer are rejected (oidc.go:412, 867) while certificate authentication is unaffected. Once running, keys are cached in memory and only re-fetched on an unknown kid (jwks.go:57-58), so with ichor-ui down, already-issued tokens keep working until they expire (5 minutes) and nothing new can be issued. After an apiserver restart with ichor-ui still down, Ichor-issuer tokens fail until ichor-ui is back, and the external IdP's entries are independent. First boot has no jwt entries: ichor init creates the cluster, installs Ichor, creates the admin, then sets spec.authentication (Decision 7).

Rollout. The rendered document changes for every control plane. pkg/fieldmeta has no rule for KubeAuthenticationConfig, so the default applies: reboot-required, and it is rolled out by the config operation (ADR 0080), one control plane at a time with etcd checks. Whether Talos v1.14.3 applies this document live (and so a rule {prefix: "KubeAuthenticationConfig", safe: true} like the one for KubeAPIServerConfig, fieldmeta.go:212) was not checked, because Talos controller source is not in pkg/machinery; the first implementation unit must read it and add the rule only if it is true. The kube-apiserver is assumed to restart on a change; the reload behaviour of --authentication-config is in k8s.io/kubernetes, not in the module cache, and was not verified. Adopt. A cluster adopted with non-empty jwt in its KubeAuthenticationConfig would have those entries replaced by the render. ADR 0075's adopt names the document as unsupported today; it must refuse (not drop) a non-empty jwt, and say to move the IdP into spec.authentication.external.

Decision 4: roles

No new binding CR. Roles are Kubernetes RBAC objects, so they are the same for the UI (impersonation, ADR 0058), for kubectl with the user's kubeconfig, and for a Git-managed setup. Three ClusterRoles ship in the chart and kustomize (config/rbac/ichor_*_role.yaml), each bound with a ClusterRoleBinding to the group of the same name (ichor-viewer to Group ichor:viewer, and so on); IchorUser.spec.roles names the groups a user is in, so a custom role is a ClusterRole plus a binding to ichor:<name>, and a one-off grant is a binding to User ichor:<name>. Role names viewer, operator, admin are reserved. ClusterRoleBindings, because the product uses one namespace and the roles also need nodes and pods; a site that wants less writes its own namespaced binding.

RoleGets
ichor-viewerget list watch on talosclusters talosmachines talosconfigpatches talosoperations talosstorages talosetcdbackups ichorinstallations; core nodes pods get list (ADR 0082); in ichor-system: services and pods get list, pods/portforward create (for ichor ui). No secrets, no events, no ichorusers.
ichor-operatorviewer, plus create update patch delete on talosconfigpatches; update patch on talosmachines (approve, reject, node settings) and on talosclusters and ichorinstallations (versions, settings; not spec.authentication); create update patch delete on talosoperations. Because create on talosoperations cannot say which kind, the destructive kinds are gated separately (below).
ichor-adminoperator, plus delete on talosmachines and talosclusters; all verbs on ichorusers; verb update on ichorusers/password and talosclusters/authentication; verb start on talosoperations/shutdown, /ca-rotation, /etcd-restore, /node-reset, /node-shutdown; verb override on talosclusters talosconfigpatches talosmachines (ADR 0065); verb download on talosmachines/support-bundle, /etcd-snapshot, /packet-capture (ADR 0084); verb download on talosclusters/credentials (the name ADR 0093 chose replaces this one; the rule below is what binds).

Destructive starts (new). A TalosOperation of kind shutdown, ca-rotation, etcd-restore or node with action reset or shutdown needs verb start on the subresource of that name, asked with a SelfSubjectAccessReview by OperationService.CreateOperation as the caller and by the validating webhook (a SubjectAccessReview on the request's user, the way the override webhook already gates a reset and an etcd restore, internal/webhook/v1alpha1/override_webhook.go) so a hand-written CR obeys it too. The controller's own identity is exempt, as in the override webhook. The override requirements of ADR 0065/0089 stay on top. The same webhook pattern gates edits to spec.authentication (update on talosclusters/authentication), because an operator who could repoint the IdP could break sign-in for everyone.

Not in any built-in role: secrets (so nobody gets the secrets bundle or the credentials Secret through Ichor's roles), impersonate, *. cluster-admin is not part of ichor-admin; a site that wants its Ichor admins to run arbitrary kubectl binds cluster-admin to Group ichor:admin itself (chart value ichorUI.users.clusterAdminRoles: [], empty by default). Doing so also lets them read the secrets bundle, which is the source of Talos credentials, so the statement "users never get Talos access" in Decision 6 holds for the built-in roles only.

Enforcement. Unchanged from ADR 0058, extended: the Authenticator accepts tokens from the local issuer (verified in process against its own keys, aud=ichor-ui, iss = the configured URL) and from the external one (go-oidc); it routes by the unverified iss, and each path then verifies fully. Both produce Identity{User, Groups} and the same impersonating client. The impersonate grant is rendered when either source is on; groups narrow to the built-in group names plus configured ones when only Ichor users are on.

Decision 5: kubeconfig issuance

No secret in the file. The kubeconfig has the API server URL, the cluster CA (public), and a user with an exec block: apiVersion: client.authentication.k8s.io/v1 (in client-go's accepted list, plugin/pkg/client/auth/exec/exec.go:76-79), command: ichor, args: [auth, token, --issuer, <url>, --client-id, ichor-cli, --audience, ichor-kube], interactiveMode: IfAvailable (required for the v1 API; tools/clientcmd/api/types.go:260-270), provideClusterInfo: false. The plugin prints an ExecCredential whose status.token is the ID token and status.expirationTimestamp is its exp; client-go caches it until then (exec.go:386-390, 496-497) and drops it on a 401 (exec.go:378).

ichor auth token. (1) a cached ID token with 30 s left: print it. (2) a cached refresh token: call the token endpoint, store the new ID token, print. (3) otherwise log in: listen on 127.0.0.1:<random>, open the authorize URL (S256 PKCE, state, nonce, offline_access), exchange the code, store, print. Diagnostics go to stderr; stdout carries only the ExecCredential. A file lock on the cache serialises concurrent kubectl runs. Cache: ~/.ichor/auth/<sha256(issuer,client)>.json, directory 0700, file 0600, written atomically by bundlefile.go's writer; it holds a refresh token, so it is a credential at rest on that user's machine, as any CLI login cache is, and the kubeconfig itself stays secret-free. ichor auth login, ichor auth logout (deletes the cache entry) and ichor auth status complete the set. Not built: device authorization grant, so a headless box without a browser cannot log in (it can copy a refresh cache from another machine); the password is never handled by the CLI, only by the browser's login form.

Getting the file. ichor auth kubeconfig [--issuer URL] [-o FILE]: logs in if needed, calls UserService.GetMyKubeconfig on ichor-ui (authenticated with the aud=ichor-ui token), which fills the server URL from TalosCluster.spec.endpoint and the cluster CA from the bundle (public certificate only) and returns the YAML; the UI has "Download my kubeconfig" on the account menu calling the same RPC. It requires ichor-viewer or better, and never contains anything but public data. After a Kubernetes CA rotation (ADR 0091) an old file has the old CA and fails TLS until it is fetched again; the rotation plan text says so.

CLI to ichor-ui. With the Ichor issuer the CLI can now present a token to the backend (ichor auth token --audience ichor-ui), which is the "flag that would carry an ID token" ADR 0058 point 5a left for later. It does this only when the current kubeconfig user is an Ichor exec-plugin user; it still never forwards kubeconfig credentials, and an external-IdP user keeps the in-process fallback.

Decision 6: Talos

Users never get a talosconfig, and nothing in this ADR issues one: no per-user Talos identity exists, so there is nothing to revoke when a user is disabled. Every Talos action stays a CR written by someone with the role for it and carried out by the controller, or one of ADR 0084's three read-only downloads, which the backend performs with a per-call certificate after Kubernetes RBAC allows the signed-in user. The remaining paths that put Talos credentials in someone's hands:

  • ichor init writes the operator's kubeconfig and talosconfig locally and ~/.ichor/clusters/<name>/ holds the bundle (the override credentials; unchanged).
  • ichor cluster credentials, bundle-export and rotate-ca (ADR 0091) read the bundle Secret with the operator's own kubeconfig. They stay admin-only without a new gate because no built-in role can get secrets; only override or cluster-admin holders can run them.
  • The UI download ADR 0093 builds is new surface: it hands an os:admin identity to a browser. This ADR requires of it: verb download on a talosclusters subresource (placeholder credentials) held by ichor-admin only; a Warning Event written before any byte is read and a refusal if the Event cannot be written (ADR 0084's rule); a short-lived certificate (24 hours) rather than ADR 0091's one year; and a confirm dialog that says the file is the cluster's root identity and is not attributable to a person. Where ADR 0093 differs, the differing point is for that ADR to change or to supersede this paragraph. The CLI's and the backend's Talos code paths are unchanged.

Decision 7: ADR 0058's flags, migration, defaults

ichorUI.oidc.* and the --oidc-* flags keep their meaning and defaults; an install that uses neither feature is byte-for-byte as before apart from the new CRD (ichorusers). New values: ichorUI.users.enabled (default false), .externalURL (required when enabled, https), .tokenTTL, .refreshLifetime, .clusterAdminRoles, and ichorUI.service.type (ClusterIP by default; exposing ichor-ui over https is the operator's job through an Ingress, Gateway or LoadBalancer, or ichorUI.tls.secretName to terminate TLS in ichor-ui; Ichor installs no ingress controller). Built-in users are off by default because the feature needs three things a default install does not have: an https URL reachable by browsers and CLIs, a Talos v1.14 contract, and a config rollout on the control planes. ichor init --ui-url https://… turns it on: it sets ichorUI.users.*, creates the first admin, sets spec.authentication.ichor and prints that ichor config apply rolls the control planes. With both ichorUI.oidc.enabled and users.enabled, /auth/config returns providers: [{id,issuer,clientId}] and keeps the old enabled/issuer/clientId keys, so an older CLI (oidcEnabled, internal/cli/api.go:379) still sees "enabled" and the SPA shows both sign-in choices. With neither, ADR 0058 point 4's warning and banner remain. Upgrading from an OIDC-off install changes nothing until users.enabled is set; turning it on makes every RPC require a token, as turning on OIDC does, so the install notes say to create the first admin before the switch.

Not built

Hub mode or several clusters; a device-code login; a user directory (LDAP), SSO session cookie, MFA, password reset by e-mail, lockout state shared between replicas (failed-login back-off is per replica and in memory: five failures per user and per source IP back off exponentially, so the effective limit is twice that with two replicas); per-token revocation; a refresh-token rotation; group nesting; an Ichor-run IdP for other applications; Ichor installing an ingress; per-user Talos identities; hardware-checked behaviour (below).

  • (Built; see "Amendment: the apiserver restart rolls out one control plane at a time" below.) An apiserver-restart rollout for KubeAuthenticationConfig.

Not verified

  • (Resolved by the hardware amendment below.) That a host-network kube-apiserver on a Talos control plane reaches a Service ClusterIP (kube-proxy or Cilium on the node). The design relies on it for discoveryURL; the first e2e must read the authenticator's state, and if it fails the fall-back is the external URL for discovery too.
  • (Resolved by the hardware amendment below.) Whether a stopped ichor-ui (authenticator uninitialised, oidc.go:412) can ever make the kube-apiserver's /readyz fail. HealthCheck() exists on the authenticator (oidc.go:974) but its caller is in k8s.io/kubernetes, which is not in the module cache. If it does, the load balancer pulls the control plane out when ichor-ui is down; the e2e must stop ichor-ui and read /readyz.
  • Whether kube-apiserver accepts algorithms other than RS256 under structured configuration (irrelevant here; RS256 is used).
  • ADR 0093's names and certificate lifetime (Decision 6); it was not on this branch.

Implementation units

Each is one PR, with the listed tests, and scaffolded with the tools named in CLAUDE.md. Order: 1 first; 2, 3, 6 and 9 after 1 and in parallel; 4 after 2 and 3; 5 after 1 and 3; 7 after 4; 8 after 4 and 5; 10 after 7 and 8; 11 last.

  1. pkg/ichorauth and the IchorUser API. kubebuilder create api for IchorUser (api/v1alpha1/ichoruser_types.go, CEL for names and roles, samples, CRD in config/crd and the chart); pkg/ichorauth (argon2id hash/verify with a dummy-hash path and rehash, PHC encode/decode, credentials-Secret entry type, password policy, token and ticket claim structs). Tests: hash round-trip, parameter upgrade, constant-time shape, envtest for the CRD validation.
  2. Issuer core internal/oidcissuer: discovery (external and internal variants), JWKS, authorize/login ticket, code, token (code and refresh grants), key loading and rotation-aware signing, per-replica code jti set and login back-off. Tests: the go-oidc client and the real k8s.io/apiserver/.../oidc.New authenticator against an httptest TLS server, plus the negative list in Decision 2. Depends on 1.
  3. Controller: signing keys and internal TLS (internal/controller/issuer_trust.go): Secrets ichor-oidc-signing, ichor-oidc-ca, ichor-oidc-tls, ConfigMap ichor-oidc-trust, rotation timeline, IchorUser.status, credentials-Secret garbage collection, RBAC markers and chart Roles (resourceNames), metrics (issuer certificate expiry is exported as a metric only; no TalosCluster condition, because both certificates renew themselves). Tests: envtest reconcile, fake clock for rotation and renewal, ClusterIP change reissues the leaf.
  4. ichor-ui integration: new listeners and flags, Authenticator for two sources, 10 s IchorUser cache, UserService (RBAC-checked with SelfSubjectAccessReview, Secret writes through the SA), /auth/config providers, chart values and ichor-ui role changes (ichorusers get, the credentials Secret by name, Secret volumes). Extends rbac_enforced_test.go so the generated role is the only thing under test. Depends on 2, 3.
  5. Render and webhook (pkg/render/authentication.go, api/v1alpha1/taloscluster_types.go, internal/webhook): spec.authentication, the typed AuthenticationConfiguration builder, ValidateAuthenticationConfiguration, the contract gate, the prefix rules, update on talosclusters/authentication, the start check on talosoperations/* in the webhook and in OperationService.CreateOperation, fieldmeta classification (after reading Talos source), adopt refusing non-empty jwt. Tests: render golden files per contract, envtest webhook with real roles (the shape of override_reset_test.go), fieldmeta rule test. Depends on 1, 3.
  6. Roles: config/rbac/ichor_{viewer,operator,admin}_role.yaml, chart templates, bindings to the three groups, ichorUI.users.clusterAdminRoles; dist/chart/chart_test.go pins that no role contains secrets, impersonate or *, and that the defaults render nothing. Independent of the rest except the names in Decision 4.
  7. CLI ichor auth {token,login,logout,status,kubeconfig} and ichor user {list,create,update,delete,set-password,revoke-sessions}: loopback + PKCE, cache and lock, ExecCredential output, bearer to ichor-ui when the user is an exec-plugin user (internal/cli/api.go). Tests: fake issuer, golden ExecCredential, 0600/0700 checks, no token in logs. Depends on 4.
  8. UI: login route, account menu (change password, download kubeconfig), Users page (list, create, roles, disable, reset password), both sign-in choices from /auth/config; preactcn components only, scaffolded with npx shadcn@latest add @preactcn/…; English and Japanese strings (ADR 0064). Tests: vitest for the PKCE helper and the login state machine. Depends on 4, 5 for the final shape.
  9. ichor init bootstrap (internal/cli/init_ichor.go): --ui-url, --admin-user, --admin-password-file, first admin with mustChange, spec.authentication.ichor. Tests: init_order_test.go-style ordering, the password is never written to a file or a log (init_leak_test.go pattern). Depends on 1 and the CR in 5.
  10. E2E: Talos v1.14 cluster, users on, log in with the CLI, kubectl as an operator and as a viewer, disable and see the 5 minute bound, stop ichor-ui and check /readyz and cached-key behaviour, restart an apiserver with ichor-ui down, rotate a signing key without restart, a rollout through ichor config apply. This is where the "Not verified" items are decided. Depends on 7, 8.
  11. Docs: architecture.md "Trust" (replace "OIDC login... comes later" with this), "Deployment", the Amendment in ADR 0058 (done with this ADR), the user guide for exposing ichor-ui over https.

Consequences

  • A compromised ichor-ui pod can now read password hashes and the signing keys and so mint a token for any user, including an ichor:admin that the apiserver accepts. ADR 0058 already gives that pod impersonate on any non-system: user, so the class of risk is the same, but the pod is now also the root of a login system. Two replicas, resourceNames on every Secret rule, no secrets list, and short tokens bound it; they do not remove it.
  • The issuer needs an https URL for browsers and CLIs that Ichor does not provide. A lab without one stays on ADR 0058 or on certificates.
  • The controller gains Secret writes in ichor-system for four named objects and a reconciler; the Talos config for control planes gains one more rendered document, and every change to it is a control-plane rollout.
  • The ichor-operator role can start the routine kinds of TalosOperation (upgrades, config, CNI, reboot, service restart) but not shutdown, CA rotation, restore, reset: those are separate, named, auditable grants.
  • Kubeconfigs downloaded before a Kubernetes CA rotation must be fetched again.

Amendments

  • Unit 2: the audience parameter (only ichor-kube or ichor-ui; ichor-ui client only ichor-ui) is accepted on the authorize request and on the refresh request. It is carried in the code and in the refresh token, so a refresh without the parameter keeps the audience chosen at login; absent everywhere, ichor-ui client gets ichor-ui and ichor-cli gets ichor-kube. A refresh grant returns no new refresh token (not rotated).

  • Unit 2: the kube-apiserver authenticator uses go-oidc v2.5.0, not v3 as the ADR cites for rotation: it refetches the JWKS on an unknown kid only after the cache expiry given by the response headers. Discovery and JWKS are therefore served with Cache-Control: no-cache (no positive max-age), which makes rotation pickup immediate; a test pins it.

  • Unit 2/3 integration: the issuer reads the controller's keys.json ({"v":1,"keys":[...]}, internal/oidctrust) through the shared oidctrust.ParseKeys, Signing and Published; it has no format of its own. Keys are PKCS#8 only. The retire boundary is the controller's: a key is dropped at retireAfter exactly, not after it. A test feeds the issuer a document made by the controller's own rotation code.

  • Unit 7: the ichor user commands and ichor auth status|kubeconfig do not use the port-forward or the in-process fallback of ADR 0058/0047. With users on, ichor-ui refuses an unauthenticated tunnel, and ichor auth kubeconfig has to work for a person with no cluster access yet. They call UserService on ichor-ui at --issuer (the external URL is ichor-ui's own URL) over https with a bearer ID token for audience ichor-ui from the login cache, and refuse plain http except to the loopback. -o FILE of the ADR text is --out FILE (-o is the global output format). The --audience default of ichor auth login|token is ichor-kube.

  • Unit 7: --ca-file (env ICHOR_CA_FILE) adds a PEM CA to the system roots for the issuer and ichor-ui connections. GetMyKubeconfigRequest.ca_file makes the server add --ca-file <path> to the exec args; unset adds nothing.

  • Unit 9: ichor init installs from the embedded kustomize manifest, not the chart, so --ui-url edits the ichor-ui Deployment (flags, internal port, optional signing and tls volumes) and Service in place and creates the empty ichor-user-credentials Secret, in the order Secret, first admin, switch, then sets spec.authentication.ichor by merge patch once the cluster exists. --admin-password-file is not built; the password is generated and printed once to stderr, also when a later step fails.

  • Unit 9: ichor user create NAME --role admin --generate --bootstrap is the first admin on an existing install: the caller's own kubeconfig writes the IchorUser and the Secret entry (merge patch of one key, hashing with pkg/ichorauth). It refuses an existing user and a missing Secret.

Amendment: Hardware results

A spike on real hardware (Talos v1.14.3, Kubernetes v1.37.1, one control plane; the change was talosctl patch machineconfig --mode=auto, with nginx as the issuer on ClusterIP 10.251.0.50:8443) settled the two "Not verified" items about Decision 3 and replaces the reboot-required default of its "Rollout" paragraph.

  1. Applying KubeAuthenticationConfig needs no reboot, but restarts the kube-apiserver. --mode=auto answered "Applied configuration without a reboot" and the boot ID stayed the same. Talos re-renders the kube-apiserver static pod and the apiserver restarts on every change; with a single control plane /readyz was down for 15 to 45 s per change. pkg/fieldmeta therefore has {prefix: "KubeAuthenticationConfig", safe: true}. The intended rollout is an apiserver restart, not a reboot: one control plane at a time, wait for that node's /readyz before the next, no drain. The machine controller today applies every safe change to every machine at once, and the config operation (ADR 0080) only rolls reboot-required changes, so the ordering and the /readyz wait were not built at this point; the amendment below builds them, and KubeAuthenticationConfig is no longer safe but the new APIServerRestart class.
  2. Talos does not validate the document. An invalid one was accepted ("applied without a reboot") and then crash-looped kube-apiserver for about 6 minutes, which also put kube-controller-manager into CrashLoopBackOff. The trigger was two audiences without audienceMatchPolicy: jwt[0].issuer.audienceMatchPolicy: Invalid value: "": audienceMatchPolicy must be MatchAny for multiple audiences. So validation.ValidateAuthenticationConfiguration is mandatory before rendering and any error stops the render, and the renderer always sets audienceMatchPolicy: MatchAny when an issuer has more than one audience.
  3. Verified, no change needed. A ClusterIP plus discoveryURL plus an Ichor CA with an IP SAN works from the host-network kube-apiserver; /readyz stays ok while the issuer is down, before and after an apiserver restart; two jwt[] issuers work together; an unknown kid makes the apiserver re-fetch the JWKS at once (about 3 s).

Amendment: the apiserver restart rolls out one control plane at a time

Replaces the "Not built" line about the apiserver-restart rollout. pkg/fieldmeta has a third class beside safe and reboot-required, APIServerRestart (ADR 0080's amendment has the whole design and the audit of which fields belong to it). KubeAuthenticationConfig is in it, so a change to spec.authentication is no longer applied by the machine controller to every control plane at once: the machines report Ready=False/APIServerRestartRequired and ichor config apply (or the cluster page) rolls it, one control plane at a time with no drain and no reboot, waiting for that node's kube-apiserver /readyz before the next. ichor init --ui-url prints that command, as before. The plan says "Restarts the Kubernetes API server on each control plane, one at a time; no reboot." On a cluster with one control plane the plan also warns that the API is unavailable while it restarts. Not verified on hardware.

Amendment: must-change is enforced server-side; the kubeconfig server is configurable

Hardware testing found that mustChange only stopped the SPA. Now AuthInterceptor refuses every RPC except WhoAmI and ChangeOwnPassword with PermissionDenied "change your password first" for an Ichor user whose credentials entry has mustChange (read through UserCache.MustChange, cached at most 10 s like the IchorUser, and dropped on this replica when the password is written; a read failure refuses the token). The issuer, via oidcissuer.User.MustChange, issues no ichor-kube token (code or refresh) to such a user and says why; ichor-ui tokens still work, so the user can change the password. GetMyKubeconfig used the cluster spec.endpoint, which is a node address, not what users reach through a load balancer: ichorUI.users.kubeAPIServerURL (--kube-api-server-url) replaces the server, and ichorUI.users.kubeAPITLSServerName (--kube-api-tls-server-name) sets tls-server-name when that host is not in the apiserver certificate SANs. Both are empty by default, which keeps the old behaviour.

Amendment (names): object names differ by install path (kustomize namePrefix: ichor- gives ichor-ichor-ui and ichor-ichor-ui-service), so ichor init and the controller find the ichor-ui Deployment and Service by the label app.kubernetes.io/component=ui (--ui-service-name stays as an override), init also adds --user-issuer and --oidc-refresh-lifetime to the controller before creating the admin, and gives ichor-ui --internal-jwks-uri from the Service ClusterIP instead of the kubelet *_SERVICE_HOST variable, whose name follows the Service name.

Amendment: accepted after the hardware run

Amendment (2026-10-28): verified end to end on hardware; ichor init --ui-url works as shipped, and must-change, roles, disable, issuer-down and the one-at-a-time apiserver rollout were each checked, so Status is Accepted.

Amendment (CLI bearer): when ichor-ui's /auth/config says auth is required, the CLI sends Authorization: Bearer <ID token> on its Connect calls through the port-forward, using the cached ichor-ui-audience login for one of the advertised issuers (refreshed with the cached refresh token when near expiry, never printed); with none cached it keeps the in-process fallback and prints ichor auth login --issuer <url> --audience ichor-ui. A TLS verification failure now says so in the fallback line. ichor auth logout removes every cached audience for the issuer unless --audience is given.

Amendment (events): the operation page's activity log lists Events as the signed-in user, so the namespaced ichor-user-access Role (not the cluster-wide roles) also grants get list watch on events (core and events.k8s.io) in ichor-system; "no events" in the viewer row means no cluster-wide events.

Amendment (ClusterIP changes): a deleted and recreated ichor-ui Service gets a new ClusterIP. Everything on the controller side already followed: IssuerTrustReconciler watches the Service and reissues only the leaf (IP SAN) and the ichor-oidc-trust discoveryURL, and the machine reconciler maps that ConfigMap to every machine of an Ichor-issuer cluster, so the apiserver config re-renders through the normal rollout. What went stale was the internal discovery document: ichor init pinned --internal-jwks-uri to the ClusterIP in the ichor-ui Deployment, so the apiserver was sent to the old address for the JWKS. Decision: ichor-ui now advertises the jwks_uri on the host the caller connected to (oidcissuer.Config.InternalJWKSFromHost), and init no longer passes --internal-jwks-uri (a re-run removes it from an existing Deployment; the flag stays as an explicit override). The apiserver reaches the internal listener only through an address in the serving certificate SAN, so that host is always one the controller has issued for. A fixed ClusterIP was rejected: it needs a chosen free address in the Service CIDR on every install path and does not help the Service-recreated-by-someone-else case.

Amendment (2026-10-29): in-page sign-in. The SPA shows one card, "Sign in to Ichor", with the user name and password form and, when the external provider of ADR 0058 is also configured, a "Sign in with your identity provider" button below a separator; there is no provider-choice screen and no page transition for the password sign-in. GET /oidc/authorize answers {"ticket": "..."} (200) instead of the 302 to /login when the request carries Accept: application/json; every check is the same as on the redirect path (client, exact redirect URI, S256 PKCE, state, nonce, audience), and a failure is a 400 JSON error with no redirect. The SPA then posts the ticket and credentials to /oidc/login, checks that the returned redirect_uri is its own /auth/callback with its own state, and exchanges the code at the token endpoint with fetch (verifier and nonce kept in memory, not in sessionStorage). Why: the second card and two navigations added nothing the PKCE and ticket checks needed, and the full-page redirects made the flow feel broken. The redirect path and the /login?ticket= page stay for ichor auth login (the CLI opens a browser at the issuer); that page renders the same card. The external IdP button still redirects, necessarily.

Amendment 2026-10-29: served certificates are reloaded on change. ichor-ui's HTTPS listener (--tls-cert-file), its internal listener on 8444 and the controller's metrics server follow their certificate files through controller-runtime's certwatcher (file events plus a 10 s poll), so a cert-manager renewal reaches new connections without a restart; a pair that fails to load keeps the previous certificate serving and is logged without key material. The webhook server already used certwatcher through its CertDir.

Amendment 2026-10-29 (ADR 0105): ichor-ui's main listener is HTTPS only, on 8443, with the operator's certificate or the Ichor leaf (ichor-oidc-tls, now issued for every install, not only with --user-issuer); the 8444 issuer listener is unchanged and keeps the Ichor leaf. The controller publishes the CA as ichor-ui-ca. Token and refresh lifetimes are runtime settings in IchorInstallation (spec.users), read per issued token; the flags are the fallback. The guide's "Expose ichor-ui over https" now carries the Ingress-NGINX, BackendTLSPolicy and passthrough recipes.

Amendment 2026-11-04: a password change keeps the browser tab signed in. ADR 0108 made a password change move notBefore and drop every refresh family, which ends the tab's own ID token too, and the SPA then called signOut(), so a user forced through the must-change screen (or choosing a new password in the account menu) had to sign in again by hand. Decision: after ChangeOwnPassword succeeds the SPA signs in again with the new password through the in-page flow above (signInAgain in ui/src/lib/login.ts: /auth/config for the ichor provider, the user name from the sub claim of the stored token, then authorize, login and token as for any sign-in), and reloads. No new server surface and no new token-minting path: the password is in memory for that one call, the new token is issued by the normal login, and notBefore and the families stay as ADR 0108 left them, so every other session and refresh-token family of the user is still revoked. The new login's iat is not before notBefore (whole seconds, compared with <), so it is accepted. If the silent sign-in fails for any reason the tab clears its token and shows the sign-in card, as before. Rejected: returning tokens from ChangeOwnPassword (a second minting path next to the issuer's) and sparing the caller's own family (the SPA has no refresh token, and the server could not tell which family is the caller's). The CLI has no change-own-password command (ichor user set-password is the administrator's and revokes the user's sessions by design), so it is unaffected.

Amendment 2026-11-04 (browser session): the SPA signs in with a refresh token, so a tab stays signed in up to the issuer's absolute refresh lifetime instead of 5 minutes. The cause of the short session was in the SPA alone: it asked for openid email profile groups, and the issuer issues a refresh token only for offline_access (codeGrant), so the tab held an ID token of DefaultTokenTTL (5 minutes) and nothing to renew it with. The issuer already allowed the refresh grant for ichor-ui (same rotation, reuse detection and absolute lifetime as the CLI's, ADR 0108 F7; audience stays ichor-ui, never ichor-kube), so it has no change; a test now pins the browser client's rotation.

Decision.

  • The ichor sign-in (redirect and in-page) adds offline_access. The external IdP request is unchanged: its refresh behaviour is the provider's, not ours.
  • The refresh token is kept in sessionStorage (ichor.refresh, with the token endpoint and client id) beside the ID token, not in localStorage, not in a cookie. Reasoning against XSS: the ID token is already there and is the bearer for every Ichor call, so a script that can read the storage can already act as the user for its 5 minutes; the refresh token extends that to the session lifetime, which is the new exposure. It is bounded by what is already in place: the CSP is script-src 'self' with no inline script, there is no HTML injection surface in the pages, the token is tab-scoped and gone when the tab closes, it is single-use with rotation and reuse detection (a thief's first use makes the owner's next refresh fail and kills the family, so the theft is visible as a sign-out), the lifetime is absolute (rotation never extends it), the audience is ichor-ui only so it is not a Kubernetes credential, and RevokeSessions, a password change and disabling the user end the family. In-memory only was rejected: a reload would sign the user in again, which is the problem being fixed. A cookie (httpOnly) would need a server-side session, which the issuer deliberately does not have, and would add CSRF surface.
  • Refresh runs one at a time per tab (single flight), a minute before the ID token ends (a third of its life for tokens under 3 minutes, never more often than every 10 s), when a hidden tab becomes visible, before a request that would carry an ID token about to end, and once on a 401 for a unary call, which is retried once. A refresh the issuer refuses (400/401/403) ends the session: tokens dropped, tab back to the sign-in card. A network or 5xx failure keeps the session and retries every 15 s. A server stream that ends with "your sign-in expired" (the log view) is reopened from its resume token after a refresh, within its reconnect budget.
  • Two tabs: each tab signs in separately and has its own family (sessionStorage is per tab, so no tokens are shared and no cross-tab protocol is needed). The one way two tabs end up with one family is the browser's "duplicate tab" and window.open without noopener, which copy sessionStorage; the first rotation would then make the copy present a retired token and revoke the family for both. A tab that has a family holds a Web Lock named after it (ichor-session-<fam>) for as long as its page lives; a copy cannot take it within 1.5 s, drops the copied tokens and shows the sign-in card, so the original keeps its session. Without Web Locks, or if the lock call fails for another reason, the tab goes ahead (the failure is then the reuse detection's: both tabs sign in again). Coordinating one family across tabs (BroadcastChannel plus a lock) was rejected as more code and more exposure (tokens on a channel) for no gain over a second sign-in.
  • Password change: signInAgain goes through the normal flow, so it asks for offline_access and stores the new refresh token; the old family died with the change (ADR 0108), as before.
  • No idle timeout. An open tab refreshes by itself, so the session ends at the absolute lifetime (ichorUI.users.refreshLifetime, runtime-settable, default 12h, 1h to 30d) and not before, like the CLI. An idle timeout would need either a server-side last-seen (the issuer is stateless apart from the family entry) or browser activity tracking that a hidden, throttled tab defeats; an administrator who wants a shorter exposure lowers refreshLifetime. Not added; the guide says so.
  • Sign-out drops the tokens in the tab; the issuer has no revocation endpoint, so a copy taken earlier lives to the end of its lifetime unless the user's sessions are revoked. Same as ichor auth logout. A revocation endpoint (RFC 7009) would need a RefreshStore.Revoke and is not built.
  • Superseded: the "A refresh grant returns no new refresh token (not rotated)" amendment of Unit 2 (rotation came with ADR 0108), and the sentence in 2026-11-04's password-change amendment that the SPA has no refresh token.
  • Known limit: if the answer to a refresh is lost after the issuer rotated, the next try presents the retired token and the family is revoked: the tab signs in again. Rotation without a grace window makes this unavoidable.

Follow-up 2026-11-04 (sign-out revokes): the open issue above is closed. The issuer has POST /oidc/revoke, the RFC 7009 revocation endpoint for refresh tokens, advertised as revocation_endpoint in the external discovery document (not on the internal listener, which serves only discovery and the JWKS). Request: form token (required), client_id (ichor-ui or ichor-cli, public clients, no other authentication; same checks and the same per-source rate limit as /oidc/token), optional token_type_hint=refresh_token. Behaviour: a valid refresh token deletes its whole family (RefreshStore.Revoke, an optimistic-lock patch of the user's credentials entry like rotation), so that token, its predecessors and its successor all stop refreshing; a token that is not a valid refresh token (garbage, expired, an ID token, family already gone) answers 200 with no body, as RFC 7009 section 2.2 requires, so the endpoint is no oracle; a valid refresh token issued to another client is refused with 400 invalid_grant and not revoked (section 2.1); a missing or repeated token, or a body that is not a form, is 400 invalid_request; an unknown client_id is 401 invalid_client; token_type_hint=access_token is 400 unsupported_token_type, because ID tokens (the bearer) are not revocable and end within TokenTTL; a store failure is 500 server_error. Sign-out: the SPA's signOut posts the refresh token to the revocation endpoint it saved from discovery with the session (revocationEndpoint beside the token endpoint in ichor.refresh; keepalive, 3 s timeout) before it clears the tokens, and ichor auth logout posts every cached login's refresh token (discovering the endpoint, 10 s timeout) before it deletes the cache. Both are best effort: a failure, a refusal or an issuer without the endpoint only warns (CLI) or is ignored (SPA), and the sign-out completes, so the token then lasts to its lifetime as before. A tab signed in before this change has no stored endpoint and only discards its tokens. A tab that lost a family to the duplicate-tab check or a refused refresh has already cleared its tokens and posts nothing, so a copied tab never revokes the original's family.

Amendment 2026-11-05 (ADR 0129): the last-enabled-admin guard and the role-grant guard also hold for a kubectl write, through a validating webhook on ichorusers. System identities and system:masters (the override certificate) are exempt, so the override credential stays the recovery path described above.

Search the docs

Search pages and headings