Skip to main content

alibi_core/store/
sso.rs

1//! Durable enterprise identity-provider configuration.
2use crate::{AuthError, AuthResult};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
6#[serde(rename_all = "camelCase")]
7pub struct SsoProvider {
8    pub id: String,
9    pub issuer: String,
10    pub domain: String,
11    pub provider_id: String,
12    pub user_id: Option<String>,
13    pub organization_id: Option<String>,
14    pub oidc_config: Option<String>,
15    pub saml_config: Option<String>,
16    pub domain_verified: Option<bool>,
17    #[serde(flatten, default)]
18    pub additional_fields: serde_json::Map<String, serde_json::Value>,
19}
20/// A provider update prepared from the actual row locked by its adapter.
21pub struct SsoProviderUpdate {
22    pub provider: SsoProvider,
23    /// Fields that decide which identity-provider identities linked accounts belong to changed.
24    pub identity_changed: bool,
25    /// Identity or signing trust changed; reported to the provider mutation guard.
26    pub authentication_boundary_changed: bool,
27    /// Only submitted fields are bound again during partial updates.
28    pub additional_field_changes: serde_json::Map<String, serde_json::Value>,
29}
30pub type SsoProviderUpdateWork =
31    dyn Fn(&SsoProvider) -> AuthResult<SsoProviderUpdate> + Send + Sync;
32#[async_trait]
33pub trait SsoProviderStore: Send + Sync {
34    async fn mark_sso_domain_verified(&self, _id: &str, _domain: &str) -> AuthResult<bool> {
35        Err(AuthError::config(
36            "SSO persistence is not supported by this store",
37        ))
38    }
39    async fn update_sso_provider(
40        &self,
41        _id: &str,
42        _provider_id: &str,
43        _prepare: &SsoProviderUpdateWork,
44    ) -> AuthResult<Option<SsoProvider>> {
45        Err(AuthError::config(
46            "SSO persistence is not supported by this store",
47        ))
48    }
49    /// Atomically retire a provider and every account linked to its identity namespace.
50    async fn delete_sso_provider_and_accounts(
51        &self,
52        _id: &str,
53        _provider_id: &str,
54    ) -> AuthResult<bool> {
55        Err(AuthError::config(
56            "SSO persistence is not supported by this store",
57        ))
58    }
59    async fn list_sso_providers(&self) -> AuthResult<Vec<SsoProvider>> {
60        Err(AuthError::config(
61            "SSO persistence is not supported by this store",
62        ))
63    }
64    async fn create_sso_provider(&self, _provider: SsoProvider) -> AuthResult<SsoProvider> {
65        Err(AuthError::config(
66            "SSO persistence is not supported by this store",
67        ))
68    }
69    async fn get_sso_provider(&self, _provider_id: &str) -> AuthResult<Option<SsoProvider>> {
70        Err(AuthError::config(
71            "SSO persistence is not supported by this store",
72        ))
73    }
74    async fn list_sso_providers_by_user(&self, _user_id: &str) -> AuthResult<Vec<SsoProvider>> {
75        Err(AuthError::config(
76            "SSO persistence is not supported by this store",
77        ))
78    }
79}
80
81/// Physical storage category of a declared provider field.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum SsoFieldType {
84    String,
85    Boolean,
86    Number,
87    Json,
88    Date,
89}
90impl SsoFieldType {
91    #[must_use]
92    pub fn from_config(field: &crate::field_policy::FieldConfig) -> Self {
93        match field.schema.get("type").and_then(serde_json::Value::as_str) {
94            Some("boolean") => Self::Boolean,
95            Some("number" | "integer") => Self::Number,
96            Some("object" | "array" | "json" | "string[]" | "number[]") => Self::Json,
97            Some("date") => Self::Date,
98            _ => Self::String,
99        }
100    }
101    #[must_use]
102    pub const fn sql_type(self, postgres: bool) -> &'static str {
103        match self {
104            Self::String => "TEXT",
105            Self::Boolean => {
106                if postgres {
107                    "BOOLEAN"
108                } else {
109                    "INTEGER"
110                }
111            }
112            Self::Number => "INTEGER",
113            Self::Json => {
114                if postgres {
115                    "JSONB"
116                } else {
117                    "TEXT"
118                }
119            }
120            Self::Date => {
121                if postgres {
122                    "TIMESTAMPTZ"
123                } else {
124                    "DATE"
125                }
126            }
127        }
128    }
129}
130/// Resolve physical additional-column names without changing logical API names.
131#[must_use]
132pub fn sso_field_column<'a>(name: &'a str, field: &'a crate::field_policy::FieldConfig) -> &'a str {
133    field.field_name.as_deref().unwrap_or(name)
134}
135
136/// Validate additional-field names against provider columns and returned fields.
137pub fn validate_sso_fields(config: &crate::config::AdvancedDatabaseConfig) -> AuthResult<()> {
138    const BUILT_IN: &[&str] = &[
139        "id",
140        "issuer",
141        "domain",
142        "providerId",
143        "userId",
144        "organizationId",
145        "oidcConfig",
146        "samlConfig",
147        "domainVerified",
148    ];
149    const RESPONSE: &[&str] = &[
150        "type",
151        "redirectURI",
152        "domainVerificationToken",
153        "spMetadataUrl",
154    ];
155    let mapped = config.sso_provider.as_ref();
156    for (name, field) in &config.sso_provider_fields.0 {
157        if BUILT_IN.contains(&name.as_str()) {
158            return Err(AuthError::config(format!(
159                "ssoProvider additional field \"{name}\" conflicts with a built-in field"
160            )));
161        }
162        if RESPONSE.contains(&name.as_str()) {
163            return Err(AuthError::config(format!(
164                "ssoProvider additional field \"{name}\" conflicts with a returned provider field"
165            )));
166        }
167        let physical = sso_field_column(name, field);
168        if physical.is_empty()
169            || BUILT_IN.iter().any(|canonical| {
170                let configured = mapped
171                    .and_then(|mapping| {
172                        mapping
173                            .columns
174                            .iter()
175                            .find(|(key, _)| crate::config::sso_provider_column(key) == *canonical)
176                            .map(|(_, value)| value.as_str())
177                    })
178                    .unwrap_or(canonical);
179                configured == physical
180            })
181        {
182            return Err(AuthError::config(format!(
183                "ssoProvider additional field \"{name}\" maps to built-in field \"{physical}\""
184            )));
185        }
186    }
187    Ok(())
188}
189/// DDL for one declared column. Application callback defaults are evaluated at creation.
190pub fn sso_field_definition(
191    name: &str,
192    field: &crate::field_policy::FieldConfig,
193    postgres: bool,
194) -> AuthResult<String> {
195    use crate::field_policy::FieldDefault;
196    let quote = |value: &str| format!("\"{}\"", value.replace('"', "\"\""));
197    let mut definition = format!(
198        "{} {}",
199        quote(sso_field_column(name, field)),
200        SsoFieldType::from_config(field).sql_type(postgres)
201    );
202    if let Some(FieldDefault::Value(value)) = &field.default {
203        let default = match value {
204            crate::utils::json::JsValue::Null => "NULL".to_owned(),
205            crate::utils::json::JsValue::Bool(value) => {
206                if postgres {
207                    value.to_string()
208                } else {
209                    if *value { "1" } else { "0" }.to_owned()
210                }
211            }
212            crate::utils::json::JsValue::Number(value) if value.is_finite() => value.to_string(),
213            crate::utils::json::JsValue::String(value) => {
214                format!("'{}'", value.replace('\'', "''"))
215            }
216            value => format!("'{}'", serde_json::to_string(value)?.replace('\'', "''")),
217        };
218        definition.push_str(" DEFAULT ");
219        definition.push_str(&default);
220    }
221    if field.required {
222        definition.push_str(" NOT NULL");
223    }
224    Ok(definition)
225}
226
227/// Apply application adapter input callbacks and defaults immediately before binding.
228pub async fn prepare_sso_fields(
229    fields: &crate::field_policy::SessionFields,
230    input: &serde_json::Map<String, serde_json::Value>,
231    create: bool,
232) -> AuthResult<serde_json::Map<String, serde_json::Value>> {
233    let policy = crate::field_policy::SessionAdapterFields(std::sync::Arc::new(fields.0.clone()));
234    let mut values: crate::field_policy::FieldValues = input
235        .iter()
236        .map(|(key, value)| {
237            (
238                key.clone(),
239                crate::utils::json::JsValue::from(value.clone()),
240            )
241        })
242        .collect();
243    policy.attach(&mut values, create);
244    values.apply_adapter_transforms_async().await?;
245    values
246        .into_iter()
247        .map(|(key, value)| Ok((key, value.to_json_value()?)))
248        .collect()
249}
250/// Resolve application output callbacks on the actual stored provider projection.
251pub async fn project_sso_provider(
252    fields: &crate::field_policy::SessionFields,
253    mut provider: SsoProvider,
254) -> AuthResult<SsoProvider> {
255    let policy = crate::field_policy::SessionAdapterFields(std::sync::Arc::new(fields.0.clone()));
256    let output = policy
257        .output(std::mem::take(&mut provider.additional_fields))
258        .await?;
259    provider.additional_fields.clone_from(output.values());
260    // The reference normalizes database date strings after output transforms.
261    for (name, field) in &fields.0 {
262        if SsoFieldType::from_config(field) == SsoFieldType::Date
263            && let Some(value) = provider.additional_fields.get_mut(name)
264            && let Some(text) = value.as_str()
265        {
266            *value = chrono::DateTime::parse_from_rfc3339(text).map_or(
267                serde_json::Value::Null,
268                |date| {
269                    serde_json::Value::String(
270                        date.with_timezone(&chrono::Utc)
271                            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
272                    )
273                },
274            );
275        }
276    }
277    Ok(provider)
278}