use crate::{
jwt::{sign_hs256_jwt, verify_hs256_jwt},
AuthError, JwtClaims, Principal, PrincipalKind,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const IDENTITY_PROVIDER_RUNTIME_FEATURES: &[&str] = &[
"local-users",
"access-keys",
"oidc",
"ldap",
"active-directory",
"saml",
];
pub const IDENTITY_PROVIDER_ADMIN_SURFACES: &[&str] = &[
"AuthStore::upsert_identity_provider",
"AuthStore::identity_provider",
"AuthStore::assume_role_with_web_identity",
];
pub const IDENTITY_PROVIDER_SECURITY_CONTROLS: &[&str] = &[
"unknown provider rejection",
"typed principal creation",
"scoped session issuance",
"expired assertion rejection",
"disabled directory subject rejection",
"invalid secret/signature rejection",
];
pub const IDENTITY_PROVIDER_OBSERVABILITY_FIELDS: &[&str] = &[
"provider_id",
"principal_kind",
"principal_id",
"tenant_id",
"access_key_id",
"scope",
"expires_at_epoch_seconds",
];
pub const IDENTITY_PROVIDER_FAILURE_MODES: &[&str] = &[
"UnknownIdentityProvider",
"InvalidIdentityProviderToken",
"UnknownDirectorySubject",
"DisabledDirectorySubject",
"WebIdentityTokenExpired",
"WebIdentityIssuerMismatch",
"WebIdentityAudienceMismatch",
"WebIdentityKeyIdMismatch",
];
pub const IDENTITY_PROVIDER_VALIDATION_TESTS: &[&str] = &[
"crates/bucketwarden-auth/tests/identity_access_keys.rs",
"crates/bucketwarden-auth/tests/oidc_jwt_identity.rs",
"crates/bucketwarden-auth/tests/sts_scoped_sessions.rs",
"crates/bucketwarden-auth/tests/external_identity_providers.rs",
];
pub const IDENTITY_PROVIDER_CAVEATS: &[&str] = &[
"Directory providers use deterministic in-process fixtures; production LDAP/AD network binding remains a deployment integration.",
"SAML assertions use signed compact test assertions rather than XML canonicalization.",
"OIDC/SAML provider keys are local HS256 fixtures until external JWKS/key rotation is added.",
];
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IdentityProviderSupportReport {
pub native_support_state: Vec<&'static str>,
pub semantic_parity: &'static str,
pub configuration_admin_surface: Vec<&'static str>,
pub security_governance_impact: Vec<&'static str>,
pub observability_evidence_fields: Vec<&'static str>,
pub failure_modes: Vec<&'static str>,
pub validation_test_coverage: Vec<&'static str>,
pub product_specific_caveats: Vec<&'static str>,
}
impl IdentityProviderSupportReport {
pub fn current() -> Self {
Self {
native_support_state: IDENTITY_PROVIDER_RUNTIME_FEATURES.to_vec(),
semantic_parity: "All configured identity sources resolve to typed principals and scoped session credentials before authorization.",
configuration_admin_surface: IDENTITY_PROVIDER_ADMIN_SURFACES.to_vec(),
security_governance_impact: IDENTITY_PROVIDER_SECURITY_CONTROLS.to_vec(),
observability_evidence_fields: IDENTITY_PROVIDER_OBSERVABILITY_FIELDS.to_vec(),
failure_modes: IDENTITY_PROVIDER_FAILURE_MODES.to_vec(),
validation_test_coverage: IDENTITY_PROVIDER_VALIDATION_TESTS.to_vec(),
product_specific_caveats: IDENTITY_PROVIDER_CAVEATS.to_vec(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum IdentityProvider {
Oidc(OidcProvider),
Ldap(DirectoryProvider),
ActiveDirectory(DirectoryProvider),
Saml(SamlProvider),
}
impl IdentityProvider {
pub fn id(&self) -> &str {
match self {
Self::Oidc(provider) => &provider.provider_id,
Self::Ldap(provider) | Self::ActiveDirectory(provider) => &provider.provider_id,
Self::Saml(provider) => &provider.provider_id,
}
}
pub fn verify_subject(&self, token: &str, now_epoch_seconds: u64) -> Result<String, AuthError> {
match self {
Self::Oidc(provider) => provider.verify_subject(token, now_epoch_seconds),
Self::Ldap(provider) | Self::ActiveDirectory(provider) => {
provider.verify_subject(token)
}
Self::Saml(provider) => provider.verify_subject(token, now_epoch_seconds),
}
}
pub fn principal_id(&self, subject: &str) -> String {
match self {
Self::Oidc(provider) => provider.principal_id(subject),
Self::Ldap(provider) | Self::ActiveDirectory(provider) => {
provider.principal_id(subject)
}
Self::Saml(provider) => provider.principal_id(subject),
}
}
pub fn parent_secret(&self) -> String {
match self {
Self::Oidc(provider) => provider.parent_secret(),
Self::Ldap(provider) | Self::ActiveDirectory(provider) => provider.parent_secret(),
Self::Saml(provider) => provider.parent_secret(),
}
}
pub fn principal_kind(&self) -> PrincipalKind {
match self {
Self::Oidc(_) => PrincipalKind::FederatedWebIdentity,
Self::Ldap(_) => PrincipalKind::LdapUser,
Self::ActiveDirectory(_) => PrincipalKind::ActiveDirectoryUser,
Self::Saml(_) => PrincipalKind::SamlSubject,
}
}
pub fn principal(&self, subject: &str) -> Principal {
Principal::with_kind(
self.principal_id(subject),
self.principal_kind(),
crate::DEFAULT_TENANT_ID,
)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct OidcProvider {
pub provider_id: String,
pub issuer: String,
pub audience: String,
pub key_id: String,
shared_secret: String,
pub principal_prefix: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DirectoryUser {
pub subject: String,
shared_secret: String,
pub enabled: bool,
}
impl DirectoryUser {
pub fn active(subject: impl Into<String>, shared_secret: impl Into<String>) -> Self {
Self {
subject: subject.into(),
shared_secret: shared_secret.into(),
enabled: true,
}
}
pub fn disabled(subject: impl Into<String>, shared_secret: impl Into<String>) -> Self {
Self {
enabled: false,
..Self::active(subject, shared_secret)
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DirectoryProvider {
pub provider_id: String,
pub authority: String,
pub principal_prefix: String,
users: BTreeMap<String, DirectoryUser>,
}
impl DirectoryProvider {
pub fn ldap(
provider_id: impl Into<String>,
authority: impl Into<String>,
principal_prefix: impl Into<String>,
) -> Self {
Self::new(provider_id, authority, principal_prefix)
}
pub fn active_directory(
provider_id: impl Into<String>,
authority: impl Into<String>,
principal_prefix: impl Into<String>,
) -> Self {
Self::new(provider_id, authority, principal_prefix)
}
fn new(
provider_id: impl Into<String>,
authority: impl Into<String>,
principal_prefix: impl Into<String>,
) -> Self {
Self {
provider_id: provider_id.into(),
authority: authority.into(),
principal_prefix: principal_prefix.into(),
users: BTreeMap::new(),
}
}
pub fn with_user(
mut self,
subject: impl Into<String>,
shared_secret: impl Into<String>,
) -> Self {
let user = DirectoryUser::active(subject, shared_secret);
self.users.insert(user.subject.clone(), user);
self
}
pub fn with_disabled_user(
mut self,
subject: impl Into<String>,
shared_secret: impl Into<String>,
) -> Self {
let user = DirectoryUser::disabled(subject, shared_secret);
self.users.insert(user.subject.clone(), user);
self
}
pub fn verify_subject(&self, token: &str) -> Result<String, AuthError> {
let (subject, shared_secret) = token.split_once(':').ok_or_else(|| {
AuthError::InvalidIdentityProviderToken("missing directory secret".into())
})?;
let user = self
.users
.get(subject)
.ok_or_else(|| AuthError::UnknownDirectorySubject(subject.to_string()))?;
if !user.enabled {
return Err(AuthError::DisabledDirectorySubject(subject.to_string()));
}
if user.shared_secret != shared_secret {
return Err(AuthError::InvalidIdentityProviderToken(
"directory secret mismatch".into(),
));
}
Ok(subject.to_string())
}
pub fn principal_id(&self, subject: &str) -> String {
format!("{}{}", self.principal_prefix, subject)
}
pub fn parent_secret(&self) -> String {
format!("bucketwarden-directory-parent:{}", self.provider_id)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SamlProvider {
pub provider_id: String,
pub issuer: String,
pub audience: String,
pub key_id: String,
shared_secret: String,
pub principal_prefix: String,
}
impl SamlProvider {
pub fn hs256(
provider_id: impl Into<String>,
issuer: impl Into<String>,
audience: impl Into<String>,
key_id: impl Into<String>,
shared_secret: impl Into<String>,
principal_prefix: impl Into<String>,
) -> Self {
Self {
provider_id: provider_id.into(),
issuer: issuer.into(),
audience: audience.into(),
key_id: key_id.into(),
shared_secret: shared_secret.into(),
principal_prefix: principal_prefix.into(),
}
}
pub fn sign_assertion(
&self,
subject: impl Into<String>,
expires_at_epoch_seconds: u64,
issued_at_epoch_seconds: u64,
) -> Result<String, AuthError> {
let token = sign_hs256_jwt(
Some(&self.key_id),
&JwtClaims {
iss: self.issuer.clone(),
sub: subject.into(),
aud: vec![self.audience.clone()],
exp: Some(expires_at_epoch_seconds),
iat: Some(issued_at_epoch_seconds),
},
self.shared_secret.as_bytes(),
)?;
Ok(format!("saml:{token}"))
}
pub fn verify_subject(&self, token: &str, now_epoch_seconds: u64) -> Result<String, AuthError> {
let token = token
.strip_prefix("saml:")
.ok_or_else(|| AuthError::InvalidIdentityProviderToken("missing saml prefix".into()))?;
let (header, claims) = verify_hs256_jwt(token, self.shared_secret.as_bytes())?;
if header.kid.as_deref() != Some(self.key_id.as_str()) {
return Err(AuthError::WebIdentityKeyIdMismatch(
header.kid.unwrap_or_default(),
));
}
if claims.iss != self.issuer {
return Err(AuthError::WebIdentityIssuerMismatch(claims.iss));
}
if !claims.aud.iter().any(|audience| audience == &self.audience) {
return Err(AuthError::WebIdentityAudienceMismatch(claims.aud.join(",")));
}
if claims
.exp
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
{
return Err(AuthError::WebIdentityTokenExpired);
}
if claims.sub.is_empty() {
return Err(AuthError::InvalidIdentityProviderToken(
"missing saml subject".to_string(),
));
}
Ok(claims.sub)
}
pub fn principal_id(&self, subject: &str) -> String {
format!("{}{}", self.principal_prefix, subject)
}
pub fn parent_secret(&self) -> String {
format!("bucketwarden-saml-parent:{}", self.provider_id)
}
}
impl OidcProvider {
pub fn hs256(
provider_id: impl Into<String>,
issuer: impl Into<String>,
audience: impl Into<String>,
key_id: impl Into<String>,
shared_secret: impl Into<String>,
principal_prefix: impl Into<String>,
) -> Self {
Self {
provider_id: provider_id.into(),
issuer: issuer.into(),
audience: audience.into(),
key_id: key_id.into(),
shared_secret: shared_secret.into(),
principal_prefix: principal_prefix.into(),
}
}
pub fn verify_subject(&self, token: &str, now_epoch_seconds: u64) -> Result<String, AuthError> {
let (header, claims) = verify_hs256_jwt(token, self.shared_secret.as_bytes())?;
if header.kid.as_deref() != Some(self.key_id.as_str()) {
return Err(AuthError::WebIdentityKeyIdMismatch(
header.kid.unwrap_or_default(),
));
}
if claims.iss != self.issuer {
return Err(AuthError::WebIdentityIssuerMismatch(claims.iss));
}
if !claims.aud.iter().any(|audience| audience == &self.audience) {
return Err(AuthError::WebIdentityAudienceMismatch(claims.aud.join(",")));
}
if claims
.exp
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
{
return Err(AuthError::WebIdentityTokenExpired);
}
if claims.sub.is_empty() {
return Err(AuthError::InvalidWebIdentityToken(
"missing subject".to_string(),
));
}
Ok(claims.sub)
}
pub fn principal_id(&self, subject: &str) -> String {
format!("{}{}", self.principal_prefix, subject)
}
pub fn parent_secret(&self) -> String {
format!("bucketwarden-web-identity-parent:{}", self.provider_id)
}
}