alibi-plugins 0.9.0

Built-in authentication plugins and API metadata for Alibi
Documentation
//! Merge partial provider configuration against its transaction-locked snapshot.
use super::{
    AuthContext, AuthResult, AuthSchema, AuthUser, EndpointResponse, JsValue, ServerEndpoint,
    SessionRequest, SsoPlugin, SsoProvider, Value, api, json,
};
use alibi_core::store::SsoProviderUpdate;
impl SsoPlugin {
    pub fn update_sso_provider_endpoint(input: &Value) -> AuthResult<ServerEndpoint<Value>> {
        ServerEndpoint::new("sso", "updateSSOProvider").with_body(input)
    }
    pub(super) async fn update<S: AuthSchema>(
        &self,
        input: &JsValue,
        request: &impl SessionRequest,
        ctx: &AuthContext<S>,
    ) -> AuthResult<EndpointResponse> {
        let (user, _) = super::super::helpers::ordinary_session(request, ctx).await?;
        let parsed = super::additional_fields::parse(
            &ctx.config.advanced.database.sso_provider_fields,
            input,
            false,
        )?;
        if !parsed.has_input_fields
            && !["issuer", "domain", "samlConfig", "oidcConfig"]
                .iter()
                .any(|field| input.get(field).is_some_and(JsValue::is_truthy))
        {
            return Err(api(400, "No fields provided for update"));
        }
        let extras = parsed.values;
        let provider_id = input
            .get("providerId")
            .and_then(JsValue::as_str)
            .ok_or_else(|| super::super::endpoint::validation("Invalid providerId"))?;
        let authorized = self
            .authorize_provider_id(provider_id, user.id().as_ref(), ctx)
            .await?;
        let body = input.to_json_value()?;
        if let Some(config) = body.get("oidcConfig") {
            super::discovery::validate_endpoints(config, &|url| {
                ctx.config.is_redirect_target_trusted(url)
            })?;
        }
        let max_metadata_size = self.config.saml_max_metadata_size;
        let private_key_configured = self.private_key_source_configured(provider_id);
        let signing_policy = self.config.saml_signing_policy.clone();
        let prepare = move |existing: &SsoProvider| {
            let mut update = prepare_update(
                existing,
                &body,
                max_metadata_size,
                private_key_configured,
                &signing_policy,
            )?;
            update.provider.additional_fields.extend(extras.clone());
            update.additional_field_changes.clone_from(&extras);
            Ok(update)
        };
        let guard = self
            .config
            .guard_provider_mutation
            .as_ref()
            .map(super::SsoMutationGuard::resolve::<S>)
            .transpose()?;
        let updated = if let Some(guard) = guard {
            let id = authorized.id.clone();
            let provider_id = provider_id.to_owned();
            let verified = self.config.domain_verification;
            alibi_core::store::transaction(ctx.database.as_ref(), move |database| {
                Box::pin(async move {
                    let existing = database
                        .lock_sso_provider(&id, &provider_id)
                        .await?
                        .ok_or_else(|| api(404, "Provider not found"))?;
                    let update = prepare(&existing)?;
                    super::mutation_guard::run(
                        guard.as_ref(),
                        &existing,
                        super::SsoMutationAction::Update,
                        Some(update.identity_changed),
                        verified,
                        database,
                    )
                    .await?;
                    database
                        .update_sso_provider(update)
                        .await?
                        .ok_or_else(|| api(404, "Provider not found"))
                })
            })
            .await?
        } else {
            ctx.database
                .update_sso_provider(&authorized.id, provider_id, &prepare)
                .await?
                .ok_or_else(|| api(404, "Provider not found"))?
        };
        EndpointResponse::json(&super::provider_read::sanitize(&updated, ctx)?)
    }
}
fn prepare_update(
    existing: &SsoProvider,
    body: &Value,
    max_metadata_size: usize,
    private_key_configured: bool,
    signing_policy: &super::SsoSamlSigningPolicy,
) -> AuthResult<SsoProviderUpdate> {
    let mut provider = existing.clone();
    let mut identity_changed = false;
    if let Some(issuer) = body.get("issuer").and_then(Value::as_str) {
        identity_changed = issuer != existing.issuer;
        provider.issuer = issuer.into();
    }
    if let Some(domain) = body.get("domain").and_then(Value::as_str) {
        if domain != existing.domain {
            provider.domain_verified = Some(false);
        }
        provider.domain = domain.into();
    }
    if let Some(updates) = body.get("samlConfig") {
        super::signing_algorithms::validate(updates, signing_policy)?;
        let issuer = body
            .get("issuer")
            .and_then(Value::as_str)
            .filter(|issuer| !issuer.is_empty());
        let (config, changed) =
            super::provider_update_saml::prepare(existing, updates, issuer, max_metadata_size)?;
        provider.saml_config = Some(config);
        identity_changed |= changed;
    }
    if let Some(updates) = body.get("oidcConfig") {
        let current: Value = existing
            .oidc_config
            .as_deref()
            .and_then(|raw| serde_json::from_str(raw).ok())
            .filter(|value: &Value| !value.is_null())
            .ok_or_else(|| {
                api(
                    400,
                    "Cannot update OIDC config for a provider that doesn't have OIDC configured",
                )
            })?;
        let mut merged = current.as_object().cloned().ok_or_else(|| {
            api(
                400,
                "Cannot update OIDC config for a provider that doesn't have OIDC configured",
            )
        })?;
        if let Some(fields) = updates.as_object() {
            for (key, value) in fields {
                _ = merged.insert(key.clone(), value.clone());
            }
        }
        let issuer = body
            .get("issuer")
            .and_then(Value::as_str)
            .or_else(|| current.get("issuer").and_then(Value::as_str))
            .filter(|issuer| !issuer.is_empty())
            .unwrap_or(&existing.issuer);
        _ = merged.insert("issuer".into(), json!(issuer));
        if merged.get("pkce").is_none_or(Value::is_null) {
            _ = merged.insert("pkce".into(), json!(true));
        }
        let method = merged
            .get("tokenEndpointAuthentication")
            .and_then(Value::as_str)
            .unwrap_or("client_secret_basic");
        if method != "private_key_jwt"
            && merged
                .get("clientSecret")
                .and_then(Value::as_str)
                .is_none_or(str::is_empty)
        {
            return Err(api(
                400,
                "clientSecret is required when using client_secret_basic or client_secret_post authentication",
            ));
        }
        if method == "private_key_jwt" && !private_key_configured {
            return Err(api(
                400,
                "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO",
            ));
        }
        let updated = Value::Object(merged);
        identity_changed |= [
            "authorizationEndpoint",
            "clientId",
            "discoveryEndpoint",
            "jwksEndpoint",
            "tokenEndpoint",
            "userInfoEndpoint",
        ]
        .iter()
        .any(|field| current.get(field) != updated.get(field));
        provider.oidc_config = Some(serde_json::to_string(&updated)?);
    }
    Ok(SsoProviderUpdate {
        provider,
        identity_changed,
        additional_field_changes: serde_json::Map::new(),
    })
}
pub(super) fn validate_update(input: Option<&JsValue>) -> AuthResult<JsValue> {
    use super::super::authentication_helpers::{JsonField, JsonFieldKind};
    let mut output = super::super::endpoint::validate_fields(
        input,
        "body",
        &[
            JsonField::string("issuer", false),
            JsonField::string("domain", false),
            JsonField {
                name: "oidcConfig",
                kind: JsonFieldKind::Record,
                required: false,
            },
            JsonField {
                name: "samlConfig",
                kind: JsonFieldKind::Record,
                required: false,
            },
            JsonField::string("providerId", true),
        ],
    )?;
    if output
        .get("issuer")
        .and_then(JsValue::as_str)
        .is_some_and(|issuer| url::Url::parse(issuer).is_err())
    {
        return Err(super::super::endpoint::validation(
            "[body.issuer] Invalid URL",
        ));
    }
    if let Some(config) = output.get("oidcConfig") {
        let original_mapping = config.get("mapping");
        let config = super::super::endpoint::validate_fields(
            Some(config),
            "body.oidcConfig",
            &[
                JsonField::string("clientId", false),
                JsonField::string("clientSecret", false),
                JsonField::string("authorizationEndpoint", false),
                JsonField::string("tokenEndpoint", false),
                JsonField::string("userInfoEndpoint", false),
                JsonField {
                    name: "tokenEndpointAuthentication",
                    kind: JsonFieldKind::OneOf(&[
                        "client_secret_post",
                        "client_secret_basic",
                        "private_key_jwt",
                    ]),
                    required: false,
                },
                JsonField::string("privateKeyId", false),
                JsonField::string("privateKeyAlgorithm", false),
                JsonField::string("jwksEndpoint", false),
                JsonField::string("discoveryEndpoint", false),
                JsonField {
                    name: "skipDiscovery",
                    kind: JsonFieldKind::Boolean,
                    required: false,
                },
                JsonField {
                    name: "pkce",
                    kind: JsonFieldKind::Boolean,
                    required: false,
                },
                JsonField {
                    name: "overrideUserInfo",
                    kind: JsonFieldKind::Boolean,
                    required: false,
                },
            ],
        )?;
        for field in super::discovery::ENDPOINT_FIELDS {
            if let Some(value) = config.get(field).and_then(JsValue::as_str)
                && url::Url::parse(value).is_err()
            {
                return Err(super::super::endpoint::validation(format!(
                    "[body.oidcConfig.{field}] Invalid URL"
                )));
            }
        }
        let mut normalized = config
            .as_object()
            .cloned()
            .ok_or_else(|| super::super::endpoint::validation("Invalid OIDC config"))?;
        if let Some(mapping) = original_mapping {
            _ = normalized.insert("mapping".into(), super::mapping_schema::oidc(mapping)?);
        }
        if let Some(scopes) = output
            .get("oidcConfig")
            .and_then(|config| config.get("scopes"))
        {
            super::oidc_scopes::validate(scopes)?;
            _ = normalized.insert("scopes".into(), scopes.clone());
        }
        if let JsValue::Object(object) = &mut output {
            _ = object.insert("oidcConfig".into(), JsValue::Object(normalized));
        }
    }
    if let Some(config) = output.get("samlConfig") {
        let config = super::registration_saml::validate_update(config)?;
        if let JsValue::Object(object) = &mut output {
            _ = object.insert("samlConfig".into(), config);
        }
    }
    Ok(output)
}