Skip to main content

rc_core/admin/
oidc.rs

1//! Typed contracts for RustFS OIDC administration.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use zeroize::Zeroize;
6
7use crate::{Error, Result};
8
9/// Maximum encoded size accepted for one OIDC administration response.
10pub const MAX_OIDC_RESPONSE_BYTES: usize = 1024 * 1024;
11
12/// Source that owns an effective OIDC provider configuration.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum OidcProviderSource {
16    Env,
17    Persisted,
18}
19
20/// Secret-free view of one effective RustFS OIDC provider.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct OidcProvider {
24    pub provider_id: String,
25    pub source: OidcProviderSource,
26    pub editable: bool,
27    pub enabled: bool,
28    pub display_name: String,
29    pub config_url: String,
30    pub issuer: Option<String>,
31    pub client_id: String,
32    pub client_secret_configured: bool,
33    pub scopes: Vec<String>,
34    pub other_audiences: Vec<String>,
35    pub redirect_uri: Option<String>,
36    pub redirect_uri_dynamic: bool,
37    pub claim_name: String,
38    pub claim_prefix: String,
39    pub role_policy: String,
40    pub groups_claim: String,
41    pub roles_claim: String,
42    pub email_claim: String,
43    pub username_claim: String,
44    pub hide_from_ui: bool,
45}
46
47impl OidcProvider {
48    /// Validate invariants that distinguish a real typed provider response from a placeholder.
49    pub fn validate_response(&self) -> Result<()> {
50        validate_provider_id(&self.provider_id)?;
51        validate_http_url(&self.config_url, "config_url")?;
52        if let Some(issuer) = self.issuer.as_deref() {
53            validate_http_url(issuer, "issuer")?;
54        }
55        if let Some(redirect_uri) = self.redirect_uri.as_deref() {
56            validate_http_url(redirect_uri, "redirect_uri")?;
57        }
58        if self.client_id.trim().is_empty() {
59            return Err(Error::General(
60                "OIDC provider response is missing client_id".to_string(),
61            ));
62        }
63        if !self.scopes.iter().any(|scope| scope == "openid") {
64            return Err(Error::General(
65                "OIDC provider response scopes do not include openid".to_string(),
66            ));
67        }
68        Ok(())
69    }
70}
71
72/// Effective OIDC provider collection and restart state.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct OidcProviderList {
76    pub providers: Vec<OidcProvider>,
77    pub restart_required: bool,
78}
79
80impl OidcProviderList {
81    pub fn validate_response(&self) -> Result<()> {
82        let mut ids = std::collections::BTreeSet::new();
83        for provider in &self.providers {
84            provider.validate_response()?;
85            if !ids.insert(provider.provider_id.as_str()) {
86                return Err(Error::General(
87                    "OIDC provider response contains duplicate provider IDs".to_string(),
88                ));
89            }
90        }
91        Ok(())
92    }
93}
94
95/// Secret-free OIDC discovery validation request.
96///
97/// RustFS discovery validation does not authenticate to the token endpoint, so this read-only
98/// contract deliberately has no client-secret field.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct OidcValidationRequest {
102    pub provider_id: String,
103    pub enabled: bool,
104    pub display_name: String,
105    pub config_url: String,
106    pub issuer: Option<String>,
107    pub client_id: String,
108    pub scopes: Vec<String>,
109    pub other_audiences: Vec<String>,
110    pub redirect_uri: Option<String>,
111    pub redirect_uri_dynamic: bool,
112    pub claim_name: String,
113    pub claim_prefix: String,
114    pub role_policy: String,
115    pub groups_claim: String,
116    pub roles_claim: String,
117    pub email_claim: String,
118    pub username_claim: String,
119    pub hide_from_ui: bool,
120}
121
122impl OidcValidationRequest {
123    pub fn new(provider_id: String, config_url: String, client_id: String) -> Self {
124        Self {
125            display_name: provider_id.clone(),
126            provider_id,
127            enabled: true,
128            config_url,
129            issuer: None,
130            client_id,
131            scopes: vec![
132                "openid".to_string(),
133                "profile".to_string(),
134                "email".to_string(),
135            ],
136            other_audiences: Vec::new(),
137            redirect_uri: None,
138            redirect_uri_dynamic: true,
139            claim_name: "policy".to_string(),
140            claim_prefix: String::new(),
141            role_policy: String::new(),
142            groups_claim: "groups".to_string(),
143            roles_claim: "roles".to_string(),
144            email_claim: "email".to_string(),
145            username_claim: "preferred_username".to_string(),
146            hide_from_ui: false,
147        }
148    }
149
150    /// Build a secret-free validation request from a complete mutation request.
151    pub fn from_mutation(request: &OidcMutationRequest) -> Self {
152        Self {
153            provider_id: request.provider_id.clone(),
154            enabled: request.enabled,
155            display_name: request.display_name.clone(),
156            config_url: request.config_url.clone(),
157            issuer: request.issuer.clone(),
158            client_id: request.client_id.clone(),
159            scopes: request.scopes.clone(),
160            other_audiences: request.other_audiences.clone(),
161            redirect_uri: request.redirect_uri.clone(),
162            redirect_uri_dynamic: request.redirect_uri_dynamic,
163            claim_name: request.claim_name.clone(),
164            claim_prefix: request.claim_prefix.clone(),
165            role_policy: request.role_policy.clone(),
166            groups_claim: request.groups_claim.clone(),
167            roles_claim: request.roles_claim.clone(),
168            email_claim: request.email_claim.clone(),
169            username_claim: request.username_claim.clone(),
170            hide_from_ui: request.hide_from_ui,
171        }
172    }
173
174    pub fn validate(&self) -> Result<()> {
175        validate_provider_id(&self.provider_id)?;
176        validate_http_url(&self.config_url, "config_url")?;
177        if let Some(issuer) = self.issuer.as_deref() {
178            validate_http_url(issuer, "issuer")?;
179        }
180        if self.client_id.trim().is_empty() {
181            return Err(Error::InvalidPath(
182                "OIDC client ID cannot be empty".to_string(),
183            ));
184        }
185        if !self.scopes.iter().any(|scope| scope == "openid") {
186            return Err(Error::InvalidPath(
187                "OIDC scopes must include openid".to_string(),
188            ));
189        }
190        if !self.redirect_uri_dynamic && self.redirect_uri.is_none() {
191            return Err(Error::InvalidPath(
192                "OIDC redirect URI is required when dynamic redirect is disabled".to_string(),
193            ));
194        }
195        if let Some(redirect_uri) = self.redirect_uri.as_deref() {
196            validate_http_url(redirect_uri, "redirect_uri")?;
197        }
198        Ok(())
199    }
200}
201
202/// Result of a live, non-mutating OIDC discovery validation.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct OidcValidationResult {
206    pub valid: bool,
207    pub message: String,
208    pub issuer: Option<String>,
209    pub authorization_endpoint: Option<String>,
210    pub token_endpoint: Option<String>,
211}
212
213impl OidcValidationResult {
214    pub fn validate_response(&self) -> Result<()> {
215        if !self.valid {
216            return Err(Error::General(
217                "OIDC validation returned an unsuccessful result".to_string(),
218            ));
219        }
220        for (value, field) in [
221            (self.issuer.as_deref(), "issuer"),
222            (
223                self.authorization_endpoint.as_deref(),
224                "authorization_endpoint",
225            ),
226            (self.token_endpoint.as_deref(), "token_endpoint"),
227        ] {
228            if let Some(value) = value {
229                validate_http_url(value, field)?;
230            }
231        }
232        if self.issuer.is_none() || self.authorization_endpoint.is_none() {
233            return Err(Error::General(
234                "OIDC validation response is incomplete".to_string(),
235            ));
236        }
237        Ok(())
238    }
239}
240
241/// Complete provider document accepted by RustFS' OIDC upsert endpoint.
242///
243/// The server treats an omitted `client_secret` as "preserve the currently persisted secret".
244/// Debug output is implemented manually so accidental diagnostics cannot expose a replacement.
245#[derive(Clone, PartialEq, Eq, Serialize)]
246#[serde(deny_unknown_fields)]
247pub struct OidcMutationRequest {
248    #[serde(skip)]
249    pub provider_id: String,
250    pub enabled: bool,
251    pub display_name: String,
252    pub config_url: String,
253    pub issuer: Option<String>,
254    pub client_id: String,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub client_secret: Option<String>,
257    pub scopes: Vec<String>,
258    pub other_audiences: Vec<String>,
259    pub redirect_uri: Option<String>,
260    pub redirect_uri_dynamic: bool,
261    pub claim_name: String,
262    pub claim_prefix: String,
263    pub role_policy: String,
264    pub groups_claim: String,
265    pub roles_claim: String,
266    pub email_claim: String,
267    pub username_claim: String,
268    pub hide_from_ui: bool,
269}
270
271impl std::fmt::Debug for OidcMutationRequest {
272    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        formatter
274            .debug_struct("OidcMutationRequest")
275            .field("provider_id", &self.provider_id)
276            .field("enabled", &self.enabled)
277            .field("display_name", &self.display_name)
278            .field("config_url", &self.config_url)
279            .field("issuer", &self.issuer)
280            .field("client_id", &self.client_id)
281            .field(
282                "client_secret",
283                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
284            )
285            .field("scopes", &self.scopes)
286            .field("other_audiences", &self.other_audiences)
287            .field("redirect_uri", &self.redirect_uri)
288            .field("redirect_uri_dynamic", &self.redirect_uri_dynamic)
289            .field("claim_name", &self.claim_name)
290            .field("claim_prefix", &self.claim_prefix)
291            .field("role_policy", &self.role_policy)
292            .field("groups_claim", &self.groups_claim)
293            .field("roles_claim", &self.roles_claim)
294            .field("email_claim", &self.email_claim)
295            .field("username_claim", &self.username_claim)
296            .field("hide_from_ui", &self.hide_from_ui)
297            .finish()
298    }
299}
300
301impl Drop for OidcMutationRequest {
302    fn drop(&mut self) {
303        if let Some(secret) = &mut self.client_secret {
304            secret.zeroize();
305        }
306    }
307}
308
309impl OidcMutationRequest {
310    pub fn new(provider_id: String, config_url: String, client_id: String) -> Self {
311        Self {
312            display_name: provider_id.clone(),
313            provider_id,
314            enabled: true,
315            config_url,
316            issuer: None,
317            client_id,
318            client_secret: None,
319            scopes: vec![
320                "openid".to_string(),
321                "profile".to_string(),
322                "email".to_string(),
323            ],
324            other_audiences: Vec::new(),
325            redirect_uri: None,
326            redirect_uri_dynamic: true,
327            claim_name: "policy".to_string(),
328            claim_prefix: String::new(),
329            role_policy: String::new(),
330            groups_claim: "groups".to_string(),
331            roles_claim: "roles".to_string(),
332            email_claim: "email".to_string(),
333            username_claim: "preferred_username".to_string(),
334            hide_from_ui: false,
335        }
336    }
337
338    pub fn from_provider(provider: &OidcProvider) -> Self {
339        Self {
340            provider_id: provider.provider_id.clone(),
341            enabled: provider.enabled,
342            display_name: provider.display_name.clone(),
343            config_url: provider.config_url.clone(),
344            issuer: provider.issuer.clone(),
345            client_id: provider.client_id.clone(),
346            client_secret: None,
347            scopes: provider.scopes.clone(),
348            other_audiences: provider.other_audiences.clone(),
349            redirect_uri: provider.redirect_uri.clone(),
350            redirect_uri_dynamic: provider.redirect_uri_dynamic,
351            claim_name: provider.claim_name.clone(),
352            claim_prefix: provider.claim_prefix.clone(),
353            role_policy: provider.role_policy.clone(),
354            groups_claim: provider.groups_claim.clone(),
355            roles_claim: provider.roles_claim.clone(),
356            email_claim: provider.email_claim.clone(),
357            username_claim: provider.username_claim.clone(),
358            hide_from_ui: provider.hide_from_ui,
359        }
360    }
361
362    pub fn validate(&self) -> Result<()> {
363        OidcValidationRequest::from_mutation(self).validate()?;
364        if self.display_name.trim().is_empty() {
365            return Err(Error::InvalidPath(
366                "OIDC display name cannot be empty".to_string(),
367            ));
368        }
369        if self
370            .client_secret
371            .as_ref()
372            .is_some_and(|secret| secret.is_empty())
373        {
374            return Err(Error::InvalidPath(
375                "OIDC client secret cannot be empty".to_string(),
376            ));
377        }
378        Ok(())
379    }
380}
381
382/// Result returned after a retry-safe OIDC provider upsert.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct OidcMutationResult {
386    pub success: bool,
387    pub message: String,
388    pub restart_required: bool,
389}
390
391impl OidcMutationResult {
392    pub fn validate_response(&self) -> Result<()> {
393        if !self.success {
394            return Err(Error::General(
395                "RustFS reported an unsuccessful OIDC mutation".to_string(),
396            ));
397        }
398        if self.message.trim().is_empty() {
399            return Err(Error::General(
400                "RustFS returned an incomplete OIDC mutation response".to_string(),
401            ));
402        }
403        Ok(())
404    }
405}
406
407#[async_trait]
408pub trait OidcReadApi: Send + Sync {
409    async fn oidc_list_providers(&self) -> Result<OidcProviderList>;
410    async fn oidc_get_provider(&self, provider_id: &str) -> Result<OidcProvider>;
411    async fn oidc_validate(&self, request: OidcValidationRequest) -> Result<OidcValidationResult>;
412}
413
414#[async_trait]
415pub trait OidcMutationApi: OidcReadApi {
416    async fn oidc_upsert_provider(
417        &self,
418        request: OidcMutationRequest,
419    ) -> Result<OidcMutationResult>;
420
421    /// Delete one persisted provider by exact ID.
422    ///
423    /// Implementations must reject invalid IDs locally and preserve not-found semantics so
424    /// callers can distinguish a completed retry from an unavailable route.
425    async fn oidc_delete_provider(&self, provider_id: &str) -> Result<OidcMutationResult>;
426}
427
428fn validate_provider_id(provider_id: &str) -> Result<()> {
429    if provider_id.is_empty()
430        || !provider_id
431            .chars()
432            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
433    {
434        return Err(Error::InvalidPath(
435            "OIDC provider ID must contain only ASCII letters, digits, '_' or '-'".to_string(),
436        ));
437    }
438    Ok(())
439}
440
441fn validate_http_url(value: &str, field: &str) -> Result<()> {
442    let parsed = url::Url::parse(value)
443        .map_err(|_| Error::InvalidPath(format!("OIDC {field} must be an absolute HTTP URL")))?;
444    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
445        return Err(Error::InvalidPath(format!(
446            "OIDC {field} must be an absolute HTTP URL"
447        )));
448    }
449    Ok(())
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn validation_request_is_secret_free_and_checks_openid_scope() {
458        let mut request = OidcValidationRequest::new(
459            "corp".to_string(),
460            "https://idp.example".to_string(),
461            "console".to_string(),
462        );
463        assert!(request.validate().is_ok());
464        request.scopes = vec!["profile".to_string()];
465        assert!(matches!(request.validate(), Err(Error::InvalidPath(_))));
466    }
467
468    #[test]
469    fn provider_list_rejects_duplicates_and_incomplete_placeholders() {
470        let provider = OidcProvider {
471            provider_id: "corp".to_string(),
472            source: OidcProviderSource::Persisted,
473            editable: true,
474            enabled: true,
475            display_name: "Corporate".to_string(),
476            config_url: "https://idp.example".to_string(),
477            issuer: Some("https://idp.example".to_string()),
478            client_id: "console".to_string(),
479            client_secret_configured: true,
480            scopes: vec!["openid".to_string()],
481            other_audiences: Vec::new(),
482            redirect_uri: None,
483            redirect_uri_dynamic: true,
484            claim_name: "policy".to_string(),
485            claim_prefix: String::new(),
486            role_policy: String::new(),
487            groups_claim: "groups".to_string(),
488            roles_claim: "roles".to_string(),
489            email_claim: "email".to_string(),
490            username_claim: "preferred_username".to_string(),
491            hide_from_ui: false,
492        };
493        assert!(
494            OidcProviderList {
495                providers: vec![provider.clone()],
496                restart_required: false,
497            }
498            .validate_response()
499            .is_ok()
500        );
501        assert!(
502            OidcProviderList {
503                providers: vec![provider.clone(), provider],
504                restart_required: false,
505            }
506            .validate_response()
507            .is_err()
508        );
509    }
510
511    #[test]
512    fn mutation_request_preserves_secret_by_omission_and_redacts_debug() {
513        let mut request = OidcMutationRequest::new(
514            "corp".to_string(),
515            "https://idp.example".to_string(),
516            "console".to_string(),
517        );
518        let without_secret = serde_json::to_value(&request).expect("serialize request");
519        assert!(without_secret.get("client_secret").is_none());
520
521        let secret = ["runtime", "replacement"].join("-");
522        request.client_secret = Some(secret.clone());
523        let debug = format!("{request:?}");
524        assert!(!debug.contains(&secret));
525        assert!(debug.contains("[REDACTED]"));
526        let validation = OidcValidationRequest::from_mutation(&request);
527        let validation_json = serde_json::to_string(&validation).expect("serialize validation");
528        assert!(!validation_json.contains(&secret));
529        assert!(!validation_json.contains("client_secret"));
530    }
531
532    #[test]
533    fn mutation_response_rejects_unsuccessful_or_incomplete_results() {
534        assert!(
535            OidcMutationResult {
536                success: true,
537                message: "saved".to_string(),
538                restart_required: true,
539            }
540            .validate_response()
541            .is_ok()
542        );
543        assert!(
544            OidcMutationResult {
545                success: false,
546                message: "not saved".to_string(),
547                restart_required: false,
548            }
549            .validate_response()
550            .is_err()
551        );
552    }
553}