alibi-plugins 0.9.0

Built-in authentication plugins and API metadata for Alibi
Documentation
//! Registered OIDC provider authorization and state issuance.
use super::{
    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, JsValue, ServerEndpoint,
    SsoPlugin, SsoProvider, Value, api, json,
};
use alibi_core::OAuthStateStrategy;
use base64::Engine as _;
use sha2::Digest as _;
impl SsoPlugin {
    pub fn sign_in_sso_endpoint(input: &Value) -> AuthResult<ServerEndpoint<Value>> {
        ServerEndpoint::new("sso", "signInSSO").with_body(input)
    }
    pub(super) async fn sign_in<S: AuthSchema>(
        &self,
        input: &JsValue,
        ctx: &AuthContext<S>,
    ) -> AuthResult<EndpointResponse> {
        let body = input.to_json_value()?;
        let string = |name: &str| {
            body.get(name)
                .and_then(Value::as_str)
                .filter(|value| !value.is_empty())
        };
        let email = string("email");
        let domain = string("domain").or_else(|| email.and_then(|email| email.split('@').nth(1)));
        let provider_id = string("providerId");
        let org_slug = string("organizationSlug");
        if self.config.default_providers.is_empty()
            && email.is_none()
            && domain.is_none()
            && provider_id.is_none()
            && org_slug.is_none()
        {
            return Err(api(
                400,
                "email, organizationSlug, domain or providerId is required",
            ));
        }
        let org_id = if let Some(slug) = org_slug {
            ctx.database
                .get_organization_by_slug(slug)
                .await?
                .map(|org| org.id)
        } else {
            None
        };
        if provider_id.is_none() && org_id.is_none() && domain.is_none() {
            return Err(api(400, "providerId, orgId or domain is required"));
        }
        let provider = if let Some(configured) = self.configured_provider(provider_id, domain)? {
            Some(configured)
        } else if let Some(id) = provider_id {
            ctx.database.get_sso_provider(id).await?
        } else {
            let providers = ctx.database.list_sso_providers().await?;
            if let Some(id) = org_id.as_deref() {
                providers
                    .into_iter()
                    .find(|provider| provider.organization_id.as_deref() == Some(id))
            } else {
                providers
                    .iter()
                    .find(|provider| Some(provider.domain.as_str()) == domain)
                    .cloned()
                    .or_else(|| {
                        providers.into_iter().find(|provider| {
                            domain.is_some_and(|domain| {
                                super::domains::matches(domain, &provider.domain)
                            })
                        })
                    })
            }
        }
        .ok_or_else(|| api(404, "No provider found for the issuer"))?;
        let oidc = provider
            .oidc_config
            .as_deref()
            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
            .filter(|value| !value.is_null());
        let saml = provider
            .saml_config
            .as_deref()
            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
            .filter(|value| !value.is_null());
        if string("providerType") == Some("oidc") && oidc.is_none() {
            return Err(api(400, "OIDC provider is not configured"));
        }
        if string("providerType") == Some("saml") && saml.is_none() {
            return Err(api(400, "SAML provider is not configured"));
        }
        if self.config.domain_verification && provider.domain_verified != Some(true) {
            return Err(api(401, "Provider domain has not been verified"));
        }
        if string("providerType") == Some("saml") || oidc.is_none() {
            return self
                .saml_sign_in(
                    &body,
                    &provider,
                    saml.as_ref()
                        .ok_or_else(|| api(400, "Invalid SSO provider"))?,
                    ctx,
                )
                .await;
        }
        let config = super::discovery::ensure(
            oidc.as_ref()
                .ok_or_else(|| api(400, "Invalid SSO provider"))?,
            &provider.issuer,
            ctx,
        )
        .await?;
        let endpoint = config
            .get("authorizationEndpoint")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                api(
                    400,
                    "Invalid OIDC configuration. Authorization URL not found.",
                )
            })?;
        let state = random(32);
        let verifier = random(128);
        let callback = string("callbackURL")
            .filter(|value| !value.is_empty())
            .unwrap_or(&ctx.config.base_url);
        let mut payload = crate::oauth::state::OAuthStatePayload::new(
            callback.into(),
            verifier.clone(),
            string("errorCallbackURL").map(str::to_owned),
            string("newUserCallbackURL").map(str::to_owned),
            None,
            body.get("requestSignUp").and_then(Value::as_bool),
            serde_json::Map::new(),
        );
        payload.server_context = Some(
            json!({"ssoProviderReference":provider_reference(&provider,self.config.domain_verification)?}),
        );
        _ = payload
            .additional_data
            .insert("oauthState".into(), json!(state));
        let cookie = issue_state(
            &payload,
            &state,
            &crate::oauth::state::state_cookie_name(&ctx.config),
            ctx,
        )
        .await?;
        let base = format!(
            "{}{}",
            ctx.config.base_url.trim_end_matches('/'),
            ctx.config.base_path.trim_end_matches('/')
        );
        let redirect = self.oidc_redirect_uri(&base, &provider.provider_id);
        let scopes = body
            .get("scopes")
            .or_else(|| config.get("scopes"))
            .cloned()
            .unwrap_or_else(|| json!(["openid", "email", "profile", "offline_access"]));
        let scope = scopes
            .as_array()
            .ok_or_else(|| api(400, "Invalid scopes"))?
            .iter()
            .filter_map(Value::as_str)
            .collect::<Vec<_>>()
            .join(" ");
        let mut url = url::Url::parse(endpoint).map_err(|_| {
            api(
                400,
                "Invalid OIDC configuration. Authorization URL not found.",
            )
        })?;
        {
            let mut query = url.query_pairs_mut();
            _ = query
                .append_pair("response_type", "code")
                .append_pair(
                    "client_id",
                    config
                        .get("clientId")
                        .and_then(Value::as_str)
                        .unwrap_or_default(),
                )
                .append_pair("state", &state)
                .append_pair("scope", &scope)
                .append_pair("redirect_uri", &redirect);
            if config.get("pkce") == Some(&json!(true)) {
                _ = query
                    .append_pair("code_challenge_method", "S256")
                    .append_pair(
                        "code_challenge",
                        &base64::engine::general_purpose::URL_SAFE_NO_PAD
                            .encode(sha2::Sha256::digest(verifier.as_bytes())),
                    );
            }
            if let Some(hint) = string("loginHint").or(email) {
                _ = query.append_pair("login_hint", hint);
            }
        }
        Ok(
            EndpointResponse::json(&json!({"url":url.to_string(),"redirect":true}))?
                .with_header("Set-Cookie", cookie),
        )
    }
}
pub(super) fn random(length: usize) -> String {
    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-_";
    use rand::RngExt as _;
    let mut rng = rand::rng();
    (0..length)
        .filter_map(|_| {
            ALPHABET
                .get(rng.random_range(0..ALPHABET.len()))
                .copied()
                .map(char::from)
        })
        .collect()
}
pub(super) fn provider_reference(
    provider: &SsoProvider,
    verified_field: bool,
) -> AuthResult<Value> {
    provider_reference_inner(provider, verified_field, true)
}
pub(super) fn mutation_provider_reference(
    provider: &SsoProvider,
    verified_field: bool,
) -> AuthResult<Value> {
    provider_reference_inner(provider, verified_field, false)
}
fn provider_reference_inner(
    provider: &SsoProvider,
    verified_field: bool,
    include_empty_organization: bool,
) -> AuthResult<Value> {
    let mut configuration = serde_json::Map::new();
    _ = configuration.insert("domain".into(), json!(provider.domain));
    _ = configuration.insert("issuer".into(), json!(provider.issuer));
    if !provider.id.is_empty() && (include_empty_organization || provider.organization_id.is_some())
    {
        _ = configuration.insert("organizationId".into(), json!(provider.organization_id));
    }
    if verified_field {
        _ = configuration.insert("domainVerified".into(), json!(provider.domain_verified));
    }
    if let Some(raw) = provider.oidc_config.as_deref() {
        let mut config: Value = serde_json::from_str(raw)?;
        if let Some(object) = config.as_object_mut() {
            _ = object.remove("clientSecret");
        }
        _ = configuration.insert("oidcConfig".into(), config);
    }
    if let Some(raw) = provider.saml_config.as_deref() {
        let mut config: Value = serde_json::from_str(raw)?;
        if let Some(object) = config.as_object_mut() {
            _ = object.remove("privateKey");
            let idp = object.entry("idpMetadata").or_insert_with(|| json!({}));
            if let Some(metadata) = idp.as_object_mut() {
                for key in [
                    "privateKey",
                    "privateKeyPass",
                    "encPrivateKey",
                    "encPrivateKeyPass",
                ] {
                    _ = metadata.remove(key);
                }
            }
            if let Some(metadata) = object.get_mut("spMetadata").and_then(Value::as_object_mut) {
                for key in [
                    "privateKey",
                    "privateKeyPass",
                    "encPrivateKey",
                    "encPrivateKeyPass",
                ] {
                    _ = metadata.remove(key);
                }
            }
        }
        _ = configuration.insert("samlConfig".into(), config);
    }
    let fingerprint = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
        sha2::Sha256::digest(canonical(&Value::Object(configuration))?.into_bytes()),
    );
    let source = if provider.id.is_empty() {
        json!({"type":"configured"})
    } else {
        json!({"type":"persisted","recordId":provider.id})
    };
    Ok(
        json!({"providerId":provider.provider_id,"source":source,"authenticationConfigurationFingerprint":fingerprint}),
    )
}
pub(super) fn validate(input: Option<&JsValue>) -> AuthResult<JsValue> {
    use super::super::authentication_helpers::{JsonField, JsonFieldKind};
    let output = super::super::endpoint::validate_fields(
        input,
        "body",
        &[
            JsonField::string("email", false),
            JsonField::string("organizationSlug", false),
            JsonField::string("providerId", false),
            JsonField::string("domain", false),
            JsonField::string("callbackURL", true),
            JsonField::string("errorCallbackURL", false),
            JsonField::string("newUserCallbackURL", false),
            JsonField::string("loginHint", false),
            JsonField {
                name: "requestSignUp",
                kind: JsonFieldKind::Boolean,
                required: false,
            },
            JsonField {
                name: "providerType",
                kind: JsonFieldKind::OneOf(&["oidc", "saml"]),
                required: false,
            },
        ],
    )?;
    let mut values = output
        .as_object()
        .cloned()
        .ok_or_else(|| super::super::endpoint::validation("Invalid body"))?;
    for field in ["scopes", "additionalParams"] {
        if let Some(value) = input.and_then(|value| value.get(field)) {
            _ = values.insert(field.into(), value.clone());
        }
    }
    Ok(JsValue::Object(values))
}

fn canonical(value: &Value) -> AuthResult<String> {
    match value {
        Value::Object(object) => {
            let sorted: std::collections::BTreeMap<&String, &Value> = object.iter().collect();
            let pairs = sorted
                .into_iter()
                .map(|(key, value)| {
                    Ok(format!(
                        "{}:{}",
                        serde_json::to_string(key)?,
                        canonical(value)?
                    ))
                })
                .collect::<AuthResult<Vec<_>>>()?;
            Ok(format!("{{{}}}", pairs.join(",")))
        }
        Value::Array(values) => Ok(format!(
            "[{}]",
            values
                .iter()
                .map(canonical)
                .collect::<AuthResult<Vec<_>>>()?
                .join(",")
        )),
        _ => serde_json::to_string(value).map_err(Into::into),
    }
}

pub(super) async fn issue_state<S: AuthSchema>(
    payload: &crate::oauth::state::OAuthStatePayload,
    state: &str,
    cookie_name: &str,
    ctx: &AuthContext<S>,
) -> AuthResult<String> {
    match ctx.config.account.store_state_strategy {
        OAuthStateStrategy::Database => {
            _ = ctx
                .verifications()
                .create(alibi_core::CreateVerification {
                    identifier: crate::oauth::state::state_verification_identifier(state),
                    value: serde_json::to_string(payload)?,
                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
                })
                .await?
                .ok_or_else(|| api(500, "Unable to create verification"))?;
            alibi_core::utils::cookie_utils::create_cookie(
                cookie_name,
                &alibi_core::utils::cookie_utils::sign_cookie_value(
                    state,
                    ctx.config.current_secret(),
                ),
                300,
                &ctx.config,
            )
        }
        OAuthStateStrategy::Cookie => alibi_core::utils::cookie_utils::create_cookie(
            cookie_name,
            &crate::oauth::state::create_cookie_state_value(&ctx.config, payload)?,
            600,
            &ctx.config,
        ),
        OAuthStateStrategy::Automatic => Err(AuthError::config(
            "OAuth state strategy has not been initialized",
        )),
    }
}