From d238b2d24e031480bc903cbff1bcf841937f176a Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 9 Jul 2026 18:07:49 +0800 Subject: [PATCH] fix(admin): support external OIDC browser redirects (#4280) --- crates/config/README.md | 4 + crates/config/src/constants/app.rs | 7 + docs/operations/authing-oidc-integration.md | 240 +++++++++++++++ docs/operations/keycloak-oidc-integration.md | 291 ++++++++++++++++++ .../openid-compatibility-issues-for-vendor.md | 134 ++++++++ rustfs/src/admin/handlers/oidc.rs | 281 ++++++++++++++++- 6 files changed, 948 insertions(+), 9 deletions(-) create mode 100644 docs/operations/authing-oidc-integration.md create mode 100644 docs/operations/keycloak-oidc-integration.md create mode 100644 docs/operations/openid-compatibility-issues-for-vendor.md diff --git a/crates/config/README.md b/crates/config/README.md index e5fae0cae..573153824 100644 --- a/crates/config/README.md +++ b/crates/config/README.md @@ -62,6 +62,10 @@ Current guidance: - `RUSTFS_CORS_ALLOWED_ORIGINS` defaults to empty, so the S3 endpoint emits no generic CORS headers unless configured. Set `*` for wildcard origins without credentials, or a comma-separated allow-list for credentialed explicit origins. - `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` defaults to `*` for the console service. +## Browser redirect environment variables + +- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node. + ## Scanner environment aliases - `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`) diff --git a/crates/config/src/constants/app.rs b/crates/config/src/constants/app.rs index 2ea351ff5..0f9db4b7d 100644 --- a/crates/config/src/constants/app.rs +++ b/crates/config/src/constants/app.rs @@ -174,6 +174,12 @@ pub const ENV_RUSTFS_CONSOLE_ENABLE: &str = "RUSTFS_CONSOLE_ENABLE"; /// Environment variable for console server address. pub const ENV_RUSTFS_CONSOLE_ADDRESS: &str = "RUSTFS_CONSOLE_ADDRESS"; +/// Public browser entrypoint used to build OIDC callback and console redirects. +/// +/// This should be the externally reachable scheme and authority, without a path. +/// Example: `RUSTFS_BROWSER_REDIRECT_URL=https://console.example.com`. +pub const ENV_RUSTFS_BROWSER_REDIRECT_URL: &str = "RUSTFS_BROWSER_REDIRECT_URL"; + /// Environment variable for server tls path. pub const ENV_RUSTFS_TLS_PATH: &str = "RUSTFS_TLS_PATH"; @@ -348,6 +354,7 @@ mod tests { RUSTFS_TLS_CERT, DEFAULT_ADDRESS, DEFAULT_CONSOLE_ADDRESS, + ENV_RUSTFS_BROWSER_REDIRECT_URL, ]; for constant in &string_constants { diff --git a/docs/operations/authing-oidc-integration.md b/docs/operations/authing-oidc-integration.md new file mode 100644 index 000000000..fe6a036cc --- /dev/null +++ b/docs/operations/authing-oidc-integration.md @@ -0,0 +1,240 @@ +# Authing OIDC Integration Runbook + +This runbook helps operators connect the RustFS Console to Authing through standard OpenID Connect. The examples use the default RustFS provider id, `default`. + +## 1. Integration Model + +RustFS expects a standards-compliant OpenID Connect provider, not an Authing-specific plugin. The Authing application must provide: + +- issuer metadata through `.well-known/openid-configuration` +- authorization endpoint +- token endpoint +- JWKS or another verifiable ID token signature path +- authorization-code flow that returns an `id_token` + +The RustFS browser login flow is: + +1. The user opens the RustFS OIDC authorize endpoint. +2. RustFS creates `state`, `nonce`, and a PKCE S256 challenge. +3. The browser is redirected to Authing. +4. Authing redirects back to RustFS with `code` and `state`. +5. RustFS exchanges the code with `client_id`, `client_secret`, and the PKCE verifier. +6. RustFS validates the ID token signature, issuer, audience, expiry, and nonce. +7. RustFS reads identity and authorization claims from the ID token. +8. RustFS maps claim values to RustFS policy names and issues one-hour STS credentials for the Console. + +## 2. Required Values + +Collect these values before deployment: + +| Value | Example | Notes | +| --- | --- | --- | +| Public RustFS browser origin | `https://rustfs.example.com` | The scheme and authority users open in the browser. | +| Provider id | `default` | This runbook uses the default provider. | +| RustFS callback URL | `https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default` | Register this exact URL in Authing. | +| Authing application domain | `https://example.authing.cn` | Use the value shown in the Authing application. | +| Authing issuer | `https://example.authing.cn/oidc` | Copy the issuer from Authing; do not guess the path. | +| Authing App ID | `` | RustFS `client_id`. | +| Authing App Secret | `` | RustFS `client_secret`. | +| RustFS scopes | `openid,profile,email,roles` | `openid` is required; include `roles` when Authing emits role claims. | + +Authing deployments can use different issuer paths, such as `/oidc` or `/oauth/oidc`. Always copy the issuer from the Authing console and verify that discovery returns the same `issuer` value. + +## 3. Authing Configuration + +### 3.1 Create the Application + +1. Open the Authing console. +2. Create a self-hosted application named `RustFS Console`. +3. Record the App ID, App Secret, application domain, issuer, and discovery URL. + +### 3.2 Configure OIDC + +Use these protocol settings: + +| Setting | Value | +| --- | --- | +| Protocol | OpenID Connect | +| Grant type | Authorization Code | +| Response type | `code` | +| Token endpoint authentication | `client_secret_post` | +| PKCE | Allow or require `S256` | +| ID token signing algorithm | `RS256` recommended | + +RustFS sends the client secret in the request body. Do not configure Authing to reject `client_secret_post`. + +### 3.3 Register the Redirect URL + +Add this exact callback URL in Authing: + +```text +https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default +``` + +The scheme, host, port, path, and provider id must match the RustFS configuration. + +### 3.4 Map Roles to RustFS Policies + +RustFS does not call Authing authorization APIs. It reads `roles` or `groups` from the ID token and maps each value to a RustFS policy name. + +Recommended policy names: + +| Authing claim value | RustFS policy | Purpose | +| --- | --- | --- | +| `consoleAdmin` | `consoleAdmin` | Full Console, admin, KMS, and S3 access. | +| `readwrite` | `readwrite` | S3 read/write access. | +| `readonly` | `readonly` | S3 read-only access. | +| `writeonly` | `writeonly` | S3 write-only access. | +| `diagnostics` | `diagnostics` | Diagnostic admin access. | + +For initial validation, assign a test user the `consoleAdmin` role and confirm that the ID token contains: + +```json +{ + "roles": ["consoleAdmin"] +} +``` + +`claim_prefix` only prepends a fixed string. It does not perform arbitrary role mapping. Keep Authing role values equal to RustFS policy names unless you already created policies with a fixed prefix. + +## 4. RustFS Configuration + +### 4.1 Environment Variables + +Set the OIDC provider and the public browser origin: + +```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,roles" +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="Authing" +export RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM="email" +export RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM="preferred_username" +export RUSTFS_IDENTITY_OPENID_ROLES_CLAIM="roles" +``` + +For short-lived connectivity testing only, you may temporarily add: + +```bash +export RUSTFS_IDENTITY_OPENID_ROLE_POLICY="consoleAdmin" +``` + +Do not keep `role_policy=consoleAdmin` in production unless every Authing user for this client should receive full Console access. + +Restart RustFS after changing OIDC settings. + +### 4.2 Admin Config + +If the deployment manages OIDC through compatible admin configuration commands, set the provider like this: + +```bash +mc admin config set rustfs identity_openid \ + enable=on \ + config_url="" \ + client_id="" \ + client_secret="" \ + scopes="openid,profile,email,roles" \ + redirect_uri="https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default" \ + redirect_uri_dynamic=off \ + display_name="Authing" \ + email_claim="email" \ + username_claim="preferred_username" \ + roles_claim="roles" + +mc admin service restart rustfs +``` + +`RUSTFS_BROWSER_REDIRECT_URL` is a process environment variable, not an `identity_openid` provider key. Configure it in the RustFS service environment even when the provider itself is stored through admin config. + +### 4.3 Redirect URL Priority + +RustFS builds browser-facing URLs with this priority: + +1. Provider `redirect_uri`, when configured, is used for the OIDC callback URL sent to Authing. +2. `RUSTFS_BROWSER_REDIRECT_URL`, when configured, is used as the public origin for OIDC callback generation when no provider `redirect_uri` exists, and for Console success redirects and logout fallback redirects. +3. Request headers are used only when provider dynamic redirects are enabled and no browser redirect URL is configured. + +For reverse-proxy or load-balancer deployments, set `RUSTFS_BROWSER_REDIRECT_URL` to avoid depending on `Host` and `X-Forwarded-Proto` for Console redirects. OIDC authorize and callback requests must still reach the same RustFS node because in-flight OIDC `state` is local to the node. + +## 5. Validation + +### 5.1 Validate Authing Discovery + +```bash +AUTHING_ISSUER="" + +curl -fsS "$AUTHING_ISSUER/.well-known/openid-configuration" | jq '{ + issuer, + authorization_endpoint, + token_endpoint, + jwks_uri, + id_token_signing_alg_values_supported, + code_challenge_methods_supported, + token_endpoint_auth_methods_supported, + scopes_supported +}' +``` + +Check that: + +- `issuer` exactly matches `RUSTFS_IDENTITY_OPENID_CONFIG_URL` +- `authorization_endpoint`, `token_endpoint`, and `jwks_uri` are present +- `code_challenge_methods_supported` includes `S256` +- `token_endpoint_auth_methods_supported` includes `client_secret_post` +- `scopes_supported` includes `openid`, `profile`, `email`, and any role scope you need + +### 5.2 Validate RustFS Provider Visibility + +```bash +curl -fsS "https://rustfs.example.com/rustfs/admin/v3/oidc/providers" | jq +``` + +The response should include the Authing provider unless `hide_from_ui` is enabled. + +### 5.3 Test Browser Login + +Open: + +```text +https://rustfs.example.com/rustfs/admin/v3/oidc/authorize/default +``` + +Expected flow: + +1. Browser redirects to Authing. +2. The user signs in. +3. Authing redirects to `/rustfs/admin/v3/oidc/callback/default?code=...&state=...`. +4. RustFS validates the ID token and issues STS credentials. +5. The browser lands on the RustFS Console and can use the expected permissions. + +## 6. Troubleshooting + +| Symptom | Common cause | Fix | +| --- | --- | --- | +| `/oidc/providers` does not show Authing | OIDC provider did not load, or RustFS was not restarted | Check environment variables and restart RustFS. | +| Authing reports redirect mismatch | Callback URL differs between Authing and RustFS | Use the exact `/rustfs/admin/v3/oidc/callback/default` URL. | +| RustFS reports missing `code` or `state` | Proxy dropped the query string | Preserve the full callback URL and query string. | +| Token exchange fails | Wrong client secret or unsupported token auth method | Confirm `client_secret_post` is allowed. | +| RustFS reports no `id_token` | Missing `openid` scope or non-OIDC OAuth flow | Include `openid` and use OIDC authorization code flow. | +| ID token verification fails | Issuer, audience, signing algorithm, or JWKS mismatch | Compare discovery metadata with RustFS config; prefer `RS256`. | +| Login succeeds but access is denied | No matching RustFS policy claim | Ensure `roles` or `groups` is in the ID token and equals a RustFS policy name. | +| Console redirects to an internal host | Missing `RUSTFS_BROWSER_REDIRECT_URL` or incorrect proxy headers | Set `RUSTFS_BROWSER_REDIRECT_URL` to the public browser origin. | +| Invalid or expired OIDC state | Callback reached a different RustFS node | Configure load-balancer session affinity for authorize and callback requests. | + +## 7. Production Checklist + +- [ ] RustFS and Authing use HTTPS. +- [ ] Authing redirect URL is exact, not a broad wildcard. +- [ ] `RUSTFS_BROWSER_REDIRECT_URL` is set to the public RustFS browser origin. +- [ ] `RUSTFS_IDENTITY_OPENID_REDIRECT_URI` matches the registered Authing callback URL. +- [ ] Authing emits role or group claims in the ID token. +- [ ] Claim values match RustFS policy names. +- [ ] `role_policy=consoleAdmin` is not used as a permanent production shortcut. +- [ ] The load balancer preserves query strings. +- [ ] OIDC authorize and callback requests have session affinity to the same RustFS node. diff --git a/docs/operations/keycloak-oidc-integration.md b/docs/operations/keycloak-oidc-integration.md new file mode 100644 index 000000000..cfc20968c --- /dev/null +++ b/docs/operations/keycloak-oidc-integration.md @@ -0,0 +1,291 @@ +# Keycloak OIDC Integration Runbook + +This runbook describes how to connect the RustFS Console to Keycloak by using OpenID Connect Authorization Code Flow. The examples use the default RustFS provider id, `default`. + +## 1. Integration Model + +RustFS supports standard OpenID Connect for Console login: + +- RustFS sends an authorization-code request with PKCE S256. +- Keycloak redirects back with `code` and `state`. +- RustFS exchanges the code at the token endpoint. +- RustFS requires an `id_token` and verifies signature, issuer, audience, expiry, and nonce. +- RustFS maps ID token claim values to local RustFS IAM policies. + +RustFS does not call Keycloak Authorization Services for object or admin authorization. Authorization is handled by RustFS policies after claims are mapped. + +## 2. Example Values + +Replace these values for your environment: + +| Value | Example | Notes | +| --- | --- | --- | +| Keycloak base URL | `https://keycloak.example.com` | Public Keycloak URL. | +| Realm | `rustfs` | Keycloak realm name. | +| Keycloak issuer | `https://keycloak.example.com/realms/rustfs` | RustFS `config_url`. | +| Discovery URL | `https://keycloak.example.com/realms/rustfs/.well-known/openid-configuration` | Used to validate metadata. | +| Public RustFS browser origin | `https://rustfs.example.com` | The scheme and authority users open in the browser. | +| Provider id | `default` | This runbook uses the default provider. | +| RustFS callback URL | `https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default` | Register this exact URL in Keycloak. | +| Keycloak client id | `rustfs-console` | OIDC client used by RustFS. | +| Keycloak client secret | `` | Confidential client secret. | +| RustFS scopes | `openid,profile,email` | Add custom scopes if they emit authorization claims. | +| RustFS groups claim | `groups` | Recommended flat array claim. | +| RustFS roles claim | `roles` | Optional flat array claim. | + +## 3. Keycloak Configuration + +### 3.1 Create or Select the Realm + +1. Open the Keycloak Admin Console. +2. Create or select the `rustfs` realm. +3. Verify discovery: + +```bash +curl -fsS "https://keycloak.example.com/realms/rustfs/.well-known/openid-configuration" \ + | jq '.issuer,.authorization_endpoint,.token_endpoint,.jwks_uri' +``` + +The `issuer` should be: + +```text +https://keycloak.example.com/realms/rustfs +``` + +### 3.2 Create the RustFS Client + +In the Keycloak Admin Console: + +1. Open `Clients` and create a client. +2. Set `Client type` to `OpenID Connect`. +3. Set `Client ID` to `rustfs-console`. +4. Enable `Client authentication`. +5. Enable `Standard flow`. +6. Disable unused flows such as `Implicit flow`, `Direct access grants`, and `Service accounts roles`. +7. Set `Valid redirect URIs` to: + +```text +https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default +``` + +8. Set `Web origins` to: + +```text +https://rustfs.example.com +``` + +9. Set `Proof Key for Code Exchange Code Challenge Method` to `S256`. +10. Save and copy the client secret from `Credentials`. + +RustFS submits the client secret in the token request body. Do not use a client policy that disables `client_secret_post`. + +### 3.3 Map Groups or Roles to RustFS Policies + +RustFS policy names are the final authorization source. Common built-in policies are: + +| Policy | Purpose | +| --- | --- | +| `consoleAdmin` | Full Console, admin, KMS, and S3 access. | +| `readwrite` | S3 read/write access. | +| `readonly` | S3 read-only access. | +| `writeonly` | S3 write-only access. | +| `diagnostics` | Diagnostic admin access. | + +Recommended production setup: + +1. Create Keycloak groups such as `consoleAdmin` and `readonly`. +2. Add users to the groups. +3. Add a group membership mapper for the `rustfs-console` client. +4. Emit a flat top-level ID token claim named `groups`. +5. Keep group values equal to RustFS policy names. + +### 3.4 Group Claim Mapper + +Create a `Group Membership` mapper in the dedicated client scope: + +| Mapper field | Value | +| --- | --- | +| Name | `rustfs-groups` | +| Token Claim Name | `groups` | +| Full group path | `Off` | +| Add to ID token | `On` | +| Add to access token | `On` | +| Add to userinfo | `On` | +| Multivalued | `On` | + +Keep `Full group path` disabled. RustFS policy names cannot contain `/`, so `/consoleAdmin` will not map to the `consoleAdmin` policy. + +### 3.5 Optional Role Claim Mapper + +If the deployment uses Keycloak roles: + +1. Assign realm or client roles such as `consoleAdmin`. +2. Add a `User Realm Role` or `User Client Role` mapper. +3. Emit a flat top-level claim named `roles`. +4. Set `RUSTFS_IDENTITY_OPENID_ROLES_CLAIM=roles`. + +RustFS does not parse Keycloak's default nested `realm_access.roles` claim. Emit a flat `roles` array when role mapping is required. + +## 4. RustFS Configuration + +### 4.1 Environment Variables + +Configure the provider and the public browser origin: + +```bash +export RUSTFS_BROWSER_REDIRECT_URL="https://rustfs.example.com" + +export RUSTFS_IDENTITY_OPENID_ENABLE=on +export RUSTFS_IDENTITY_OPENID_CONFIG_URL="https://keycloak.example.com/realms/rustfs" +export RUSTFS_IDENTITY_OPENID_CLIENT_ID="rustfs-console" +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="Keycloak" +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" +``` + +For short-lived connectivity testing only, you may temporarily add: + +```bash +export RUSTFS_IDENTITY_OPENID_ROLE_POLICY="consoleAdmin" +``` + +Do not use the temporary `role_policy` shortcut as a permanent production authorization model. + +Restart RustFS after changing OIDC settings. + +### 4.2 Admin Config + +If the deployment uses compatible admin configuration commands: + +```bash +mc admin config set rustfs identity_openid \ + enable=on \ + config_url="https://keycloak.example.com/realms/rustfs" \ + client_id="rustfs-console" \ + client_secret="" \ + scopes="openid,profile,email" \ + redirect_uri="https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default" \ + redirect_uri_dynamic=off \ + display_name="Keycloak" \ + groups_claim="groups" \ + roles_claim="roles" \ + email_claim="email" \ + username_claim="preferred_username" + +mc admin service restart rustfs +``` + +`RUSTFS_BROWSER_REDIRECT_URL` is a process environment variable, not an `identity_openid` provider key. Configure it in the RustFS service environment even when the provider is stored through admin config. + +### 4.3 Named Provider + +To use a provider id such as `keycloak`, register this callback URL in Keycloak: + +```text +https://rustfs.example.com/rustfs/admin/v3/oidc/callback/keycloak +``` + +Then suffix the provider-specific environment variables: + +```bash +export RUSTFS_IDENTITY_OPENID_ENABLE_keycloak=on +export RUSTFS_IDENTITY_OPENID_CONFIG_URL_keycloak="https://keycloak.example.com/realms/rustfs" +export RUSTFS_IDENTITY_OPENID_CLIENT_ID_keycloak="rustfs-console" +export RUSTFS_IDENTITY_OPENID_CLIENT_SECRET_keycloak="" +export RUSTFS_IDENTITY_OPENID_SCOPES_keycloak="openid,profile,email" +export RUSTFS_IDENTITY_OPENID_REDIRECT_URI_keycloak="https://rustfs.example.com/rustfs/admin/v3/oidc/callback/keycloak" +export RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC_keycloak=off +export RUSTFS_IDENTITY_OPENID_DISPLAY_NAME_keycloak="Keycloak" +export RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM_keycloak="groups" +``` + +`RUSTFS_BROWSER_REDIRECT_URL` remains global and is not suffixed per provider. + +### 4.4 Redirect URL Priority + +RustFS builds browser-facing URLs with this priority: + +1. Provider `redirect_uri`, when configured, is used for the OIDC callback URL sent to Keycloak. +2. `RUSTFS_BROWSER_REDIRECT_URL`, when configured, is used as the public origin for OIDC callback generation when no provider `redirect_uri` exists, and for Console success redirects and logout fallback redirects. +3. Request headers are used only when provider dynamic redirects are enabled and no browser redirect URL is configured. + +For reverse-proxy or load-balancer deployments, set `RUSTFS_BROWSER_REDIRECT_URL` to avoid depending on `Host` and `X-Forwarded-Proto` for Console redirects. OIDC authorize and callback requests must still reach the same RustFS node because in-flight OIDC `state` is local to the node. + +## 5. Validation + +### 5.1 Validate Discovery + +```bash +curl -fsS "https://keycloak.example.com/realms/rustfs/.well-known/openid-configuration" | jq '{ + issuer, + authorization_endpoint, + token_endpoint, + jwks_uri, + code_challenge_methods_supported, + token_endpoint_auth_methods_supported +}' +``` + +Check that: + +- `issuer` equals `RUSTFS_IDENTITY_OPENID_CONFIG_URL` +- `authorization_endpoint`, `token_endpoint`, and `jwks_uri` are present +- `code_challenge_methods_supported` includes `S256` +- the token endpoint accepts client secret authentication compatible with request-body submission + +### 5.2 Validate ID Token Claims + +After a test login, decode the ID token and confirm: + +- `iss` matches the Keycloak issuer +- `aud` includes the RustFS client id +- `email` and `preferred_username` are present when configured +- `groups` or `roles` contains RustFS policy names if fine-grained authorization is enabled + +### 5.3 Test Browser Login + +Open: + +```text +https://rustfs.example.com/rustfs/admin/v3/oidc/authorize/default +``` + +Expected flow: + +1. Browser redirects to Keycloak. +2. The user signs in. +3. Keycloak redirects to `/rustfs/admin/v3/oidc/callback/default?code=...&state=...`. +4. RustFS validates the ID token and issues STS credentials. +5. The browser lands on the RustFS Console and can use the expected permissions. + +## 6. Troubleshooting + +| Symptom | Common cause | Fix | +| --- | --- | --- | +| Keycloak reports `invalid redirect_uri` | Valid Redirect URIs does not match the RustFS callback URL | Use the exact callback URL and provider id. | +| Callback reports missing `code` or `state` | Proxy dropped the query string | Preserve the full callback URL and query string. | +| Token exchange fails | Client secret or client authentication policy mismatch | Confirm the client is confidential and accepts request-body secret auth. | +| RustFS reports no `id_token` | Missing `openid` scope or disabled Standard Flow | Include `openid` and enable Standard Flow. | +| ID token verification fails | Issuer, client id, audience, or JWKS mismatch | Compare discovery metadata and client settings. | +| Login succeeds but access is denied | No RustFS policy claim was mapped | Emit `groups` or `roles` as a flat ID token claim matching RustFS policy names. | +| Groups appear as `/consoleAdmin` | Keycloak `Full group path` is enabled | Disable `Full group path`. | +| Console redirects to an internal host | Missing `RUSTFS_BROWSER_REDIRECT_URL` or incorrect proxy headers | Set `RUSTFS_BROWSER_REDIRECT_URL` to the public browser origin. | +| Invalid or expired OIDC state | Callback reached a different RustFS node | Configure load-balancer session affinity for authorize and callback requests. | + +## 7. Production Checklist + +- [ ] Keycloak and RustFS use HTTPS. +- [ ] Keycloak Valid Redirect URIs uses exact callback URLs. +- [ ] `RUSTFS_BROWSER_REDIRECT_URL` is set to the public RustFS browser origin. +- [ ] `RUSTFS_IDENTITY_OPENID_REDIRECT_URI` matches the registered Keycloak callback URL. +- [ ] PKCE S256 is enabled or required. +- [ ] Users receive `groups` or `roles` claims that match RustFS policy names. +- [ ] `role_policy=consoleAdmin` is not used as a permanent production shortcut. +- [ ] The load balancer preserves query strings. +- [ ] OIDC authorize and callback requests have session affinity to the same RustFS node. diff --git a/docs/operations/openid-compatibility-issues-for-vendor.md b/docs/operations/openid-compatibility-issues-for-vendor.md new file mode 100644 index 000000000..f184dd67d --- /dev/null +++ b/docs/operations/openid-compatibility-issues-for-vendor.md @@ -0,0 +1,134 @@ +# OIDC Vendor Compatibility Checklist + +Use this checklist when a vendor provides an OAuth or SSO document that is described as OIDC but does not clearly expose the standard OpenID Connect contract required by RustFS. + +## 1. Discovery Metadata + +RustFS expects provider metadata at: + +```text +GET {issuer}/.well-known/openid-configuration +``` + +Ask the vendor to provide the discovery URL and confirm that it returns at least: + +- `issuer` +- `authorization_endpoint` +- `token_endpoint` +- `jwks_uri` +- `response_types_supported` +- `subject_types_supported` +- `id_token_signing_alg_values_supported` + +The returned `issuer` must exactly match the issuer configured in RustFS. + +## 2. JWKS and Token Signature Verification + +RustFS must verify the ID token signature. Ask the vendor to provide: + +- `jwks_uri` +- supported signing algorithms, such as `RS256` +- key rotation behavior +- how the token `kid` maps to the JWKS key set + +Without a verifiable ID token signature, the provider is not suitable for RustFS OIDC login. + +## 3. Authorization Request Parameters + +The provider must accept the standard authorization-code request parameters: + +- `scope=openid profile email` +- `response_type=code` +- `client_id` +- `redirect_uri` +- `state` +- `nonce` +- `code_challenge` +- `code_challenge_method=S256` + +If the vendor example omits `state`, `nonce`, or PKCE, confirm whether those parameters are supported. + +## 4. Callback State + +The provider must return the original `state` value in the callback: + +```text +...?code=xxx&state=yyy +``` + +RustFS uses `state` for CSRF protection and to find the in-flight OIDC session. A callback that only returns `code` is not enough. + +## 5. Token Response + +The token endpoint response must be JSON and include at least: + +- `access_token` +- `token_type`, usually `Bearer` +- `expires_in` +- `id_token` + +RustFS requires `id_token`; an OAuth-only access token is not sufficient for Console OIDC login. + +## 6. ID Token Claims + +The ID token must contain standard claims that RustFS can verify: + +- `iss` +- `sub` +- `aud` +- `exp` +- `iat` +- `nonce` when the authorization request includes `nonce` + +Ask the vendor for a sample ID token payload and claim documentation. + +## 7. UserInfo Endpoint + +Standard OIDC UserInfo normally uses: + +```text +GET /userinfo +Authorization: Bearer +``` + +If the vendor only documents a private profile endpoint such as `/oidc/profile?access_token=...`, ask whether a standard `userinfo_endpoint` is available and returned in discovery. + +## 8. Logout Endpoint + +Standard RP-initiated logout is normally exposed through an `end_session_endpoint` in discovery. If the vendor only documents a private token removal endpoint, ask whether standard OIDC logout is available. + +RustFS can still fall back to the Console login page when the provider does not advertise an end-session endpoint. + +## 9. Authorization Claims + +OIDC primarily authenticates the user. RustFS authorization is still based on RustFS policies. The provider must emit claims that can be mapped to RustFS policies, for example: + +- `groups` +- `roles` +- `policy` +- another agreed flat array or string claim + +Ask the vendor to confirm: + +- whether group, role, or policy claims can be included in the ID token +- whether those claims can be included in UserInfo +- the exact claim names and value formats +- whether the claim values can match RustFS policy names such as `consoleAdmin`, `readwrite`, or `readonly` + +If the provider only returns a user id or token validity result, it can authenticate the user but cannot by itself express RustFS authorization. + +## 10. RustFS Redirect Requirements + +RustFS browser-facing redirect behavior depends on these values: + +- provider `redirect_uri`, when explicitly configured, is the callback URL sent to the provider +- `RUSTFS_BROWSER_REDIRECT_URL` is the public RustFS browser origin used for callback generation when no provider `redirect_uri` exists, and for Console success and logout fallback redirects +- dynamic request-header redirects are used only when no configured redirect source exists and dynamic redirects are enabled + +Ask the vendor to register the exact callback URL, for example: + +```text +https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default +``` + +For load-balanced RustFS deployments, ensure authorize and callback requests reach the same RustFS node while the OIDC `state` is in flight. diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 146b8de72..70d6a8599 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -20,7 +20,7 @@ use crate::admin::runtime_sources::{ }; use crate::admin::storage_api::config::{read_admin_config_without_migrate, save_admin_server_config}; use crate::auth::{check_key_valid, get_session_token}; -use crate::server::{ADMIN_PREFIX, MINIO_ADMIN_PREFIX, RemoteAddr}; +use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX, RemoteAddr}; use http::StatusCode; use hyper::Method; use matchit::Params; @@ -31,7 +31,7 @@ use rustfs_config::oidc::{ OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM, }; use rustfs_config::server_config::Config as ServerConfig; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; +use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_RUSTFS_BROWSER_REDIRECT_URL, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; use rustfs_policy::policy::action::{Action, AdminAction}; use rustfs_utils::egress::validate_outbound_url; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; @@ -49,6 +49,10 @@ const OIDC_PUBLIC_PROVIDERS_SUFFIX: &str = "/v3/oidc/providers"; const OIDC_AUTHORIZE_SUFFIX: &str = "/v3/oidc/authorize/"; const OIDC_CALLBACK_SUFFIX: &str = "/v3/oidc/callback/"; const OIDC_LOGOUT_SUFFIX: &str = "/v3/oidc/logout"; +const CONSOLE_OIDC_CALLBACK_SUFFIX: &str = "/auth/oidc-callback/"; +const CONSOLE_LOGIN_SUFFIX: &str = "/auth/login"; +const OIDC_STATE_LB_HINT: &str = + "check load balancer session affinity for OIDC authorize/callback requests or configure RUSTFS_BROWSER_REDIRECT_URL"; /// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen). fn is_valid_provider_id(id: &str) -> bool { @@ -538,6 +542,11 @@ impl Operation for OidcCallbackHandler { // Exchange authorization code for tokens and extract claims let (claims, actual_provider_id, session, id_token) = oidc_sys.exchange_code(&state, &code, &redirect_uri).await.map_err(|e| { + let lb_hint = if is_invalid_oidc_state_error(&e) { + OIDC_STATE_LB_HINT + } else { + "" + }; error!( event = EVENT_ADMIN_OIDC_STATE, component = LOG_COMPONENT_ADMIN, @@ -550,6 +559,7 @@ impl Operation for OidcCallbackHandler { code_len = code.len(), state_len = state.len(), error = %e, + lb_hint = %lb_hint, "admin oidc state" ); S3Error::with_message(S3ErrorCode::AccessDenied, format!("code exchange failed: {e}")) @@ -654,10 +664,21 @@ impl Operation for OidcLogoutHandler { /// from request headers. For production deployments behind a reverse proxy, configuring /// an explicit redirect_uri is recommended to prevent header manipulation. fn derive_callback_uri(req: &S3Request, provider_id: &str) -> S3Result { - // Use explicitly configured redirect_uri if available if let Some(oidc_sys) = current_oidc_handle() && let Some(config) = oidc_sys.get_provider_config(provider_id) { + return derive_callback_uri_with_provider_config(req, provider_id, Some(config)); + } + + derive_callback_uri_with_provider_config(req, provider_id, None) +} + +fn derive_callback_uri_with_provider_config( + req: &S3Request, + provider_id: &str, + config: Option<&rustfs_iam::oidc::OidcProviderConfig>, +) -> S3Result { + if let Some(config) = config { if let Some(ref uri) = config.redirect_uri { let parsed = Url::parse(uri).map_err(|_| s3_error!(InvalidRequest, "invalid configured redirect_uri"))?; if !is_valid_scheme(parsed.scheme()) || parsed.host_str().is_none() { @@ -666,6 +687,10 @@ fn derive_callback_uri(req: &S3Request, provider_id: &str) -> S3Result, provider_id: &str) -> S3Result String { + format!("{ADMIN_PREFIX}{OIDC_CALLBACK_SUFFIX}{provider_id}") } /// Extract a query parameter from the URI. @@ -755,19 +788,29 @@ fn build_console_redirect( redirect_after: Option<&str>, logout_token: Option<&str>, ) -> S3Result { - let scheme = extract_request_scheme(req)?; - let host = extract_request_host(req)?; - let console_prefix = "/rustfs/console"; let fragment = build_console_callback_fragment(access_key, secret_key, session_token, expiration, redirect_after, logout_token); - Ok(format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}")) + let callback_path = format!("{CONSOLE_PREFIX}{CONSOLE_OIDC_CALLBACK_SUFFIX}"); + if let Some(base_url) = browser_redirect_url(&callback_path)? { + return Ok(format!("{base_url}#{fragment}")); + } + + let scheme = extract_request_scheme(req)?; + let host = extract_request_host(req)?; + + Ok(format!("{scheme}://{host}{callback_path}#{fragment}")) } fn build_console_login_redirect(req: &S3Request) -> S3Result { + let login_path = format!("{CONSOLE_PREFIX}{CONSOLE_LOGIN_SUFFIX}"); + if let Some(url) = browser_redirect_url(&login_path)? { + return Ok(url); + } + let scheme = extract_request_scheme(req)?; let host = extract_request_host(req)?; - Ok(format!("{scheme}://{host}/rustfs/console/auth/login")) + Ok(format!("{scheme}://{host}{login_path}")) } fn redirect_response(location: &str) -> S3Result> { @@ -1163,9 +1206,101 @@ fn parse_host_authority(raw_host: &str) -> S3Result { Ok(parsed.authority().to_string()) } +fn browser_redirect_url(path: &str) -> S3Result> { + let Some(base) = browser_redirect_base()? else { + return Ok(None); + }; + Ok(Some(format!("{base}{path}"))) +} + +fn browser_redirect_base() -> S3Result> { + let Some(raw) = rustfs_utils::get_env_opt_str(ENV_RUSTFS_BROWSER_REDIRECT_URL) else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + validate_browser_redirect_base(trimmed).map(Some) +} + +fn validate_browser_redirect_base(raw_url: &str) -> S3Result { + let parsed = Url::parse(raw_url).map_err(|_| s3_error!(InvalidRequest, "invalid browser redirect URL"))?; + if !is_valid_scheme(parsed.scheme()) || parsed.host_str().is_none() { + return Err(s3_error!(InvalidRequest, "browser redirect URL must be an absolute http/https URL")); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(s3_error!(InvalidRequest, "browser redirect URL must not contain userinfo")); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(s3_error!(InvalidRequest, "browser redirect URL must not contain query or fragment")); + } + if !matches!(parsed.path(), "" | "/") { + return Err(s3_error!(InvalidRequest, "browser redirect URL must not contain a path")); + } + + Ok(format!("{}://{}", parsed.scheme(), parsed.authority())) +} + +fn is_invalid_oidc_state_error(error: &str) -> bool { + error.contains("invalid or expired OIDC state") +} + #[cfg(test)] mod tests { use super::*; + use http::{Extensions, HeaderMap, HeaderValue, Uri}; + use temp_env::with_var; + + fn build_oidc_request( + uri: &'static str, + host: Option<&'static str>, + forwarded_proto: Option<&'static str>, + ) -> S3Request { + let mut headers = HeaderMap::new(); + if let Some(host) = host { + headers.insert(http::header::HOST, HeaderValue::from_static(host)); + } + if let Some(proto) = forwarded_proto { + headers.insert("x-forwarded-proto", HeaderValue::from_static(proto)); + } + + S3Request { + input: Body::empty(), + method: Method::GET, + uri: Uri::from_static(uri), + headers, + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + fn test_provider_config(redirect_uri: Option<&str>, redirect_uri_dynamic: bool) -> rustfs_iam::oidc::OidcProviderConfig { + rustfs_iam::oidc::OidcProviderConfig { + id: "default".to_string(), + enabled: true, + config_url: "https://idp.example.com/.well-known/openid-configuration".to_string(), + client_id: "rustfs-console".to_string(), + client_secret: None, + scopes: vec!["openid".to_string()], + other_audiences: Vec::new(), + redirect_uri: redirect_uri.map(ToString::to_string), + redirect_uri_dynamic, + claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(), + claim_prefix: String::new(), + role_policy: String::new(), + display_name: "default".to_string(), + groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(), + roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), + email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), + username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), + hide_from_ui: false, + } + } #[test] fn test_is_oidc_path() { @@ -1263,6 +1398,134 @@ mod tests { assert!(fragment.contains("logoutToken=logout-token")); } + #[test] + fn test_validate_browser_redirect_base_accepts_origin() { + assert_eq!( + validate_browser_redirect_base("https://console.example.com/").expect("browser redirect origin should be valid"), + "https://console.example.com" + ); + assert_eq!( + validate_browser_redirect_base("http://20.78.1.4:9000").expect("browser redirect origin should be valid"), + "http://20.78.1.4:9000" + ); + } + + #[test] + fn test_validate_browser_redirect_base_rejects_unsafe_parts() { + assert!(validate_browser_redirect_base("ftp://console.example.com").is_err()); + assert!(validate_browser_redirect_base("https://user:pass@console.example.com").is_err()); + assert!(validate_browser_redirect_base("https://console.example.com/proxy").is_err()); + assert!(validate_browser_redirect_base("https://console.example.com?next=/").is_err()); + assert!(validate_browser_redirect_base("https://console.example.com/#fragment").is_err()); + } + + #[test] + fn test_derive_callback_uri_uses_browser_redirect_url() { + let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/authorize/default", Some("internal:9000"), None); + + let callback = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, Some("https://console.example.com"), || { + derive_callback_uri(&req, "default").expect("callback URI should use browser redirect URL") + }); + + assert_eq!(callback, "https://console.example.com/rustfs/admin/v3/oidc/callback/default"); + } + + #[test] + fn test_derive_callback_uri_falls_back_to_request_headers() { + let req = build_oidc_request( + "http://internal/rustfs/admin/v3/oidc/authorize/default", + Some("internal:9000"), + Some("https"), + ); + + let callback = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, None::<&str>, || { + derive_callback_uri(&req, "default").expect("callback URI should fall back to request headers") + }); + + assert_eq!(callback, "https://internal:9000/rustfs/admin/v3/oidc/callback/default"); + } + + #[test] + fn test_derive_callback_uri_configured_redirect_uri_wins() { + let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/authorize/default", Some("internal:9000"), None); + let config = test_provider_config(Some("https://configured.example.com/rustfs/admin/v3/oidc/callback/default"), false); + + let callback = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, Some("https://console.example.com"), || { + derive_callback_uri_with_provider_config(&req, "default", Some(&config)) + .expect("configured redirect_uri should be preferred") + }); + + assert_eq!(callback, "https://configured.example.com/rustfs/admin/v3/oidc/callback/default"); + } + + #[test] + fn test_derive_callback_uri_browser_redirect_url_satisfies_static_provider() { + let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/authorize/default", Some("internal:9000"), None); + let config = test_provider_config(None, false); + + let callback = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, Some("https://console.example.com"), || { + derive_callback_uri_with_provider_config(&req, "default", Some(&config)) + .expect("browser redirect URL should satisfy a non-dynamic provider") + }); + + assert_eq!(callback, "https://console.example.com/rustfs/admin/v3/oidc/callback/default"); + } + + #[test] + fn test_derive_callback_uri_static_provider_requires_redirect_source() { + let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/authorize/default", Some("internal:9000"), None); + let config = test_provider_config(None, false); + + let err = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, None::<&str>, || { + derive_callback_uri_with_provider_config(&req, "default", Some(&config)) + .expect_err("non-dynamic provider without redirect source should fail") + }); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn test_derive_callback_uri_dynamic_provider_falls_back_to_request_headers() { + let req = build_oidc_request( + "http://internal/rustfs/admin/v3/oidc/authorize/default", + Some("internal:9000"), + Some("https"), + ); + let config = test_provider_config(None, true); + + let callback = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, None::<&str>, || { + derive_callback_uri_with_provider_config(&req, "default", Some(&config)) + .expect("dynamic provider should fall back to request headers") + }); + + assert_eq!(callback, "https://internal:9000/rustfs/admin/v3/oidc/callback/default"); + } + + #[test] + fn test_build_console_redirect_uses_browser_redirect_url() { + let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/callback/default", Some("internal:9000"), None); + + let redirect = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, Some("https://console.example.com/"), || { + build_console_redirect(&req, "access", "secret", "token", None, Some("/buckets"), Some("logout-token")) + .expect("console redirect should use browser redirect URL") + }); + + assert!(redirect.starts_with("https://console.example.com/rustfs/console/auth/oidc-callback/#")); + assert!(redirect.contains("redirect=%2Fbuckets")); + assert!(redirect.contains("logoutToken=logout-token")); + } + + #[test] + fn test_build_console_login_redirect_uses_browser_redirect_url() { + let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/logout", Some("internal:9000"), None); + + let redirect = with_var(ENV_RUSTFS_BROWSER_REDIRECT_URL, Some("https://console.example.com"), || { + build_console_login_redirect(&req).expect("login redirect should use browser redirect URL") + }); + + assert_eq!(redirect, "https://console.example.com/rustfs/console/auth/login"); + } + #[test] fn test_is_oidc_path_includes_logout() { assert!(is_oidc_path("/rustfs/admin/v3/oidc/logout"));