Skip to main content

alibi_plugins/sso/
callback.rs

1//! OIDC callback state admission, grant redemption, and session issuance.
2use super::{
3    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, ServerEndpoint, SsoPlugin,
4    Value, api, json,
5};
6use crate::oauth::{
7    OAuthTokenSet, OAuthUserInfo,
8    handlers::{OAuthIdentity, OAuthProcessPolicy, process_oauth_sign_in},
9    state::{OAuthStatePayload, get_cookie, state_cookie_name, state_verification_identifier},
10};
11use alibi_core::utils::cookie_utils::{create_clear_cookie, verify_cookie_value};
12use alibi_core::{OAuthStateStrategy, RequestMeta, session::SessionRequest};
13use std::sync::Arc;
14impl SsoPlugin {
15    pub fn callback_sso_endpoint(
16        provider_id: &str,
17        query: &Value,
18    ) -> AuthResult<ServerEndpoint<Value>> {
19        ServerEndpoint::new("sso", "callbackSSO")
20            .with_params(&json!({"providerId":provider_id}))?
21            .with_query(query)
22    }
23    pub(super) async fn callback<S: AuthSchema>(
24        &self,
25        provider_id: Option<&str>,
26        query: &Value,
27        request: &impl SessionRequest,
28        meta: &RequestMeta,
29        ctx: &AuthContext<S>,
30    ) -> AuthResult<EndpointResponse> {
31        let base = format!(
32            "{}{}",
33            ctx.config.base_url.trim_end_matches('/'),
34            ctx.config.base_path.trim_end_matches('/')
35        );
36        let default_error = format!("{base}/error");
37        let Some(state) = query
38            .get("state")
39            .and_then(Value::as_str)
40            .filter(|s| !s.is_empty())
41        else {
42            return redirect_error(&default_error, "state_not_found", None);
43        };
44        let cookie = get_cookie(request, &state_cookie_name(&ctx.config));
45        let payload: Option<OAuthStatePayload> = match ctx.config.account.store_state_strategy {
46            OAuthStateStrategy::Database => {
47                let identifier = state_verification_identifier(state);
48                let proof = ctx.verifications().find(&identifier).await?;
49                let payload = proof
50                    .as_ref()
51                    .and_then(|proof| proof.value().ok())
52                    .and_then(|value| serde_json::from_str::<OAuthStatePayload>(value).ok());
53                if let Some(payload) = payload.as_ref()
54                    && !ctx.config.account.skip_state_cookie_check
55                    && cookie
56                        .as_deref()
57                        .and_then(|cookie| verify_cookie_value(cookie, ctx.config.current_secret()))
58                        .as_deref()
59                        != Some(state)
60                {
61                    return redirect_error(
62                        payload.error_url.as_deref().unwrap_or(&default_error),
63                        "state_mismatch",
64                        None,
65                    );
66                }
67                if payload.is_some() && ctx.verifications().consume(&identifier).await?.is_none() {
68                    None
69                } else {
70                    payload
71                }
72            }
73            OAuthStateStrategy::Cookie => cookie.as_deref().and_then(|cookie| {
74                crate::oauth::state::decode_cookie_state_value(&ctx.config, cookie).ok()
75            }),
76            OAuthStateStrategy::Automatic => None,
77        };
78        let Some(payload) = payload else {
79            return redirect_error(&default_error, "state_mismatch", None);
80        };
81        let clear = create_clear_cookie(&state_cookie_name(&ctx.config), &ctx.config)?;
82        let target = payload
83            .error_url
84            .as_deref()
85            .unwrap_or(&payload.callback_url);
86        let fail =
87            |error: &str, description: Option<&str>| -> AuthResult<EndpointResponse> {
88                Ok(redirect_error(target, error, description)?
89                    .with_header("Set-Cookie", clear.clone()))
90            };
91        if payload
92            .additional_data
93            .get("oauthState")
94            .and_then(Value::as_str)
95            != Some(state)
96        {
97            return fail("state_mismatch", None);
98        }
99        if payload.expires_at < chrono::Utc::now().timestamp_millis() {
100            return fail("state_expired", None);
101        }
102        // Shared callbacks derive the provider only from protected state.
103        let provider_id = match provider_id {
104            Some(id) => id,
105            None => match payload
106                .server_context
107                .as_ref()
108                .and_then(|context| context.get("ssoProviderReference"))
109                .and_then(|reference| reference.get("providerId"))
110                .and_then(Value::as_str)
111            {
112                Some(id) => id,
113                None => return fail("invalid_state", Some("missing_sso_provider_reference")),
114            },
115        };
116        let code = query
117            .get("code")
118            .and_then(Value::as_str)
119            .filter(|code| !code.is_empty());
120        let error = query
121            .get("error")
122            .and_then(Value::as_str)
123            .filter(|error| !error.is_empty());
124        if code.is_none() || error.is_some() {
125            return fail(
126                error.unwrap_or("invalid_request"),
127                Some(
128                    query
129                        .get("error_description")
130                        .and_then(Value::as_str)
131                        .unwrap_or_else(|| error.unwrap_or("authorization_code_not_found")),
132                ),
133            );
134        }
135        let Some(provider) = self.find_provider(provider_id, ctx).await? else {
136            return fail("invalid_provider", Some("provider not found"));
137        };
138        let reference = payload
139            .server_context
140            .as_ref()
141            .and_then(|value| value.get("ssoProviderReference"));
142        if reference.is_none() {
143            return fail("invalid_state", Some("missing_sso_provider_reference"));
144        }
145        if reference
146            != Some(&super::sign_in::provider_reference(
147                &provider,
148                self.config.domain_verification,
149            )?)
150        {
151            return fail(
152                "invalid_state",
153                Some("sso_provider_changed_during_authentication"),
154            );
155        }
156        if self.config.domain_verification && provider.domain_verified != Some(true) {
157            return Err(api(401, "Provider domain has not been verified"));
158        }
159        let Some(config) = provider
160            .oidc_config
161            .as_deref()
162            .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
163        else {
164            return fail("invalid_provider", Some("provider not found"));
165        };
166        let config = match super::discovery::ensure(&config, &provider.issuer, ctx).await {
167            Ok(config) => config,
168            Err(error) => {
169                return fail(
170                    "discovery_failed",
171                    Some(super::discovery::callback_error_message(&error)),
172                );
173            }
174        };
175        let Some(token_endpoint) = config.get("tokenEndpoint").and_then(Value::as_str) else {
176            return fail("invalid_provider", Some("token_endpoint_not_found"));
177        };
178        let redirect_uri = self.oidc_redirect_uri(&base, provider_id);
179        let mut form = vec![
180            ("grant_type".to_owned(), "authorization_code".to_owned()),
181            ("code".to_owned(), code.unwrap_or_default().to_owned()),
182            ("redirect_uri".to_owned(), redirect_uri),
183        ];
184        if config.get("pkce") == Some(&json!(true)) {
185            form.push(("code_verifier".into(), payload.code_verifier.clone()));
186        }
187        let client_id = config
188            .get("clientId")
189            .and_then(Value::as_str)
190            .unwrap_or_default();
191        let secret = config
192            .get("clientSecret")
193            .and_then(Value::as_str)
194            .unwrap_or_default();
195        let client_assertion = if config
196            .get("tokenEndpointAuthentication")
197            .and_then(Value::as_str)
198            == Some("private_key_jwt")
199        {
200            let mut key = self
201                .config
202                .default_providers
203                .iter()
204                .find(|provider| provider.provider_id == provider_id)
205                .and_then(|provider| provider.private_key.clone());
206            if key.is_none()
207                && let Some(resolver) = &self.config.resolve_private_key
208            {
209                key = resolver
210                    .0
211                    .resolve(super::SsoPrivateKeyContext {
212                        provider_id: provider_id.into(),
213                        key_id: config
214                            .get("privateKeyId")
215                            .and_then(Value::as_str)
216                            .map(str::to_owned),
217                        issuer: config
218                            .get("issuer")
219                            .and_then(Value::as_str)
220                            .unwrap_or_default()
221                            .into(),
222                    })
223                    .await?;
224            }
225            let Some(key) = key.filter(super::SsoPrivateKey::has_material) else {
226                return fail("invalid_provider", Some("no_private_key_available"));
227            };
228            Some(key.assertion(&config).map_err(AuthError::internal)?)
229        } else {
230            None
231        };
232        let exchange = async {
233            let (client, token_url) = outbound_client(token_endpoint, ctx).await?;
234            let mut grant = client.post(token_url);
235            if let Some(assertion) = client_assertion.as_ref() {
236                let signed_assertion = assertion
237                    .0
238                    .get_client_assertion(crate::oauth::OAuthClientAssertionContext {
239                        client_id: client_id.into(),
240                        token_endpoint: token_endpoint.into(),
241                        grant_type: crate::oauth::OAuthTokenGrant::AuthorizationCode,
242                    })
243                    .await
244                    .map_err(AuthError::internal)?;
245                form.push(("client_id".into(), client_id.into()));
246                form.push(("client_assertion".into(), signed_assertion));
247                form.push((
248                    "client_assertion_type".into(),
249                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".into(),
250                ));
251            } else if config
252                .get("tokenEndpointAuthentication")
253                .and_then(Value::as_str)
254                == Some("client_secret_post")
255            {
256                form.push(("client_id".into(), client_id.into()));
257                form.push(("client_secret".into(), secret.into()));
258            } else {
259                grant = grant.basic_auth(client_id, Some(secret));
260            }
261            let raw = grant
262                .form(&form)
263                .send()
264                .await
265                .map_err(|e| AuthError::internal(e.to_string()))?
266                .error_for_status()
267                .map_err(|e| AuthError::internal(e.to_string()))?
268                .json::<Value>()
269                .await
270                .map_err(|e| AuthError::internal(e.to_string()))?;
271            let verified = if let Some(token) = raw
272                .get("id_token")
273                .and_then(Value::as_str)
274                .filter(|token| !token.is_empty())
275            {
276                let endpoint = config
277                    .get("jwksEndpoint")
278                    .and_then(Value::as_str)
279                    .ok_or_else(|| api(400, "jwks_endpoint_not_found"))?;
280                Some(
281                    super::callback_id_token::verify(
282                        token,
283                        endpoint,
284                        client_id,
285                        &provider.issuer,
286                        ctx,
287                    )
288                    .await?,
289                )
290            } else {
291                None
292            };
293            let access = raw
294                .get("access_token")
295                .and_then(Value::as_str)
296                .ok_or_else(|| AuthError::internal("missing_access_token"))?;
297            if self.config.resolve_user.is_some() && verified.is_none() {
298                return Err(api(400, "id_token_required_for_user_resolution"));
299            }
300            let verified_claims = verified.clone();
301            let profile = if let Some(endpoint) = config
302                .get("userInfoEndpoint")
303                .and_then(Value::as_str)
304                .filter(|endpoint| !endpoint.is_empty())
305            {
306                let (client, info_url) = outbound_client(endpoint, ctx).await?;
307                let profile = client
308                    .get(info_url)
309                    .bearer_auth(access)
310                    .send()
311                    .await
312                    .map_err(|error| AuthError::internal(error.to_string()))?
313                    .error_for_status()
314                    .map_err(|error| AuthError::internal(error.to_string()))?
315                    .json::<Value>()
316                    .await
317                    .map_err(|error| AuthError::internal(error.to_string()))?;
318                if verified
319                    .as_ref()
320                    .is_some_and(|claims| profile.get("sub") != claims.get("sub"))
321                {
322                    return Err(api(400, "id_token_userinfo_subject_mismatch"));
323                }
324                profile
325            } else {
326                verified.ok_or_else(|| api(400, "user_info_endpoint_not_found"))?
327            };
328            let field = |name: &str, default: &str| {
329                config
330                    .get("mapping")
331                    .and_then(|mapping| mapping.get(name))
332                    .and_then(Value::as_str)
333                    .filter(|field| !field.is_empty())
334                    .unwrap_or(default)
335                    .to_owned()
336            };
337            let text = |name: &str, default: &str| {
338                profile
339                    .get(field(name, default))
340                    .and_then(Value::as_str)
341                    .map(str::to_owned)
342            };
343            let user = OAuthUserInfo {
344                id: text("id", "sub")
345                    .filter(|value| !value.is_empty())
346                    .ok_or_else(|| AuthError::internal("missing_user_info"))?,
347                // Some IdPs send a list of addresses, such as Azure AD B2C's `emails`.
348                email: match profile.get(field("email", "email")) {
349                    Some(Value::Array(values)) => values
350                        .iter()
351                        .filter_map(Value::as_str)
352                        .find(|value| !value.is_empty()),
353                    value => value.and_then(Value::as_str),
354                }
355                .filter(|value| !value.is_empty())
356                .map(str::to_owned)
357                .ok_or_else(|| AuthError::internal("missing_user_info"))?,
358                name: text("name", "name"),
359                image: text("image", "picture"),
360                email_verified: self.config.trust_email_verified
361                    && super::provider_email_verified(
362                        profile.get(field("emailVerified", "email_verified")),
363                    ),
364                additional_fields: serde_json::Map::new(),
365            };
366            let tokens = OAuthTokenSet {
367                id_token: raw
368                    .get("id_token")
369                    .and_then(Value::as_str)
370                    .map(str::to_owned),
371                access_token: Some(access.into()),
372                refresh_token: raw
373                    .get("refresh_token")
374                    .and_then(Value::as_str)
375                    .map(str::to_owned),
376                token_type: raw
377                    .get("token_type")
378                    .and_then(Value::as_str)
379                    .map(str::to_owned),
380                access_token_expires_at: raw.get("expires_in").and_then(Value::as_i64).and_then(
381                    |seconds| {
382                        chrono::Utc::now().checked_add_signed(chrono::Duration::seconds(seconds))
383                    },
384                ),
385                scopes: raw.get("scope").and_then(Value::as_str).map_or_else(
386                    || {
387                        config.get("scopes").and_then(Value::as_array).map_or_else(
388                            || {
389                                vec![
390                                    "openid".into(),
391                                    "email".into(),
392                                    "profile".into(),
393                                    "offline_access".into(),
394                                ]
395                            },
396                            |scopes| {
397                                scopes
398                                    .iter()
399                                    .filter_map(Value::as_str)
400                                    .map(str::to_owned)
401                                    .collect()
402                            },
403                        )
404                    },
405                    |scope| scope.split(' ').map(str::to_owned).collect(),
406                ),
407                raw: Some(raw.clone()),
408                ..Default::default()
409            };
410            Ok((user, tokens, profile, verified_claims))
411        }
412        .await;
413        let (user, tokens, profile, verified_claims) = match exchange {
414            Ok(value) => value,
415            Err(error) => return fail("invalid_provider", Some(&error.to_string())),
416        };
417        let mut scoped = ctx.clone();
418        let mut scoped_config = (*ctx.config).clone();
419        scoped_config
420            .account
421            .account_linking
422            .trusted_providers
423            .retain(|id| id != provider_id);
424        if provider.domain_verified == Some(true)
425            && user
426                .email
427                .split('@')
428                .nth(1)
429                .is_some_and(|domain| super::domains::matches(domain, &provider.domain))
430        {
431            scoped_config
432                .account
433                .account_linking
434                .trusted_providers
435                .push(provider_id.into());
436        }
437        scoped.config = Arc::new(scoped_config);
438        let policy = OAuthProcessPolicy {
439            user_validation_method: Some("sso-oidc"),
440            override_user_info: config
441                .get("overrideUserInfo")
442                .and_then(Value::as_bool)
443                .unwrap_or(false),
444            callback_url: Some(payload.callback_url.clone()),
445            use_updated_user: true,
446            ..Default::default()
447        };
448        let outcome = if self.config.resolve_user.is_some() {
449            let claims =
450                verified_claims.ok_or_else(|| api(400, "id_token_required_for_user_resolution"))?;
451            let mut provider_user = user.public_profile(false);
452            _ = provider_user.entry("name").or_insert_with(|| json!(""));
453            let input = super::SsoUserResolutionInput {
454                protocol: super::SsoProtocol::Oidc,
455                provider_id: provider.provider_id.clone(),
456                account_key: super::SsoAccountKey {
457                    issuer: claims
458                        .get("iss")
459                        .and_then(Value::as_str)
460                        .unwrap_or(&provider.issuer)
461                        .into(),
462                    account_id: user.id.clone(),
463                },
464                provider_user: Value::Object(provider_user),
465                provider_claims: Some(profile.clone()),
466                verified_id_token_claims: Some(claims),
467                provider_attributes: None,
468                provider_reference: super::sign_in::provider_reference(
469                    &provider,
470                    self.config.domain_verification,
471                )?,
472            };
473            let request = super::user_resolution::Authentication {
474                identity: crate::oauth::handlers::transactional_identity::Identity {
475                    provider: provider_id,
476                    user: &user,
477                    profile: &profile,
478                    policy: &policy,
479                    tokens: &tokens,
480                    disable_signup: self.config.disable_implicit_sign_up
481                        && payload.request_sign_up != Some(true),
482                    meta,
483                    ctx: &scoped,
484                },
485                input,
486                provider: &provider,
487            };
488            match self.resolved_authentication(request).await {
489                Ok(issued) => Ok(issued),
490                Err(error) => {
491                    let code = match &error {
492                        AuthError::Api {
493                            code: Some(code), ..
494                        } => code.as_str(),
495                        _ => "SSO_USER_RESOLUTION_FAILED",
496                    };
497                    let message = match &error {
498                        AuthError::Api { message, .. } => message.as_str(),
499                        _ => "Unable to resolve the SSO user",
500                    };
501                    return fail(code, (!message.is_empty()).then_some(message));
502                }
503            }
504        } else {
505            process_oauth_sign_in(
506                OAuthIdentity {
507                    provider_name: provider_id,
508                    user: &user,
509                    profile: &profile,
510                },
511                &policy,
512                &tokens,
513                self.config.disable_implicit_sign_up && payload.request_sign_up != Some(true),
514                meta,
515                &scoped,
516            )
517            .await
518        };
519        let issued = match outcome {
520            Ok(issued) => issued,
521            Err(error) => {
522                let (code, description) =
523                    if let crate::oauth::handlers::OAuthSignInError::Generic(message) = &error {
524                        (message.clone(), None)
525                    } else {
526                        error.redirect_parts()
527                    };
528                return fail(&code, description);
529            }
530        };
531        self.provision(&issued, &user, Some(&tokens), &provider)
532            .await?;
533        self.provision_organization(
534            &issued.user,
535            &provider,
536            ctx,
537            &Value::Object(user.public_profile(true)),
538            Some(&tokens),
539        )
540        .await?;
541        let destination = if issued.is_register {
542            payload
543                .new_user_url
544                .as_deref()
545                .unwrap_or(&payload.callback_url)
546        } else {
547            &payload.callback_url
548        };
549        let mut response = crate::authentication_helpers::with_session_cookies(
550            crate::authentication_helpers::redirect(destination),
551            &ctx.config,
552            &issued.session.token,
553            false,
554        )?;
555        response.headers.append("Set-Cookie", clear);
556        Ok(EndpointResponse::from_http_response(response))
557    }
558}
559fn redirect_error(
560    target: &str,
561    error: &str,
562    description: Option<&str>,
563) -> AuthResult<EndpointResponse> {
564    let mut url =
565        url::Url::parse(target).map_err(|error| AuthError::internal(error.to_string()))?;
566    {
567        let mut query = url.query_pairs_mut();
568        _ = query.append_pair("error", error);
569        if let Some(description) = description {
570            _ = query.append_pair("error_description", description);
571        }
572    }
573    Ok(EndpointResponse::from_http_response(
574        crate::authentication_helpers::redirect(url.as_str()),
575    ))
576}
577pub(super) async fn outbound_client<S: AuthSchema>(
578    endpoint: &str,
579    ctx: &AuthContext<S>,
580) -> AuthResult<(reqwest::Client, url::Url)> {
581    let mut builder = reqwest::Client::builder()
582        .redirect(reqwest::redirect::Policy::none())
583        .timeout(std::time::Duration::from_secs(10))
584        .no_proxy();
585    let url = url::Url::parse(endpoint).map_err(|e| AuthError::internal(e.to_string()))?;
586    if !matches!(url.scheme(), "http" | "https")
587        || !url.username().is_empty()
588        || url.password().is_some()
589    {
590        return Err(AuthError::internal("Invalid OIDC endpoint URL"));
591    }
592    if !ctx.config.is_redirect_target_trusted(endpoint) {
593        let host = url
594            .host_str()
595            .ok_or_else(|| AuthError::internal("Invalid OIDC endpoint host"))?;
596        let addresses = tokio::net::lookup_host((host, url.port_or_known_default().unwrap_or(443)))
597            .await
598            .map_err(|e| AuthError::internal(e.to_string()))?
599            .collect::<Vec<_>>();
600        if addresses.is_empty()
601            || addresses.iter().any(|address| match address.ip() {
602                std::net::IpAddr::V4(ip) => {
603                    ip.is_private()
604                        || ip.is_loopback()
605                        || ip.is_link_local()
606                        || ip.is_unspecified()
607                        || ip.is_multicast()
608                        || ip.is_broadcast()
609                }
610                std::net::IpAddr::V6(ip) => {
611                    ip.is_loopback()
612                        || ip.is_unique_local()
613                        || ip.is_unicast_link_local()
614                        || ip.is_unspecified()
615                        || ip.is_multicast()
616                }
617            })
618        {
619            return Err(AuthError::internal(
620                "OIDC endpoint resolves to a private or reserved address",
621            ));
622        }
623        builder = builder.resolve_to_addrs(host, &addresses);
624    }
625    Ok((
626        builder
627            .build()
628            .map_err(|error| AuthError::internal(error.to_string()))?,
629        url,
630    ))
631}