Skip to main content

bucketwarden_auth/
providers.rs

1use crate::{
2    jwt::{sign_hs256_jwt, verify_hs256_jwt},
3    AuthError, JwtClaims, Principal, PrincipalKind,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8pub const IDENTITY_PROVIDER_RUNTIME_FEATURES: &[&str] = &[
9    "local-users",
10    "access-keys",
11    "oidc",
12    "ldap",
13    "active-directory",
14    "saml",
15];
16
17pub const IDENTITY_PROVIDER_ADMIN_SURFACES: &[&str] = &[
18    "AuthStore::upsert_identity_provider",
19    "AuthStore::identity_provider",
20    "AuthStore::assume_role_with_web_identity",
21];
22
23pub const IDENTITY_PROVIDER_SECURITY_CONTROLS: &[&str] = &[
24    "unknown provider rejection",
25    "typed principal creation",
26    "scoped session issuance",
27    "expired assertion rejection",
28    "disabled directory subject rejection",
29    "invalid secret/signature rejection",
30];
31
32pub const IDENTITY_PROVIDER_OBSERVABILITY_FIELDS: &[&str] = &[
33    "provider_id",
34    "principal_kind",
35    "principal_id",
36    "tenant_id",
37    "access_key_id",
38    "scope",
39    "expires_at_epoch_seconds",
40];
41
42pub const IDENTITY_PROVIDER_FAILURE_MODES: &[&str] = &[
43    "UnknownIdentityProvider",
44    "InvalidIdentityProviderToken",
45    "UnknownDirectorySubject",
46    "DisabledDirectorySubject",
47    "WebIdentityTokenExpired",
48    "WebIdentityIssuerMismatch",
49    "WebIdentityAudienceMismatch",
50    "WebIdentityKeyIdMismatch",
51];
52
53pub const IDENTITY_PROVIDER_VALIDATION_TESTS: &[&str] = &[
54    "crates/bucketwarden-auth/tests/identity_access_keys.rs",
55    "crates/bucketwarden-auth/tests/oidc_jwt_identity.rs",
56    "crates/bucketwarden-auth/tests/sts_scoped_sessions.rs",
57    "crates/bucketwarden-auth/tests/external_identity_providers.rs",
58];
59
60pub const IDENTITY_PROVIDER_CAVEATS: &[&str] = &[
61    "Directory providers use deterministic in-process fixtures; production LDAP/AD network binding remains a deployment integration.",
62    "SAML assertions use signed compact test assertions rather than XML canonicalization.",
63    "OIDC/SAML provider keys are local HS256 fixtures until external JWKS/key rotation is added.",
64];
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67pub struct IdentityProviderSupportReport {
68    pub native_support_state: Vec<&'static str>,
69    pub semantic_parity: &'static str,
70    pub configuration_admin_surface: Vec<&'static str>,
71    pub security_governance_impact: Vec<&'static str>,
72    pub observability_evidence_fields: Vec<&'static str>,
73    pub failure_modes: Vec<&'static str>,
74    pub validation_test_coverage: Vec<&'static str>,
75    pub product_specific_caveats: Vec<&'static str>,
76}
77
78impl IdentityProviderSupportReport {
79    pub fn current() -> Self {
80        Self {
81            native_support_state: IDENTITY_PROVIDER_RUNTIME_FEATURES.to_vec(),
82            semantic_parity: "All configured identity sources resolve to typed principals and scoped session credentials before authorization.",
83            configuration_admin_surface: IDENTITY_PROVIDER_ADMIN_SURFACES.to_vec(),
84            security_governance_impact: IDENTITY_PROVIDER_SECURITY_CONTROLS.to_vec(),
85            observability_evidence_fields: IDENTITY_PROVIDER_OBSERVABILITY_FIELDS.to_vec(),
86            failure_modes: IDENTITY_PROVIDER_FAILURE_MODES.to_vec(),
87            validation_test_coverage: IDENTITY_PROVIDER_VALIDATION_TESTS.to_vec(),
88            product_specific_caveats: IDENTITY_PROVIDER_CAVEATS.to_vec(),
89        }
90    }
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94pub enum IdentityProvider {
95    Oidc(OidcProvider),
96    Ldap(DirectoryProvider),
97    ActiveDirectory(DirectoryProvider),
98    Saml(SamlProvider),
99}
100
101impl IdentityProvider {
102    pub fn id(&self) -> &str {
103        match self {
104            Self::Oidc(provider) => &provider.provider_id,
105            Self::Ldap(provider) | Self::ActiveDirectory(provider) => &provider.provider_id,
106            Self::Saml(provider) => &provider.provider_id,
107        }
108    }
109
110    pub fn verify_subject(&self, token: &str, now_epoch_seconds: u64) -> Result<String, AuthError> {
111        match self {
112            Self::Oidc(provider) => provider.verify_subject(token, now_epoch_seconds),
113            Self::Ldap(provider) | Self::ActiveDirectory(provider) => {
114                provider.verify_subject(token)
115            }
116            Self::Saml(provider) => provider.verify_subject(token, now_epoch_seconds),
117        }
118    }
119
120    pub fn principal_id(&self, subject: &str) -> String {
121        match self {
122            Self::Oidc(provider) => provider.principal_id(subject),
123            Self::Ldap(provider) | Self::ActiveDirectory(provider) => {
124                provider.principal_id(subject)
125            }
126            Self::Saml(provider) => provider.principal_id(subject),
127        }
128    }
129
130    pub fn parent_secret(&self) -> String {
131        match self {
132            Self::Oidc(provider) => provider.parent_secret(),
133            Self::Ldap(provider) | Self::ActiveDirectory(provider) => provider.parent_secret(),
134            Self::Saml(provider) => provider.parent_secret(),
135        }
136    }
137
138    pub fn principal_kind(&self) -> PrincipalKind {
139        match self {
140            Self::Oidc(_) => PrincipalKind::FederatedWebIdentity,
141            Self::Ldap(_) => PrincipalKind::LdapUser,
142            Self::ActiveDirectory(_) => PrincipalKind::ActiveDirectoryUser,
143            Self::Saml(_) => PrincipalKind::SamlSubject,
144        }
145    }
146
147    pub fn principal(&self, subject: &str) -> Principal {
148        Principal::with_kind(
149            self.principal_id(subject),
150            self.principal_kind(),
151            crate::DEFAULT_TENANT_ID,
152        )
153    }
154}
155
156#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct OidcProvider {
158    pub provider_id: String,
159    pub issuer: String,
160    pub audience: String,
161    pub key_id: String,
162    shared_secret: String,
163    pub principal_prefix: String,
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167pub struct DirectoryUser {
168    pub subject: String,
169    shared_secret: String,
170    pub enabled: bool,
171}
172
173impl DirectoryUser {
174    pub fn active(subject: impl Into<String>, shared_secret: impl Into<String>) -> Self {
175        Self {
176            subject: subject.into(),
177            shared_secret: shared_secret.into(),
178            enabled: true,
179        }
180    }
181
182    pub fn disabled(subject: impl Into<String>, shared_secret: impl Into<String>) -> Self {
183        Self {
184            enabled: false,
185            ..Self::active(subject, shared_secret)
186        }
187    }
188}
189
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
191pub struct DirectoryProvider {
192    pub provider_id: String,
193    pub authority: String,
194    pub principal_prefix: String,
195    users: BTreeMap<String, DirectoryUser>,
196}
197
198impl DirectoryProvider {
199    pub fn ldap(
200        provider_id: impl Into<String>,
201        authority: impl Into<String>,
202        principal_prefix: impl Into<String>,
203    ) -> Self {
204        Self::new(provider_id, authority, principal_prefix)
205    }
206
207    pub fn active_directory(
208        provider_id: impl Into<String>,
209        authority: impl Into<String>,
210        principal_prefix: impl Into<String>,
211    ) -> Self {
212        Self::new(provider_id, authority, principal_prefix)
213    }
214
215    fn new(
216        provider_id: impl Into<String>,
217        authority: impl Into<String>,
218        principal_prefix: impl Into<String>,
219    ) -> Self {
220        Self {
221            provider_id: provider_id.into(),
222            authority: authority.into(),
223            principal_prefix: principal_prefix.into(),
224            users: BTreeMap::new(),
225        }
226    }
227
228    pub fn with_user(
229        mut self,
230        subject: impl Into<String>,
231        shared_secret: impl Into<String>,
232    ) -> Self {
233        let user = DirectoryUser::active(subject, shared_secret);
234        self.users.insert(user.subject.clone(), user);
235        self
236    }
237
238    pub fn with_disabled_user(
239        mut self,
240        subject: impl Into<String>,
241        shared_secret: impl Into<String>,
242    ) -> Self {
243        let user = DirectoryUser::disabled(subject, shared_secret);
244        self.users.insert(user.subject.clone(), user);
245        self
246    }
247
248    pub fn verify_subject(&self, token: &str) -> Result<String, AuthError> {
249        let (subject, shared_secret) = token.split_once(':').ok_or_else(|| {
250            AuthError::InvalidIdentityProviderToken("missing directory secret".into())
251        })?;
252        let user = self
253            .users
254            .get(subject)
255            .ok_or_else(|| AuthError::UnknownDirectorySubject(subject.to_string()))?;
256        if !user.enabled {
257            return Err(AuthError::DisabledDirectorySubject(subject.to_string()));
258        }
259        if user.shared_secret != shared_secret {
260            return Err(AuthError::InvalidIdentityProviderToken(
261                "directory secret mismatch".into(),
262            ));
263        }
264        Ok(subject.to_string())
265    }
266
267    pub fn principal_id(&self, subject: &str) -> String {
268        format!("{}{}", self.principal_prefix, subject)
269    }
270
271    pub fn parent_secret(&self) -> String {
272        format!("bucketwarden-directory-parent:{}", self.provider_id)
273    }
274}
275
276#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
277pub struct SamlProvider {
278    pub provider_id: String,
279    pub issuer: String,
280    pub audience: String,
281    pub key_id: String,
282    shared_secret: String,
283    pub principal_prefix: String,
284}
285
286impl SamlProvider {
287    pub fn hs256(
288        provider_id: impl Into<String>,
289        issuer: impl Into<String>,
290        audience: impl Into<String>,
291        key_id: impl Into<String>,
292        shared_secret: impl Into<String>,
293        principal_prefix: impl Into<String>,
294    ) -> Self {
295        Self {
296            provider_id: provider_id.into(),
297            issuer: issuer.into(),
298            audience: audience.into(),
299            key_id: key_id.into(),
300            shared_secret: shared_secret.into(),
301            principal_prefix: principal_prefix.into(),
302        }
303    }
304
305    pub fn sign_assertion(
306        &self,
307        subject: impl Into<String>,
308        expires_at_epoch_seconds: u64,
309        issued_at_epoch_seconds: u64,
310    ) -> Result<String, AuthError> {
311        let token = sign_hs256_jwt(
312            Some(&self.key_id),
313            &JwtClaims {
314                iss: self.issuer.clone(),
315                sub: subject.into(),
316                aud: vec![self.audience.clone()],
317                exp: Some(expires_at_epoch_seconds),
318                iat: Some(issued_at_epoch_seconds),
319            },
320            self.shared_secret.as_bytes(),
321        )?;
322        Ok(format!("saml:{token}"))
323    }
324
325    pub fn verify_subject(&self, token: &str, now_epoch_seconds: u64) -> Result<String, AuthError> {
326        let token = token
327            .strip_prefix("saml:")
328            .ok_or_else(|| AuthError::InvalidIdentityProviderToken("missing saml prefix".into()))?;
329        let (header, claims) = verify_hs256_jwt(token, self.shared_secret.as_bytes())?;
330        if header.kid.as_deref() != Some(self.key_id.as_str()) {
331            return Err(AuthError::WebIdentityKeyIdMismatch(
332                header.kid.unwrap_or_default(),
333            ));
334        }
335        if claims.iss != self.issuer {
336            return Err(AuthError::WebIdentityIssuerMismatch(claims.iss));
337        }
338        if !claims.aud.iter().any(|audience| audience == &self.audience) {
339            return Err(AuthError::WebIdentityAudienceMismatch(claims.aud.join(",")));
340        }
341        if claims
342            .exp
343            .is_some_and(|expires_at| now_epoch_seconds > expires_at)
344        {
345            return Err(AuthError::WebIdentityTokenExpired);
346        }
347        if claims.sub.is_empty() {
348            return Err(AuthError::InvalidIdentityProviderToken(
349                "missing saml subject".to_string(),
350            ));
351        }
352        Ok(claims.sub)
353    }
354
355    pub fn principal_id(&self, subject: &str) -> String {
356        format!("{}{}", self.principal_prefix, subject)
357    }
358
359    pub fn parent_secret(&self) -> String {
360        format!("bucketwarden-saml-parent:{}", self.provider_id)
361    }
362}
363
364impl OidcProvider {
365    pub fn hs256(
366        provider_id: impl Into<String>,
367        issuer: impl Into<String>,
368        audience: impl Into<String>,
369        key_id: impl Into<String>,
370        shared_secret: impl Into<String>,
371        principal_prefix: impl Into<String>,
372    ) -> Self {
373        Self {
374            provider_id: provider_id.into(),
375            issuer: issuer.into(),
376            audience: audience.into(),
377            key_id: key_id.into(),
378            shared_secret: shared_secret.into(),
379            principal_prefix: principal_prefix.into(),
380        }
381    }
382
383    pub fn verify_subject(&self, token: &str, now_epoch_seconds: u64) -> Result<String, AuthError> {
384        let (header, claims) = verify_hs256_jwt(token, self.shared_secret.as_bytes())?;
385        if header.kid.as_deref() != Some(self.key_id.as_str()) {
386            return Err(AuthError::WebIdentityKeyIdMismatch(
387                header.kid.unwrap_or_default(),
388            ));
389        }
390        if claims.iss != self.issuer {
391            return Err(AuthError::WebIdentityIssuerMismatch(claims.iss));
392        }
393        if !claims.aud.iter().any(|audience| audience == &self.audience) {
394            return Err(AuthError::WebIdentityAudienceMismatch(claims.aud.join(",")));
395        }
396        if claims
397            .exp
398            .is_some_and(|expires_at| now_epoch_seconds > expires_at)
399        {
400            return Err(AuthError::WebIdentityTokenExpired);
401        }
402        if claims.sub.is_empty() {
403            return Err(AuthError::InvalidWebIdentityToken(
404                "missing subject".to_string(),
405            ));
406        }
407        Ok(claims.sub)
408    }
409
410    pub fn principal_id(&self, subject: &str) -> String {
411        format!("{}{}", self.principal_prefix, subject)
412    }
413
414    pub fn parent_secret(&self) -> String {
415        format!("bucketwarden-web-identity-parent:{}", self.provider_id)
416    }
417}