alibi-plugins 0.8.0

Built-in authentication plugins and API metadata for Alibi
Documentation
//! Enterprise single-sign-on provider registration.
mod provider_list;
mod provider_read;
use alibi_core::endpoint::{
    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
};
use alibi_core::session::SessionRequest;
use alibi_core::store::SsoProvider;
use alibi_core::utils::json::JsValue;
use alibi_core::{
    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
    AuthSchema, AuthUser, HttpMethod,
};
use async_trait::async_trait;
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub struct SsoConfig {
    pub providers_limit: usize,
    pub default_override_user_info: bool,
    pub redirect_uri: Option<String>,
}
impl Default for SsoConfig {
    fn default() -> Self {
        Self {
            providers_limit: 10,
            default_override_user_info: false,
            redirect_uri: None,
        }
    }
}
#[derive(Clone, Debug, Default)]
pub struct SsoPlugin {
    config: SsoConfig,
}
impl SsoPlugin {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
    #[must_use]
    pub fn with_config(config: SsoConfig) -> Self {
        Self { config }
    }
    pub fn register_sso_provider_endpoint(input: &Value) -> AuthResult<ServerEndpoint<Value>> {
        ServerEndpoint::new("sso", "registerSSOProvider").with_body(input)
    }
    async fn register<S: AuthSchema>(
        &self,
        input: &JsValue,
        request: &impl SessionRequest,
        ctx: &AuthContext<S>,
    ) -> AuthResult<EndpointResponse> {
        let (user, _) = super::helpers::ordinary_session(request, ctx).await?;
        if self.config.providers_limit == 0 {
            return Err(api(403, "SSO provider registration is disabled"));
        }
        if ctx
            .database
            .list_sso_providers_by_user(user.id().as_ref())
            .await?
            .len()
            >= self.config.providers_limit
        {
            return Err(api(
                403,
                "You have reached the maximum number of SSO providers",
            ));
        }
        let body = input.to_json_value()?;
        let provider_id = required_string(&body, "providerId")?;
        let issuer = required_string(&body, "issuer")?;
        let domain = required_string(&body, "domain")?;
        if let Some(organization_id) = body.get("organizationId").and_then(Value::as_str) {
            let member = ctx
                .database
                .get_member(organization_id, user.id().as_ref())
                .await?
                .ok_or_else(|| api(400, "You are not a member of the organization"))?;
            if ctx
                .get_metadata(super::organization::METADATA_ENABLED)
                .is_some()
                && !member
                    .role
                    .split(',')
                    .any(|r| matches!(r.trim(), "owner" | "admin"))
            {
                return Err(api(
                    403,
                    "You must be an organization owner or admin to register SSO providers",
                ));
            }
        }
        if [
            "credential",
            "email-otp",
            "magic-link",
            "phone-number",
            "anonymous",
            "siwe",
        ]
        .contains(&provider_id)
            || ctx
                .config
                .account
                .account_linking
                .trusted_providers
                .iter()
                .any(|p| p == provider_id)
        {
            return Err(api(
                422,
                "This providerId is reserved and cannot be used for an SSO provider",
            ));
        }
        if ctx.database.get_sso_provider(provider_id).await?.is_some() {
            return Err(api(422, "SSO provider with this providerId already exists"));
        }
        let oidc=body.get("oidcConfig").map(|config| {
            if config.get("skipDiscovery").and_then(Value::as_bool)!=Some(true) {
                return Err(AuthError::not_implemented("OIDC discovery is not implemented"));
            }
            let method=config.get("tokenEndpointAuthentication").and_then(Value::as_str).unwrap_or("client_secret_basic");
            if method!="private_key_jwt" && config.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" {return Err(api(400,"private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO"));}
            let mut output=serde_json::Map::new();
            for field in ["clientId","clientSecret","authorizationEndpoint","tokenEndpoint","privateKeyId","privateKeyAlgorithm","jwksEndpoint","pkce","mapping","scopes","userInfoEndpoint"] {
                if let Some(value)=config.get(field) { _=output.insert(field.into(),value.clone()); }
            }
            _=output.insert("issuer".into(),json!(issuer));
            _=output.insert("tokenEndpointAuthentication".into(),json!(method));
            _=output.insert("discoveryEndpoint".into(),config.get("discoveryEndpoint").cloned().unwrap_or_else(||json!(format!("{issuer}/.well-known/openid-configuration"))));
            _=output.insert("overrideUserInfo".into(),json!(body.get("overrideUserInfo").and_then(Value::as_bool).unwrap_or(false)||self.config.default_override_user_info));
            serde_json::to_string(&output).map_err(Into::into)
        }).transpose()?;
        if body.get("samlConfig").is_some() {
            return Err(AuthError::not_implemented(
                "SAML registration is not implemented",
            ));
        }
        let provider = ctx
            .database
            .create_sso_provider(SsoProvider {
                id: uuid::Uuid::new_v4().to_string(),
                issuer: issuer.into(),
                domain: domain.into(),
                provider_id: provider_id.into(),
                user_id: Some(user.id().into_owned()),
                organization_id: body
                    .get("organizationId")
                    .and_then(Value::as_str)
                    .map(str::to_owned),
                oidc_config: oidc,
                saml_config: None,
                domain_verified: None,
            })
            .await?;
        let mut output = serde_json::to_value(&provider)?;
        let object = output
            .as_object_mut()
            .ok_or_else(|| AuthError::internal("SSO provider serialization failed"))?;
        _ = object.remove("domainVerified");
        for (key, value) in [
            ("oidcConfig", provider.oidc_config.as_deref()),
            ("samlConfig", provider.saml_config.as_deref()),
        ] {
            _ = object.insert(
                key.into(),
                value
                    .map(serde_json::from_str::<Value>)
                    .transpose()?
                    .unwrap_or(Value::Null),
            );
        }
        let base = format!(
            "{}{}",
            ctx.config.base_url.trim_end_matches('/'),
            ctx.config.base_path.trim_end_matches('/')
        );
        let redirect = self
            .config
            .redirect_uri
            .as_ref()
            .filter(|uri| !uri.trim().is_empty())
            .map_or_else(
                || format!("{base}/sso/callback/{provider_id}"),
                |uri| {
                    if url::Url::parse(uri).is_ok() {
                        uri.clone()
                    } else {
                        format!("{base}/{}", uri.trim_start_matches('/'))
                    }
                },
            );
        _ = object.insert("redirectURI".into(), json!(redirect));
        EndpointResponse::json(&output)
    }
}
fn required_string<'a>(value: &'a Value, name: &str) -> AuthResult<&'a str> {
    value
        .get(name)
        .and_then(Value::as_str)
        .ok_or_else(|| super::endpoint::validation(format!("Invalid {name}")))
}
fn api(status: u16, message: &str) -> AuthError {
    AuthError::Api {
        status,
        code: None,
        message: message.into(),
    }
}
fn validate(input: Option<&JsValue>) -> AuthResult<JsValue> {
    use super::authentication_helpers::{JsonField, JsonFieldKind};
    let output = super::endpoint::validate_fields(
        input,
        "body",
        &[
            JsonField::string("providerId", true),
            JsonField::string("issuer", true),
            JsonField::string("domain", true),
            JsonField {
                name: "oidcConfig",
                kind: JsonFieldKind::Record,
                required: false,
            },
            JsonField {
                name: "samlConfig",
                kind: JsonFieldKind::Record,
                required: false,
            },
            JsonField::string("organizationId", false),
            JsonField {
                name: "overrideUserInfo",
                kind: JsonFieldKind::Boolean,
                required: false,
            },
        ],
    )?;
    if output
        .get("issuer")
        .and_then(JsValue::as_str)
        .is_none_or(|issuer| url::Url::parse(issuer).is_err())
    {
        return Err(super::endpoint::validation("[body.issuer] Invalid URL"));
    }
    if let Some(config) = output.get("oidcConfig") {
        _ = super::endpoint::validate_fields(
            Some(config),
            "body.oidcConfig",
            &[
                JsonField::string("clientId", true),
                JsonField::string("clientSecret", false),
                JsonField::string("authorizationEndpoint", false),
                JsonField::string("tokenEndpoint", false),
                JsonField::string("jwksEndpoint", false),
                JsonField::string("discoveryEndpoint", false),
                JsonField::string("userInfoEndpoint", false),
                JsonField {
                    name: "skipDiscovery",
                    kind: JsonFieldKind::Boolean,
                    required: false,
                },
                JsonField {
                    name: "pkce",
                    kind: JsonFieldKind::Boolean,
                    required: false,
                },
            ],
        )?;
    }
    Ok(output)
}
#[async_trait]
impl<S: AuthSchema> AuthPlugin<S> for SsoPlugin {
    fn name(&self) -> &'static str {
        "sso"
    }
    fn routes(&self) -> Vec<AuthRoute> {
        vec![
            AuthRoute::post("/sso/register", "registerSSOProvider"),
            AuthRoute::get("/sso/get-provider", "getSSOProvider"),
            AuthRoute::get("/sso/providers", "listSSOProviders"),
        ]
    }
    fn server_endpoints(&self) -> Vec<EndpointDefinition> {
        vec![
            super::endpoint::definition(
                "listSSOProviders",
                "listSSOProviders",
                Some("/sso/providers"),
                HttpMethod::Get,
            ),
            super::endpoint::definition(
                "getSSOProvider",
                "getSSOProvider",
                Some("/sso/get-provider"),
                HttpMethod::Get,
            ),
            super::endpoint::definition(
                "registerSSOProvider",
                "registerSSOProvider",
                Some("/sso/register"),
                HttpMethod::Post,
            ),
        ]
    }
    fn validate_endpoint(
        &self,
        call: &EndpointCall,
        _ctx: &AuthContext<S>,
    ) -> AuthResult<EndpointInput> {
        if call.operation_id() == "listSSOProviders" {
            return Ok(EndpointInput {
                body: call.body().cloned(),
                query: call.query().cloned(),
            });
        }
        if call.operation_id() == "getSSOProvider" {
            return Ok(EndpointInput {
                body: None,
                query: Some(provider_read::validate_query(call.query())?),
            });
        }
        Ok(EndpointInput {
            body: Some(validate(call.body())?),
            query: call.query().cloned(),
        })
    }
    async fn on_endpoint(
        &self,
        call: &EndpointCall,
        ctx: &AuthContext<S>,
    ) -> AuthResult<EndpointResponse> {
        if call.operation_id() == "listSSOProviders" {
            return self.list(call, ctx).await;
        }
        if call.operation_id() == "getSSOProvider" {
            return self
                .read(
                    call.query()
                        .ok_or_else(|| super::endpoint::validation("Missing query"))?,
                    call,
                    ctx,
                )
                .await;
        }
        self.register(
            call.body()
                .ok_or_else(|| super::endpoint::validation("Missing body"))?,
            call,
            ctx,
        )
        .await
    }
    async fn on_request(
        &self,
        req: &AuthRequest,
        ctx: &AuthContext<S>,
    ) -> AuthResult<Option<AuthResponse>> {
        if req.path() == "/sso/providers" && *req.method() == HttpMethod::Get {
            return Ok(Some(self.list(req, ctx).await?.to_response()?));
        }
        if req.path() == "/sso/get-provider" && *req.method() == HttpMethod::Get {
            let query = alibi_core::utils::json::parse_value(&serde_json::to_string(&req.query)?)?;
            return Ok(Some(
                self.read(&provider_read::validate_query(Some(&query))?, req, ctx)
                    .await?
                    .to_response()?,
            ));
        }
        if req.path() != "/sso/register" || *req.method() != HttpMethod::Post {
            return Ok(None);
        }
        let input = alibi_core::utils::json::parse_value(
            std::str::from_utf8(req.body.as_deref().unwrap_or_default())
                .map_err(|_| super::endpoint::validation("Invalid JSON"))?,
        )?;
        Ok(Some(
            self.register(&validate(Some(&input))?, req, ctx)
                .await?
                .to_response()?,
        ))
    }
}