1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use zeroize::Zeroize;
6
7use crate::{Error, Result};
8
9pub const MAX_OIDC_RESPONSE_BYTES: usize = 1024 * 1024;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum OidcProviderSource {
16 Env,
17 Persisted,
18}
19
20#[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 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#[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#[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 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#[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#[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#[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 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}