Skip to main content

alibi_plugins/sso/
saml_acs.rs

1//! SAML ACS admission, verified-claim consumption, and real session issuance.
2use super::{
3    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, JsValue, ServerEndpoint,
4    SsoPlugin, Value, api, json,
5};
6use crate::oauth::{
7    OAuthTokenSet, OAuthUserInfo,
8    handlers::{OAuthIdentity, OAuthProcessPolicy, process_oauth_sign_in},
9    state::{OAuthStatePayload, get_cookie, state_verification_identifier},
10};
11use alibi_core::{OAuthStateStrategy, RequestMeta, session::SessionRequest};
12use base64::Engine as _;
13use std::sync::Arc;
14impl SsoPlugin {
15    pub fn acs_endpoint(provider_id: &str, input: &Value) -> AuthResult<ServerEndpoint<Value>> {
16        ServerEndpoint::new("sso", "acsEndpoint")
17            .with_params(&json!({"providerId":provider_id}))?
18            .with_body(input)
19    }
20    pub(super) async fn acs<S: AuthSchema>(
21        &self,
22        provider_id: &str,
23        body: Option<&Value>,
24        query: Option<&Value>,
25        request: &impl SessionRequest,
26        meta: &RequestMeta,
27        ctx: &AuthContext<S>,
28    ) -> AuthResult<EndpointResponse> {
29        let base = format!(
30            "{}{}",
31            ctx.config.base_url.trim_end_matches('/'),
32            ctx.config.base_path.trim_end_matches('/')
33        );
34        let callback = format!("{base}/sso/saml2/sp/acs/{provider_id}");
35        let encoded = body
36            .and_then(|body| body.get("SAMLResponse"))
37            .and_then(Value::as_str)
38            .filter(|value| !value.is_empty());
39        if *request.session_method() == alibi_core::HttpMethod::Get && encoded.is_none() {
40            if crate::helpers::ordinary_session(request, ctx)
41                .await
42                .is_err()
43            {
44                return error_redirect(
45                    &format!("{}/error", ctx.config.base_url.trim_end_matches('/')),
46                    "invalid_request",
47                    None,
48                    None,
49                    ctx,
50                );
51            }
52            let target = safe_redirect(
53                [query
54                    .and_then(|query| query.get("RelayState"))
55                    .and_then(Value::as_str)],
56                &callback,
57                ctx,
58            );
59            return Ok(EndpointResponse::from_http_response(
60                crate::authentication_helpers::redirect(&target),
61            ));
62        }
63        let encoded =
64            encoded.ok_or_else(|| api(400, "SAMLResponse is required for POST requests"))?;
65        let relay = body
66            .and_then(|body| body.get("RelayState"))
67            .and_then(Value::as_str)
68            .filter(|state| !state.is_empty());
69        let (payload, failed, clear) = if let Some(state) = relay {
70            let payload = self.parse_saml_relay(state, request, ctx).await?;
71            let failed = payload.is_none();
72            let clear = payload
73                .as_ref()
74                .map(|_| {
75                    alibi_core::utils::cookie_utils::create_clear_cookie(
76                        &alibi_core::utils::cookie_utils::related_cookie_name(
77                            &ctx.config,
78                            "relay_state",
79                        ),
80                        &ctx.config,
81                    )
82                })
83                .transpose()?;
84            (payload, failed, clear)
85        } else {
86            (None, false, None)
87        };
88        let provider = self
89            .find_provider(provider_id, ctx)
90            .await?
91            .ok_or_else(|| api(404, "No SAML provider found"))?;
92        let config = provider
93            .saml_config
94            .as_deref()
95            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
96            .filter(|value| !value.is_null())
97            .ok_or_else(|| api(404, "No SAML provider found"))?;
98        if self.config.domain_verification && provider.domain_verified != Some(true) {
99            return Err(api(401, "Provider domain has not been verified"));
100        }
101        let target = safe_redirect(
102            [
103                payload
104                    .as_ref()
105                    .map(|payload| payload.callback_url.as_str()),
106                config
107                    .get("idpInitiatedCallbackUrl")
108                    .and_then(Value::as_str),
109                self.config.saml_idp_initiated_callback_url.as_deref(),
110                config.get("callbackUrl").and_then(Value::as_str),
111            ],
112            &callback,
113            ctx,
114        );
115        let error_target = safe_redirect(
116            [
117                payload
118                    .as_ref()
119                    .and_then(|payload| payload.error_url.as_deref()),
120                Some(target.as_str()),
121            ],
122            &callback,
123            ctx,
124        );
125        let fail = |code: &str, message: Option<&str>| {
126            error_redirect(&error_target, code, message, clear.as_deref(), ctx)
127        };
128        if encoded.len() > self.config.saml_max_response_size {
129            return fail(
130                "saml_error",
131                Some(&format!(
132                    "SAML response exceeds maximum allowed size ({} bytes)",
133                    self.config.saml_max_response_size
134                )),
135            );
136        }
137        let encoded = encoded
138            .chars()
139            .filter(|character| !character.is_whitespace())
140            .collect::<String>();
141        let Some(xml) = base64::engine::general_purpose::STANDARD
142            .decode(encoded)
143            .ok()
144            .and_then(|bytes| String::from_utf8(bytes).ok())
145        else {
146            return fail("saml_error", Some("Invalid SAML response"));
147        };
148        let identity = match super::saml_crypto::verify(
149            &xml,
150            &config,
151            &callback,
152            self.config.saml_clock_skew,
153            self.config.saml_require_timestamps,
154            &self.config.saml_encryption_policy,
155        ) {
156            Ok(identity) => identity,
157            Err(error) => return fail(error.code, Some(&error.message)),
158        };
159        let request_reference = if self.config.saml_enable_in_response_to_validation
160            && let Some(response_to) = identity.in_response_to.as_deref()
161        {
162            let proof = ctx
163                .verifications()
164                .consume(&format!("saml-authn-request:{response_to}"))
165                .await?;
166            let proof = proof
167                .as_ref()
168                .and_then(|proof| proof.value().ok())
169                .and_then(|value| serde_json::from_str::<Value>(value).ok());
170            let Some(proof) = proof else {
171                return fail(
172                    "invalid_saml_response",
173                    Some("Unknown or expired request ID"),
174                );
175            };
176            if proof.get("providerId").and_then(Value::as_str) != Some(provider_id) {
177                return fail("invalid_saml_response", Some("Provider mismatch"));
178            }
179            let Some(reference) = proof.get("providerReference").cloned() else {
180                return fail(
181                    "invalid_state",
182                    Some("SSO provider reference missing or invalid"),
183                );
184            };
185            Some(reference)
186        } else if self.config.saml_enable_in_response_to_validation
187            && !self.config.saml_allow_idp_initiated
188        {
189            return fail(
190                "unsolicited_response",
191                Some("IdP-initiated SSO not allowed"),
192            );
193        } else {
194            None
195        };
196        if failed {
197            return fail("invalid_state", Some("invalid_or_expired_relay_state"));
198        }
199        let relay_reference = payload
200            .as_ref()
201            .and_then(|payload| payload.server_context.as_ref())
202            .and_then(|context| context.get("ssoProviderReference"));
203        if payload.is_some() && relay_reference.is_none() {
204            return fail(
205                "invalid_state",
206                Some("sso_provider_reference_missing_or_invalid"),
207            );
208        }
209        if let (Some(relay), Some(request)) = (relay_reference, request_reference.as_ref())
210            && relay != request
211        {
212            return fail("invalid_state", Some("sso_provider_reference_mismatch"));
213        }
214        let current =
215            super::sign_in::provider_reference(&provider, self.config.domain_verification)?;
216        let reference = relay_reference
217            .or(request_reference.as_ref())
218            .unwrap_or(&current);
219        if reference != &current {
220            return fail(
221                "invalid_state",
222                Some("sso_provider_changed_during_authentication"),
223            );
224        }
225        if let Some(assertion) = identity.assertion_id.as_deref() {
226            let expires_at = identity
227                .expires_at
228                .and_then(|expiry| expiry.checked_add_signed(self.config.saml_clock_skew))
229                .unwrap_or_else(|| chrono::Utc::now() + chrono::Duration::minutes(10));
230            if !ctx.verifications().reserve(alibi_core::CreateVerification{identifier:format!("saml-used-assertion:{assertion}"),value:serde_json::to_string(&json!({"assertionId":assertion,"issuer":identity.issuer,"providerId":provider_id,"usedAt":chrono::Utc::now().timestamp_millis(),"expiresAt":expires_at.timestamp_millis()}))?,expires_at}).await?{return fail("replay_detected",Some("SAML assertion has already been used"));}
231        }
232        let verified = config
233            .get("mapping")
234            .and_then(|mapping| mapping.get("emailVerified"))
235            .and_then(Value::as_str)
236            .filter(|field| !field.is_empty())
237            .and_then(|field| identity.attributes.get(field))
238            .and_then(|value| {
239                if let Some(values) = value.as_array() {
240                    values.first()
241                } else {
242                    Some(value)
243                }
244            });
245        let verified = self.config.trust_email_verified && super::provider_email_verified(verified);
246        let user = OAuthUserInfo {
247            id: identity.id,
248            email: identity.email,
249            name: Some(identity.name),
250            image: None,
251            email_verified: verified,
252            additional_fields: serde_json::Map::new(),
253        };
254        let mut scoped = ctx.clone();
255        let mut config = (*ctx.config).clone();
256        config
257            .account
258            .account_linking
259            .trusted_providers
260            .retain(|id| id != provider_id);
261        if provider.domain_verified == Some(true)
262            && user
263                .email
264                .split('@')
265                .nth(1)
266                .is_some_and(|domain| super::domains::matches(domain, &provider.domain))
267        {
268            config
269                .account
270                .account_linking
271                .trusted_providers
272                .push(provider_id.into());
273        }
274        scoped.config = Arc::new(config);
275        let tokens = OAuthTokenSet {
276            access_token: Some(String::new()),
277            refresh_token: Some(String::new()),
278            ..Default::default()
279        };
280        let policy = OAuthProcessPolicy {
281            user_validation_method: Some("sso-saml"),
282            callback_url: Some(target.clone()),
283            use_updated_user: true,
284            ..Default::default()
285        };
286        let outcome = if self.config.resolve_user.is_some() {
287            let input = super::SsoUserResolutionInput {
288                protocol: super::SsoProtocol::Saml,
289                provider_id: provider.provider_id.clone(),
290                account_key: super::SsoAccountKey {
291                    issuer: identity.issuer.clone(),
292                    account_id: user.id.clone(),
293                },
294                provider_user: Value::Object(user.public_profile(false)),
295                provider_claims: None,
296                verified_id_token_claims: None,
297                provider_attributes: Some(identity.attributes.clone()),
298                provider_reference: super::sign_in::provider_reference(
299                    &provider,
300                    self.config.domain_verification,
301                )?,
302            };
303            let request = super::user_resolution::Authentication {
304                identity: crate::oauth::handlers::transactional_identity::Identity {
305                    provider: provider_id,
306                    user: &user,
307                    profile: &identity.attributes,
308                    policy: &policy,
309                    tokens: &tokens,
310                    disable_signup: self.config.disable_implicit_sign_up,
311                    meta,
312                    ctx: &scoped,
313                },
314                input,
315                provider: &provider,
316            };
317            match self.resolved_authentication(request).await {
318                Ok(issued) => Ok(issued),
319                Err(error) => {
320                    let code = match &error {
321                        AuthError::Api {
322                            code: Some(code), ..
323                        } => code.as_str(),
324                        _ => "SSO_USER_RESOLUTION_FAILED",
325                    };
326                    let message = match &error {
327                        AuthError::Api { message, .. } => message.as_str(),
328                        _ => "Unable to resolve the SSO user",
329                    };
330                    return error_redirect(
331                        &error_target,
332                        code,
333                        (!message.is_empty()).then_some(message),
334                        clear.as_deref(),
335                        ctx,
336                    );
337                }
338            }
339        } else {
340            process_oauth_sign_in(
341                OAuthIdentity {
342                    provider_name: provider_id,
343                    user: &user,
344                    profile: &identity.attributes,
345                },
346                &policy,
347                &tokens,
348                self.config.disable_implicit_sign_up,
349                meta,
350                &scoped,
351            )
352            .await
353        };
354        let issued = match outcome {
355            Ok(issued) => issued,
356            Err(error) => {
357                let (code, description) = error.redirect_parts();
358                let target = if matches!(
359                    error,
360                    crate::oauth::handlers::OAuthSignInError::IdentityDenied { .. }
361                        | crate::oauth::handlers::OAuthSignInError::Banned(_)
362                ) {
363                    &error_target
364                } else {
365                    &target
366                };
367                return error_redirect(target, &code, description, clear.as_deref(), ctx);
368            }
369        };
370        self.provision(&issued, &user, None, &provider).await?;
371        self.provision_organization(&issued.user, &provider, ctx, &identity.attributes, None)
372            .await?;
373        if self.config.saml_enable_single_logout {
374            let key = format!("saml-session:{provider_id}:{}", user.id);
375            let mut data = json!({"sessionId":issued.session.id,"sessionToken":issued.session.token,"providerId":provider_id,"nameID":user.id});
376            if let Some(index) = identity.session_index {
377                _ = data
378                    .as_object_mut()
379                    .ok_or_else(|| AuthError::internal("SAML session serialization failed"))?
380                    .insert("sessionIndex".into(), json!(index));
381            }
382            let value = serde_json::to_string(&data)?;
383            if let Err(error) = ctx
384                .verifications()
385                .create(alibi_core::CreateVerification {
386                    identifier: key.clone(),
387                    value,
388                    expires_at: issued.session.expires_at,
389                })
390                .await
391            {
392                tracing::warn!(%error,"Failed to create SAML session record");
393            }
394            if let Err(error) = ctx
395                .verifications()
396                .create(alibi_core::CreateVerification {
397                    identifier: format!("saml-session-by-id:{}", issued.session.id),
398                    value: key,
399                    expires_at: issued.session.expires_at,
400                })
401                .await
402            {
403                tracing::warn!(%error,"Failed to create SAML session lookup record");
404            }
405        }
406        let mut response = crate::authentication_helpers::with_session_cookies(
407            crate::authentication_helpers::redirect(&target),
408            &ctx.config,
409            &issued.session.token,
410            false,
411        )?;
412        if let Some(clear) = clear {
413            response.headers.append("Set-Cookie", clear);
414        }
415        Ok(EndpointResponse::from_http_response(response))
416    }
417    async fn parse_saml_relay<S: AuthSchema>(
418        &self,
419        state: &str,
420        request: &impl SessionRequest,
421        ctx: &AuthContext<S>,
422    ) -> AuthResult<Option<OAuthStatePayload>> {
423        let payload = match ctx.config.account.store_state_strategy {
424            OAuthStateStrategy::Database => {
425                let identifier = state_verification_identifier(state);
426                let found = ctx.verifications().find(&identifier).await?;
427                let payload = found
428                    .as_ref()
429                    .and_then(|proof| proof.value().ok())
430                    .and_then(|value| serde_json::from_str::<OAuthStatePayload>(value).ok());
431                if payload.as_ref().is_some_and(|payload| {
432                    payload
433                        .additional_data
434                        .get("oauthState")
435                        .and_then(Value::as_str)
436                        .is_some_and(|stored| stored != state)
437                }) {
438                    return Ok(None);
439                }
440                if payload.is_some() && ctx.verifications().consume(&identifier).await?.is_none() {
441                    None
442                } else {
443                    payload
444                }
445            }
446            OAuthStateStrategy::Cookie => get_cookie(
447                request,
448                &alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, "relay_state"),
449            )
450            .as_deref()
451            .and_then(|cookie| {
452                crate::oauth::state::decode_cookie_state_value(&ctx.config, cookie).ok()
453            })
454            .filter(|payload| {
455                payload
456                    .additional_data
457                    .get("oauthState")
458                    .and_then(Value::as_str)
459                    == Some(state)
460            }),
461            OAuthStateStrategy::Automatic => None,
462        };
463        Ok(payload
464            .filter(|payload| payload.expires_at >= chrono::Utc::now().timestamp_millis())
465            .map(|mut payload| {
466                if payload.error_url.is_none() {
467                    payload.error_url = Some(format!(
468                        "{}{}/error",
469                        ctx.config.base_url.trim_end_matches('/'),
470                        ctx.config.base_path.trim_end_matches('/')
471                    ));
472                }
473                payload
474            }))
475    }
476}
477pub(super) fn safe_redirect<'a, S: AuthSchema>(
478    candidates: impl IntoIterator<Item = Option<&'a str>>,
479    callback: &str,
480    ctx: &AuthContext<S>,
481) -> String {
482    let origin = url::Url::parse(&ctx.config.base_url).ok();
483    let callback = url::Url::parse(callback).ok();
484    for value in candidates
485        .into_iter()
486        .flatten()
487        .filter(|value| !value.is_empty())
488    {
489        let relative = value.starts_with('/') && !value.starts_with("//") && !value.contains('\\');
490        let url = if relative {
491            origin.as_ref().and_then(|origin| origin.join(value).ok())
492        } else {
493            url::Url::parse(value).ok()
494        };
495        if let Some(url) = url
496            && matches!(url.scheme(), "http" | "https")
497            && origin.as_ref().is_some_and(|origin| {
498                url.origin() == origin.origin() || ctx.config.is_redirect_target_trusted(value)
499            })
500            && !callback.as_ref().is_some_and(|callback| {
501                url.origin() == callback.origin() && url.path() == callback.path()
502            })
503        {
504            return value.into();
505        }
506    }
507    origin.map_or_else(
508        || ctx.config.base_url.clone(),
509        |origin| origin.origin().ascii_serialization(),
510    )
511}
512fn error_redirect<S: AuthSchema>(
513    target: &str,
514    code: &str,
515    message: Option<&str>,
516    clear: Option<&str>,
517    ctx: &AuthContext<S>,
518) -> AuthResult<EndpointResponse> {
519    let relative = target.starts_with('/');
520    let origin = url::Url::parse(&ctx.config.base_url)
521        .map_err(|error| AuthError::internal(error.to_string()))?;
522    let mut url = origin
523        .join(target)
524        .map_err(|error| AuthError::internal(error.to_string()))?;
525    {
526        let mut query = url.query_pairs_mut();
527        _ = query.append_pair("error", code);
528        if let Some(message) = message {
529            _ = query.append_pair("error_description", message);
530        }
531    }
532    let target = if relative {
533        format!(
534            "{}{}{}",
535            url.path(),
536            url.query()
537                .map_or_else(String::new, |query| format!("?{query}")),
538            url.fragment()
539                .map_or_else(String::new, |fragment| format!("#{fragment}"))
540        )
541    } else {
542        url.to_string()
543    };
544    let response =
545        EndpointResponse::from_http_response(crate::authentication_helpers::redirect(&target));
546    Ok(if let Some(clear) = clear {
547        response.with_header("Set-Cookie", clear)
548    } else {
549        response
550    })
551}
552pub(super) fn validate_body(input: Option<&JsValue>) -> AuthResult<Option<JsValue>> {
553    input
554        .map(|input| {
555            crate::endpoint::validate_fields(
556                Some(input),
557                "body",
558                &[
559                    crate::authentication_helpers::JsonField::string("SAMLResponse", true),
560                    crate::authentication_helpers::JsonField::string("RelayState", false),
561                ],
562            )
563        })
564        .transpose()
565}
566pub(super) fn validate_query(input: Option<&JsValue>) -> AuthResult<Option<JsValue>> {
567    input
568        .map(|input| {
569            crate::endpoint::validate_fields(
570                Some(input),
571                "query",
572                &[crate::authentication_helpers::JsonField::string(
573                    "RelayState",
574                    false,
575                )],
576            )
577        })
578        .transpose()
579}