fix(admin): support external OIDC browser redirects (#4280)

This commit is contained in:
GatewayJ
2026-07-09 18:07:49 +08:00
committed by GitHub
parent c8348f8b6f
commit d238b2d24e
6 changed files with 948 additions and 9 deletions
+4
View File
@@ -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`)
+7
View File
@@ -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 {
+240
View File
@@ -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 | `<AUTHING_APP_ID>` | RustFS `client_id`. |
| Authing App Secret | `<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="<AUTHING_ISSUER>"
export RUSTFS_IDENTITY_OPENID_CLIENT_ID="<AUTHING_APP_ID>"
export RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="<AUTHING_APP_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="<AUTHING_ISSUER>" \
client_id="<AUTHING_APP_ID>" \
client_secret="<AUTHING_APP_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="<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.
@@ -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 | `<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="<KEYCLOAK_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="<KEYCLOAK_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="<KEYCLOAK_CLIENT_SECRET>"
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.
@@ -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 <access_token>
```
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.
+272 -9
View File
@@ -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<Body>, provider_id: &str) -> S3Result<String> {
// 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<Body>,
provider_id: &str,
config: Option<&rustfs_iam::oidc::OidcProviderConfig>,
) -> S3Result<String> {
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<Body>, provider_id: &str) -> S3Result<Str
return Ok(uri.clone());
}
if let Some(url) = browser_redirect_url(&oidc_callback_path(provider_id))? {
return Ok(url);
}
if !config.redirect_uri_dynamic {
return Err(s3_error!(
InvalidRequest,
@@ -674,10 +699,18 @@ fn derive_callback_uri(req: &S3Request<Body>, provider_id: &str) -> S3Result<Str
}
}
if let Some(url) = browser_redirect_url(&oidc_callback_path(provider_id))? {
return Ok(url);
}
let scheme = extract_request_scheme(req)?;
let host = extract_request_host(req)?;
Ok(format!("{scheme}://{host}/rustfs/admin/v3/oidc/callback/{provider_id}"))
Ok(format!("{scheme}://{host}{}", oidc_callback_path(provider_id)))
}
fn oidc_callback_path(provider_id: &str) -> 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<String> {
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<Body>) -> S3Result<String> {
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<S3Response<(StatusCode, Body)>> {
@@ -1163,9 +1206,101 @@ fn parse_host_authority(raw_host: &str) -> S3Result<String> {
Ok(parsed.authority().to_string())
}
fn browser_redirect_url(path: &str) -> S3Result<Option<String>> {
let Some(base) = browser_redirect_base()? else {
return Ok(None);
};
Ok(Some(format!("{base}{path}")))
}
fn browser_redirect_base() -> S3Result<Option<String>> {
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<String> {
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<Body> {
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"));