1use 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(¤t);
219 if reference != ¤t {
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}