# OIDC Console integration **Use this when:** connecting RustFS Console login to an OpenID Connect provider (Keycloak, Authing, or any standards-compliant IdP), or debugging an OIDC redirect, token, or policy-mapping failure. **Source of truth:** `crates/config/src/constants/oidc.rs` (provider keys and `RUSTFS_IDENTITY_OPENID_*`), `crates/iam/src/oidc/config.rs` (per-provider env suffixes), `crates/iam/src/oidc/provider.rs` (discovery and JWKS), `crates/iam/src/oidc/runtime.rs` (PKCE and token validation), `rustfs/src/admin/handlers/oidc.rs` (authorize/callback handlers), `crates/config/src/constants/app.rs` (`ENV_RUSTFS_BROWSER_REDIRECT_URL`), `crates/utils/src/egress.rs` (`ENV_OUTBOUND_ALLOW_ORIGINS`), `crates/policy/src/policy/policy.rs` (built-in policies). The RustFS side is vendor-neutral and is described once; what RustFS requires from any provider is tabulated in [oidc-provider-requirements.md](oidc-provider-requirements.md). The [Keycloak](#keycloak) and [Authing](#authing) sections contain only IdP-side steps and vendor caveats. Examples use provider id `default` and public origin `https://rustfs.example.com`. ## Integration model RustFS requires a standards-compliant OpenID Connect provider: discovery at `/.well-known/openid-configuration`, authorization and token endpoints, a JWKS, and an authorization-code flow that returns an `id_token`. RustFS never calls a vendor's authorization API; access is decided by RustFS IAM policies after claim mapping. Protocol requirements for IdP vendors are collected in [oidc-provider-requirements.md](oidc-provider-requirements.md). Login flow: 1. The browser opens `https://rustfs.example.com/rustfs/admin/v3/oidc/authorize/`. 2. RustFS creates `state`, `nonce`, and a PKCE S256 challenge and redirects to the IdP. 3. The IdP redirects back to `/rustfs/admin/v3/oidc/callback/?code=...&state=...`. 4. RustFS exchanges the code at the token endpoint, sending `client_id` and `client_secret` in the request body (`client_secret_post`) together with the PKCE verifier. 5. RustFS validates the ID token signature (JWKS), issuer, audience, expiry, and nonce. 6. RustFS maps claim values to policy names and issues STS credentials to the Console with the lifetime set by `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` (default 3600 seconds, one hour). In-flight `state` and PKCE verifiers are node-local: the authorize and callback requests must reach the same RustFS node. ## Configuration keys Every provider key can be set as `RUSTFS_IDENTITY_OPENID_` in the process environment or as `identity_openid` `=` through `mc admin config set`. Names are constants in `crates/config/src/constants/oidc.rs`. | Provider key | Environment variable | Purpose | | --- | --- | --- | | `enable` | `RUSTFS_IDENTITY_OPENID_ENABLE` | `on` loads the provider. | | `config_url` | `RUSTFS_IDENTITY_OPENID_CONFIG_URL` | Issuer URL used for discovery. A trailing `/.well-known/openid-configuration` is stripped; any other `.well-known` path is rejected. | | `issuer` | `RUSTFS_IDENTITY_OPENID_ISSUER` | Expected `iss` when it differs from `config_url` (internal discovery URL, public token issuer). | | `client_id`, `client_secret` | `RUSTFS_IDENTITY_OPENID_CLIENT_ID`, `RUSTFS_IDENTITY_OPENID_CLIENT_SECRET` | Confidential client credentials. | | `scopes` | `RUSTFS_IDENTITY_OPENID_SCOPES` | Comma-separated; `openid` is required. | | `other_audiences` | `RUSTFS_IDENTITY_OPENID_OTHER_AUDIENCES` | Additional accepted `aud` values. | | `redirect_uri` | `RUSTFS_IDENTITY_OPENID_REDIRECT_URI` | Callback URL sent to the IdP; must equal the URL registered there. | | `redirect_uri_dynamic` | `RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC` | `on` derives the callback from request headers. Keep `off` behind proxies. | | `claim_name`, `claim_prefix` | `RUSTFS_IDENTITY_OPENID_CLAIM_NAME`, `RUSTFS_IDENTITY_OPENID_CLAIM_PREFIX` | Policy claim name and a fixed string prepended to each value. `claim_prefix` is not a mapping table. | | `groups_claim`, `roles_claim` | `RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM`, `RUSTFS_IDENTITY_OPENID_ROLES_CLAIM` | Flat top-level array claims whose values are RustFS policy names. | | `email_claim`, `username_claim` | `RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM`, `RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM` | Identity claims shown in the Console. | | `role_policy` | `RUSTFS_IDENTITY_OPENID_ROLE_POLICY` | One fixed policy for every login from this provider. Connectivity testing only. | | `display_name` | `RUSTFS_IDENTITY_OPENID_DISPLAY_NAME` | Login button label. | | `hide_from_ui` | `RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI` | Hides the provider from `/oidc/providers`. Required (`on`) for STS workload issuers whose discovery document omits `authorization_endpoint`; see [workload provider requirements](oidc-provider-requirements.md#sts-workload-providers). Complete hidden providers still support direct Console login. | Process-level settings (environment only, never suffixed per provider): | Variable | Purpose | | --- | --- | | `RUSTFS_BROWSER_REDIRECT_URL` | Public browser origin used for callback generation, Console success redirects, and logout fallback. | | `RUSTFS_OUTBOUND_ALLOW_ORIGINS` | Exact `scheme://host[:port]` origins RustFS may contact for discovery, JWKS, and token requests when the IdP resolves to a private, loopback, or container-network address. See [outbound-connection-policy.md](outbound-connection-policy.md). | | `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` | Credential lifetime in seconds for Console credentials issued by this callback, for every provider. Default `3600`. Values that are missing, not a positive integer, or zero keep the default; values above `604800` (7 days) are clamped to `604800` with a warning. The same value also sets how long the Console logout token stays valid, so RP-initiated logout keeps working for the whole session. Note that `RUSTFS_CONSOLE_AUTH_TIMEOUT` does not control this: it is the Console HTTP request timeout. | Named providers: to use provider id ``, suffix every provider env var with `_` (for example `RUSTFS_IDENTITY_OPENID_CLIENT_ID_keycloak`) and register the callback `/rustfs/admin/v3/oidc/callback/`. Suffix scanning is `parse_single_provider` in `crates/iam/src/oidc/config.rs`. Restart RustFS after changing any of these settings. ### Environment example ```bash export RUSTFS_BROWSER_REDIRECT_URL="https://rustfs.example.com" export RUSTFS_IDENTITY_OPENID_ENABLE=on export RUSTFS_IDENTITY_OPENID_CONFIG_URL="" export RUSTFS_IDENTITY_OPENID_CLIENT_ID="" export RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="" export RUSTFS_IDENTITY_OPENID_SCOPES="openid,profile,email" export RUSTFS_IDENTITY_OPENID_REDIRECT_URI="https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default" export RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC=off export RUSTFS_IDENTITY_OPENID_DISPLAY_NAME="" export RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM="groups" export RUSTFS_IDENTITY_OPENID_ROLES_CLAIM="roles" export RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM="email" export RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM="preferred_username" ``` The same keys through admin config: ```bash mc admin config set rustfs identity_openid \ enable=on config_url="" client_id="" client_secret="" \ scopes="openid,profile,email" \ redirect_uri="https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default" \ redirect_uri_dynamic=off display_name="" \ groups_claim="groups" roles_claim="roles" email_claim="email" username_claim="preferred_username" mc admin service restart rustfs ``` `RUSTFS_BROWSER_REDIRECT_URL` is not an `identity_openid` key; it must still be set in the process environment. ## Redirect URL priority 1. Provider `redirect_uri`, when set, is the callback URL sent to the IdP. 2. `RUSTFS_BROWSER_REDIRECT_URL`, when set, is the public origin for callback generation when no provider `redirect_uri` exists, and for Console success and logout fallback redirects. 3. Request headers (`Host`, `X-Forwarded-Proto`) are used only when `redirect_uri_dynamic=on` and no browser redirect URL is configured. Behind a reverse proxy or load balancer, set `RUSTFS_BROWSER_REDIRECT_URL` and keep session affinity for the authorize and callback requests. ## Policy mapping Claim values are used verbatim as policy names (after `claim_prefix`, if any). Names must satisfy `is_safe_claim_policy_name` in `crates/iam/src/sys.rs`: ASCII letters, digits, `_`, `-`, `:`, `.` only, so a value containing `/` (for example Keycloak's full group path `/consoleAdmin`) never matches. Built-in policies: | Policy | Grants | | --- | --- | | `consoleAdmin` | Full Console, admin, KMS, and S3 access. | | `readwrite` | S3 read/write. | | `readonly` | S3 read-only. | | `writeonly` | S3 write-only. | | `diagnostics` | Diagnostic admin access. | For first-contact testing only, `RUSTFS_IDENTITY_OPENID_ROLE_POLICY=consoleAdmin` grants every login full access; remove it before production. ## Validation 1. Discovery: ```bash curl -fsS "/.well-known/openid-configuration" | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri, code_challenge_methods_supported, token_endpoint_auth_methods_supported, scopes_supported}' ``` `issuer` must equal `RUSTFS_IDENTITY_OPENID_ISSUER` when set, otherwise the issuer derived from `RUSTFS_IDENTITY_OPENID_CONFIG_URL`; `code_challenge_methods_supported` must include `S256`; `token_endpoint_auth_methods_supported` must include `client_secret_post`; `scopes_supported` must include every configured scope. 2. Provider visibility: `curl -fsS https://rustfs.example.com/rustfs/admin/v3/oidc/providers | jq` lists the provider unless `hide_from_ui=on`. 3. Browser login: open `https://rustfs.example.com/rustfs/admin/v3/oidc/authorize/default`. Expect a redirect to the IdP, sign-in, a redirect to `/rustfs/admin/v3/oidc/callback/default?code=...&state=...`, and then the Console with the mapped permissions. 4. ID token claims (decode the token after a test login): `iss` matches the issuer, `aud` includes the client id, `email` and `preferred_username` are present when configured, `groups` or `roles` is a flat array of policy names. ## Troubleshooting | Symptom | Common cause | Fix | | --- | --- | --- | | `/oidc/providers` does not list the provider | provider failed to load, or RustFS was not restarted | Check env/admin config and restart RustFS. | | Provider or login button missing; startup logs `OIDC provider discovery blocked by outbound policy` | IdP origin is private/internal and not allowlisted | Add the exact origin to `RUSTFS_OUTBOUND_ALLOW_ORIGINS` on every node and restart. | | IdP reports a redirect mismatch (`invalid redirect_uri`) | registered callback differs from RustFS `redirect_uri` | Use the exact `/rustfs/admin/v3/oidc/callback/` URL on both sides. | | Callback reports missing `code` or `state` | proxy dropped the query string | Preserve the full callback URL and query string. | | Token exchange fails | wrong secret, or the IdP rejects request-body client authentication | Confirm the client is confidential and accepts `client_secret_post`. | | No `id_token` in the token response | `openid` scope missing or a non-OIDC OAuth flow | Add `openid`; use the authorization-code flow. | | ID token verification fails | issuer, audience, algorithm, or JWKS mismatch | Compare discovery metadata with `CONFIG_URL`/`ISSUER`/`CLIENT_ID`; prefer `RS256`. | | Login succeeds, access denied | no claim value matches a policy name | Emit `groups` or `roles` as a flat array equal to policy names; check for `/` prefixes. | | Console redirects to an internal host | `RUSTFS_BROWSER_REDIRECT_URL` unset or proxy headers wrong | Set `RUSTFS_BROWSER_REDIRECT_URL` to the public origin. | | Invalid or expired OIDC state | callback reached a different node | Configure load-balancer session affinity for authorize and callback. | | Console session expires after about an hour | callback issues credentials with the default lifetime | Set `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` to the desired lifetime in seconds (max `604800`, 7 days) and restart RustFS. `RUSTFS_CONSOLE_AUTH_TIMEOUT` is a request timeout and does not extend the session. | ## Production checklist - [ ] RustFS and the IdP use HTTPS. - [ ] The IdP registers the exact callback URL (no wildcard) and `RUSTFS_IDENTITY_OPENID_REDIRECT_URI` matches it. - [ ] `RUSTFS_BROWSER_REDIRECT_URL` is the public browser origin. - [ ] PKCE S256 is allowed or required at the IdP. - [ ] ID tokens carry `groups` or `roles` values equal to RustFS policy names. - [ ] `role_policy` is not used as a permanent shortcut. - [ ] The load balancer preserves query strings and pins authorize/callback to one node. - [ ] Internal IdP origins are listed exactly in `RUSTFS_OUTBOUND_ALLOW_ORIGINS` on every node. ## Keycloak | Value | Example | | --- | --- | | Realm | `rustfs` | | Issuer (`config_url`) | `https://keycloak.example.com/realms/rustfs` | | Discovery URL | `https://keycloak.example.com/realms/rustfs/.well-known/openid-configuration` | | Client id | `rustfs-console` | | Scopes | `openid,profile,email` | | Groups claim | `groups` (flat array) | Client setup in the Keycloak Admin Console: 1. Create or select the realm and confirm discovery returns `issuer` equal to `https://keycloak.example.com/realms/rustfs`. 2. `Clients` → create: `Client type` = `OpenID Connect`, `Client ID` = `rustfs-console`. 3. Enable `Client authentication` and `Standard flow`; disable `Implicit flow`, `Direct access grants`, and `Service accounts roles`. 4. `Valid redirect URIs` = `https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default`; `Web origins` = `https://rustfs.example.com`. 5. `Proof Key for Code Exchange Code Challenge Method` = `S256`. 6. Save and copy the secret from `Credentials`. Do not apply a client policy that disables `client_secret_post`. Group mapper (a `Group Membership` mapper in the client's dedicated scope): | Mapper field | Value | | --- | --- | | Name | `rustfs-groups` | | Token Claim Name | `groups` | | Full group path | `Off` (a leading `/` breaks policy matching) | | Add to ID token / access token / userinfo | `On` | | Multivalued | `On` | Create Keycloak groups named after RustFS policies (`consoleAdmin`, `readonly`, ...) and add users to them. Roles instead of groups: assign realm or client roles named after policies, add a `User Realm Role` or `User Client Role` mapper that emits a flat top-level `roles` claim, and set `RUSTFS_IDENTITY_OPENID_ROLES_CLAIM=roles`. RustFS does not read Keycloak's nested `realm_access.roles` claim. Internal discovery URL with a public issuer (for example in-cluster Keycloak on Kubernetes): ```bash export RUSTFS_IDENTITY_OPENID_CONFIG_URL="http://keycloak.keycloak.svc.cluster.local:8080/realms/rustfs" export RUSTFS_IDENTITY_OPENID_ISSUER="https://keycloak.example.com/realms/rustfs" export RUSTFS_OUTBOUND_ALLOW_ORIGINS="http://keycloak.keycloak.svc.cluster.local:8080" ``` Discovery and issuer-relative JWKS requests use the `CONFIG_URL` base; `iss` validation uses `ISSUER`. The allowlist entry is the origin only (no realm or discovery path) and is read at startup on every node. Prefer HTTPS with a trusted CA for the internal URL: discovery and JWKS define the token-signing trust root, so plain HTTP is acceptable only where DNS and traffic cannot be tampered with. ## Authing | Value | Example | Note | | --- | --- | --- | | Application domain | `https://example.authing.cn` | From the Authing application page. | | Issuer (`config_url`) | `https://example.authing.cn/oidc` | Tenants differ (`/oidc`, `/oauth/oidc`): copy the issuer from the console and confirm discovery returns the same `issuer`. | | App ID / App Secret | `` / `` | RustFS `client_id` / `client_secret`. | | Scopes | `openid,profile,email,roles` | `roles` is needed when Authing emits role claims. | | Roles claim | `roles` | Set `RUSTFS_IDENTITY_OPENID_ROLES_CLAIM=roles`. | Application settings in the Authing console: | Setting | Value | | --- | --- | | Protocol | OpenID Connect | | Grant type / response type | Authorization Code / `code` | | Token endpoint authentication | `client_secret_post` | | PKCE | allow or require `S256` | | ID token signing algorithm | `RS256` | | Redirect URL | `https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default` (exact) | Assign Authing roles whose names equal RustFS policy names; a test user with role `consoleAdmin` should produce `"roles": ["consoleAdmin"]` in the ID token. `claim_prefix` only prepends a fixed string, so keep role values equal to policy names unless policies with that prefix already exist.