Skip to main content

alibi_plugins/sso/
sign_in.rs

1//! Registered OIDC provider authorization and state issuance.
2use super::{
3    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, JsValue, ServerEndpoint,
4    SsoPlugin, SsoProvider, Value, api, json,
5};
6use alibi_core::OAuthStateStrategy;
7use base64::Engine as _;
8use sha2::Digest as _;
9impl SsoPlugin {
10    pub fn sign_in_sso_endpoint(input: &Value) -> AuthResult<ServerEndpoint<Value>> {
11        ServerEndpoint::new("sso", "signInSSO").with_body(input)
12    }
13    pub(super) async fn sign_in<S: AuthSchema>(
14        &self,
15        input: &JsValue,
16        ctx: &AuthContext<S>,
17    ) -> AuthResult<EndpointResponse> {
18        let body = input.to_json_value()?;
19        let string = |name: &str| {
20            body.get(name)
21                .and_then(Value::as_str)
22                .filter(|value| !value.is_empty())
23        };
24        let email = string("email");
25        let domain = string("domain").or_else(|| email.and_then(|email| email.split('@').nth(1)));
26        let provider_id = string("providerId");
27        let org_slug = string("organizationSlug");
28        if self.config.default_providers.is_empty()
29            && email.is_none()
30            && domain.is_none()
31            && provider_id.is_none()
32            && org_slug.is_none()
33        {
34            return Err(api(
35                400,
36                "email, organizationSlug, domain or providerId is required",
37            ));
38        }
39        let org_id = if let Some(slug) = org_slug {
40            ctx.database
41                .get_organization_by_slug(slug)
42                .await?
43                .map(|org| org.id)
44        } else {
45            None
46        };
47        if provider_id.is_none() && org_id.is_none() && domain.is_none() {
48            return Err(api(400, "providerId, orgId or domain is required"));
49        }
50        let provider = if let Some(configured) = self.configured_provider(provider_id, domain)? {
51            Some(configured)
52        } else if let Some(id) = provider_id {
53            ctx.database.get_sso_provider(id).await?
54        } else {
55            let providers = ctx.database.list_sso_providers().await?;
56            if let Some(id) = org_id.as_deref() {
57                providers
58                    .into_iter()
59                    .find(|provider| provider.organization_id.as_deref() == Some(id))
60            } else {
61                providers
62                    .iter()
63                    .find(|provider| Some(provider.domain.as_str()) == domain)
64                    .cloned()
65                    .or_else(|| {
66                        providers.into_iter().find(|provider| {
67                            domain.is_some_and(|domain| {
68                                super::domains::matches(domain, &provider.domain)
69                            })
70                        })
71                    })
72            }
73        }
74        .ok_or_else(|| api(404, "No provider found for the issuer"))?;
75        let oidc = provider
76            .oidc_config
77            .as_deref()
78            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
79            .filter(|value| !value.is_null());
80        let saml = provider
81            .saml_config
82            .as_deref()
83            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
84            .filter(|value| !value.is_null());
85        if string("providerType") == Some("oidc") && oidc.is_none() {
86            return Err(api(400, "OIDC provider is not configured"));
87        }
88        if string("providerType") == Some("saml") && saml.is_none() {
89            return Err(api(400, "SAML provider is not configured"));
90        }
91        if self.config.domain_verification && provider.domain_verified != Some(true) {
92            return Err(api(401, "Provider domain has not been verified"));
93        }
94        if string("providerType") == Some("saml") || oidc.is_none() {
95            return self
96                .saml_sign_in(
97                    &body,
98                    &provider,
99                    saml.as_ref()
100                        .ok_or_else(|| api(400, "Invalid SSO provider"))?,
101                    ctx,
102                )
103                .await;
104        }
105        let config = super::discovery::ensure(
106            oidc.as_ref()
107                .ok_or_else(|| api(400, "Invalid SSO provider"))?,
108            &provider.issuer,
109            ctx,
110        )
111        .await?;
112        let endpoint = config
113            .get("authorizationEndpoint")
114            .and_then(Value::as_str)
115            .filter(|value| !value.is_empty())
116            .ok_or_else(|| {
117                api(
118                    400,
119                    "Invalid OIDC configuration. Authorization URL not found.",
120                )
121            })?;
122        let state = random(32);
123        let verifier = random(128);
124        let callback = string("callbackURL")
125            .filter(|value| !value.is_empty())
126            .unwrap_or(&ctx.config.base_url);
127        let mut payload = crate::oauth::state::OAuthStatePayload::new(
128            callback.into(),
129            verifier.clone(),
130            string("errorCallbackURL").map(str::to_owned),
131            string("newUserCallbackURL").map(str::to_owned),
132            None,
133            body.get("requestSignUp").and_then(Value::as_bool),
134            serde_json::Map::new(),
135        );
136        payload.server_context = Some(
137            json!({"ssoProviderReference":provider_reference(&provider,self.config.domain_verification)?}),
138        );
139        _ = payload
140            .additional_data
141            .insert("oauthState".into(), json!(state));
142        let cookie = issue_state(
143            &payload,
144            &state,
145            &crate::oauth::state::state_cookie_name(&ctx.config),
146            ctx,
147        )
148        .await?;
149        let base = format!(
150            "{}{}",
151            ctx.config.base_url.trim_end_matches('/'),
152            ctx.config.base_path.trim_end_matches('/')
153        );
154        let redirect = self.oidc_redirect_uri(&base, &provider.provider_id);
155        let scopes = body
156            .get("scopes")
157            .or_else(|| config.get("scopes"))
158            .cloned()
159            .unwrap_or_else(|| json!(["openid", "email", "profile", "offline_access"]));
160        let scope = scopes
161            .as_array()
162            .ok_or_else(|| api(400, "Invalid scopes"))?
163            .iter()
164            .filter_map(Value::as_str)
165            .collect::<Vec<_>>()
166            .join(" ");
167        let mut url = url::Url::parse(endpoint).map_err(|_| {
168            api(
169                400,
170                "Invalid OIDC configuration. Authorization URL not found.",
171            )
172        })?;
173        {
174            let mut query = url.query_pairs_mut();
175            _ = query
176                .append_pair("response_type", "code")
177                .append_pair(
178                    "client_id",
179                    config
180                        .get("clientId")
181                        .and_then(Value::as_str)
182                        .unwrap_or_default(),
183                )
184                .append_pair("state", &state)
185                .append_pair("scope", &scope)
186                .append_pair("redirect_uri", &redirect);
187            if config.get("pkce") == Some(&json!(true)) {
188                _ = query
189                    .append_pair("code_challenge_method", "S256")
190                    .append_pair(
191                        "code_challenge",
192                        &base64::engine::general_purpose::URL_SAFE_NO_PAD
193                            .encode(sha2::Sha256::digest(verifier.as_bytes())),
194                    );
195            }
196            if let Some(hint) = string("loginHint").or(email) {
197                _ = query.append_pair("login_hint", hint);
198            }
199        }
200        Ok(
201            EndpointResponse::json(&json!({"url":url.to_string(),"redirect":true}))?
202                .with_header("Set-Cookie", cookie),
203        )
204    }
205}
206pub(super) fn random(length: usize) -> String {
207    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-_";
208    use rand::RngExt as _;
209    let mut rng = rand::rng();
210    (0..length)
211        .filter_map(|_| {
212            ALPHABET
213                .get(rng.random_range(0..ALPHABET.len()))
214                .copied()
215                .map(char::from)
216        })
217        .collect()
218}
219pub(super) fn provider_reference(
220    provider: &SsoProvider,
221    verified_field: bool,
222) -> AuthResult<Value> {
223    provider_reference_inner(provider, verified_field, true)
224}
225pub(super) fn mutation_provider_reference(
226    provider: &SsoProvider,
227    verified_field: bool,
228) -> AuthResult<Value> {
229    provider_reference_inner(provider, verified_field, false)
230}
231fn provider_reference_inner(
232    provider: &SsoProvider,
233    verified_field: bool,
234    include_empty_organization: bool,
235) -> AuthResult<Value> {
236    let mut configuration = serde_json::Map::new();
237    _ = configuration.insert("domain".into(), json!(provider.domain));
238    _ = configuration.insert("issuer".into(), json!(provider.issuer));
239    if !provider.id.is_empty() && (include_empty_organization || provider.organization_id.is_some())
240    {
241        _ = configuration.insert("organizationId".into(), json!(provider.organization_id));
242    }
243    if verified_field {
244        _ = configuration.insert("domainVerified".into(), json!(provider.domain_verified));
245    }
246    if let Some(raw) = provider.oidc_config.as_deref() {
247        let mut config: Value = serde_json::from_str(raw)?;
248        if let Some(object) = config.as_object_mut() {
249            _ = object.remove("clientSecret");
250        }
251        _ = configuration.insert("oidcConfig".into(), config);
252    }
253    if let Some(raw) = provider.saml_config.as_deref() {
254        let mut config: Value = serde_json::from_str(raw)?;
255        if let Some(object) = config.as_object_mut() {
256            _ = object.remove("privateKey");
257            let idp = object.entry("idpMetadata").or_insert_with(|| json!({}));
258            if let Some(metadata) = idp.as_object_mut() {
259                for key in [
260                    "privateKey",
261                    "privateKeyPass",
262                    "encPrivateKey",
263                    "encPrivateKeyPass",
264                ] {
265                    _ = metadata.remove(key);
266                }
267            }
268            if let Some(metadata) = object.get_mut("spMetadata").and_then(Value::as_object_mut) {
269                for key in [
270                    "privateKey",
271                    "privateKeyPass",
272                    "encPrivateKey",
273                    "encPrivateKeyPass",
274                ] {
275                    _ = metadata.remove(key);
276                }
277            }
278        }
279        _ = configuration.insert("samlConfig".into(), config);
280    }
281    let fingerprint = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
282        sha2::Sha256::digest(canonical(&Value::Object(configuration))?.into_bytes()),
283    );
284    let source = if provider.id.is_empty() {
285        json!({"type":"configured"})
286    } else {
287        json!({"type":"persisted","recordId":provider.id})
288    };
289    Ok(
290        json!({"providerId":provider.provider_id,"source":source,"authenticationConfigurationFingerprint":fingerprint}),
291    )
292}
293pub(super) fn validate(input: Option<&JsValue>) -> AuthResult<JsValue> {
294    use super::super::authentication_helpers::{JsonField, JsonFieldKind};
295    let output = super::super::endpoint::validate_fields(
296        input,
297        "body",
298        &[
299            JsonField::string("email", false),
300            JsonField::string("organizationSlug", false),
301            JsonField::string("providerId", false),
302            JsonField::string("domain", false),
303            JsonField::string("callbackURL", true),
304            JsonField::string("errorCallbackURL", false),
305            JsonField::string("newUserCallbackURL", false),
306            JsonField::string("loginHint", false),
307            JsonField {
308                name: "requestSignUp",
309                kind: JsonFieldKind::Boolean,
310                required: false,
311            },
312            JsonField {
313                name: "providerType",
314                kind: JsonFieldKind::OneOf(&["oidc", "saml"]),
315                required: false,
316            },
317        ],
318    )?;
319    let mut values = output
320        .as_object()
321        .cloned()
322        .ok_or_else(|| super::super::endpoint::validation("Invalid body"))?;
323    for field in ["scopes", "additionalParams"] {
324        if let Some(value) = input.and_then(|value| value.get(field)) {
325            _ = values.insert(field.into(), value.clone());
326        }
327    }
328    Ok(JsValue::Object(values))
329}
330
331fn canonical(value: &Value) -> AuthResult<String> {
332    match value {
333        Value::Object(object) => {
334            let sorted: std::collections::BTreeMap<&String, &Value> = object.iter().collect();
335            let pairs = sorted
336                .into_iter()
337                .map(|(key, value)| {
338                    Ok(format!(
339                        "{}:{}",
340                        serde_json::to_string(key)?,
341                        canonical(value)?
342                    ))
343                })
344                .collect::<AuthResult<Vec<_>>>()?;
345            Ok(format!("{{{}}}", pairs.join(",")))
346        }
347        Value::Array(values) => Ok(format!(
348            "[{}]",
349            values
350                .iter()
351                .map(canonical)
352                .collect::<AuthResult<Vec<_>>>()?
353                .join(",")
354        )),
355        _ => serde_json::to_string(value).map_err(Into::into),
356    }
357}
358
359pub(super) async fn issue_state<S: AuthSchema>(
360    payload: &crate::oauth::state::OAuthStatePayload,
361    state: &str,
362    cookie_name: &str,
363    ctx: &AuthContext<S>,
364) -> AuthResult<String> {
365    match ctx.config.account.store_state_strategy {
366        OAuthStateStrategy::Database => {
367            _ = ctx
368                .verifications()
369                .create(alibi_core::CreateVerification {
370                    identifier: crate::oauth::state::state_verification_identifier(state),
371                    value: serde_json::to_string(payload)?,
372                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
373                })
374                .await?
375                .ok_or_else(|| api(500, "Unable to create verification"))?;
376            alibi_core::utils::cookie_utils::create_cookie(
377                cookie_name,
378                &alibi_core::utils::cookie_utils::sign_cookie_value(
379                    state,
380                    ctx.config.current_secret(),
381                ),
382                300,
383                &ctx.config,
384            )
385        }
386        OAuthStateStrategy::Cookie => alibi_core::utils::cookie_utils::create_cookie(
387            cookie_name,
388            &crate::oauth::state::create_cookie_state_value(&ctx.config, payload)?,
389            600,
390            &ctx.config,
391        ),
392        OAuthStateStrategy::Automatic => Err(AuthError::config(
393            "OAuth state strategy has not been initialized",
394        )),
395    }
396}