mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
fix(admin): support external OIDC browser redirects (#4280)
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user