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}