Skip to main content

alibi_plugins/sso/
user_resolution.rs

1//! Application-owned resolution of a cryptographically verified enterprise identity.
2use alibi_core::{AuthResult, AuthSchema, AuthTransaction};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::{any::Any, sync::Arc};
6#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum SsoProtocol {
9    Oidc,
10    Saml,
11}
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct SsoAccountKey {
15    pub issuer: String,
16    pub account_id: String,
17}
18#[derive(Clone, Debug, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct SsoUserResolutionInput {
21    pub protocol: SsoProtocol,
22    pub provider_id: String,
23    pub account_key: SsoAccountKey,
24    pub provider_user: Value,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub provider_claims: Option<Value>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub verified_id_token_claims: Option<Value>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub provider_attributes: Option<Value>,
31    pub provider_reference: Value,
32}
33#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "lowercase")]
35pub enum SsoResolvedProfile {
36    Preserve,
37    Update,
38}
39#[derive(Clone, Debug, Serialize, Deserialize)]
40#[serde(tag = "action", rename_all = "lowercase")]
41pub enum SsoUserResolution {
42    Continue,
43    Link {
44        #[serde(rename = "userId")]
45        user_id: String,
46        profile: SsoResolvedProfile,
47    },
48    Reject {
49        code: String,
50        #[serde(skip_serializing_if = "Option::is_none")]
51        message: Option<String>,
52    },
53}
54impl SsoUserResolution {
55    pub(super) fn valid(&self) -> bool {
56        match self {
57            Self::Continue => true,
58            Self::Link { user_id, .. } => !user_id.trim().is_empty(),
59            Self::Reject { code, .. } => !code.trim().is_empty(),
60        }
61    }
62}
63#[async_trait::async_trait]
64pub trait SsoUserResolver<S: AuthSchema>: Send + Sync {
65    async fn resolve(
66        &self,
67        input: SsoUserResolutionInput,
68        database: &dyn AuthTransaction<S>,
69    ) -> AuthResult<SsoUserResolution>;
70}
71#[derive(Clone)]
72pub struct SsoResolver {
73    callback: Arc<dyn Any + Send + Sync>,
74}
75impl SsoResolver {
76    pub fn new<S: AuthSchema>(callback: Arc<dyn SsoUserResolver<S>>) -> Self {
77        Self {
78            callback: Arc::new(callback),
79        }
80    }
81    pub(super) fn resolve<S: AuthSchema>(&self) -> AuthResult<Arc<dyn SsoUserResolver<S>>> {
82        self.callback
83            .downcast_ref::<Arc<dyn SsoUserResolver<S>>>()
84            .cloned()
85            .ok_or_else(|| {
86                alibi_core::AuthError::config(
87                    "SSO user resolver schema does not match the application schema",
88                )
89            })
90    }
91}
92impl std::fmt::Debug for SsoResolver {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("SsoResolver").finish_non_exhaustive()
95    }
96}
97
98pub(super) struct Authentication<'a, S: AuthSchema> {
99    pub identity: crate::oauth::handlers::transactional_identity::Identity<'a, S>,
100    pub input: SsoUserResolutionInput,
101    pub provider: &'a alibi_core::store::SsoProvider,
102}
103fn failure() -> alibi_core::AuthError {
104    alibi_core::AuthError::Api {
105        status: 500,
106        code: Some("SSO_USER_RESOLUTION_FAILED".into()),
107        message: "Unable to resolve the SSO user".into(),
108    }
109}
110impl super::SsoPlugin {
111    pub(super) async fn resolved_authentication<S: AuthSchema>(
112        &self,
113        request: Authentication<'_, S>,
114    ) -> AuthResult<crate::oauth::handlers::ProcessOAuthUserResult> {
115        let resolver = self
116            .config
117            .resolve_user
118            .as_ref()
119            .ok_or_else(failure)?
120            .resolve::<S>()?;
121        let identity = request.identity;
122        let ctx = identity.ctx.clone();
123        if ctx.config.session.stateless {
124            return Err(alibi_core::AuthError::Api {
125                status: 501,
126                code: Some("SSO_USER_RESOLUTION_REQUIRES_DATABASE_SESSIONS".into()),
127                message:
128                    "SSO user resolution requires database-backed sessions with database fallback"
129                        .into(),
130            });
131        }
132        let provider = request.provider.clone();
133        let input = request.input;
134        let verified = self.config.domain_verification;
135        let name = identity.provider.to_owned();
136        let user = identity.user.clone();
137        let profile = identity.profile.clone();
138        let policy = identity.policy.clone();
139        let tokens = identity.tokens.clone();
140        let meta = identity.meta.clone();
141        let disabled = identity.disable_signup;
142        let scoped = ctx.clone();
143        let authenticated = alibi_core::store::transaction_with_policy(
144            ctx.database.as_ref(),
145            alibi_core::store::AfterCommitErrorPolicy::Report,
146            move |database| {
147                Box::pin(async move {
148                    if !provider.id.is_empty() {
149                        let current = database
150                            .lock_sso_provider(&provider.id, &provider.provider_id)
151                            .await?
152                            .ok_or_else(|| alibi_core::AuthError::Api {
153                                status: 409,
154                                code: Some("SSO_PROVIDER_CHANGED".into()),
155                                message:
156                                    "SSO provider changed while account linking was in progress"
157                                        .into(),
158                            })?;
159                        if super::sign_in::provider_reference(&current, verified)?
160                            != input.provider_reference
161                        {
162                            return Err(alibi_core::AuthError::Api {
163                                status: 409,
164                                code: Some("SSO_PROVIDER_CHANGED".into()),
165                                message:
166                                    "SSO provider changed while account linking was in progress"
167                                        .into(),
168                            });
169                        }
170                    }
171                    let decision = resolver.resolve(input, database).await.map_err(|_| {
172                        tracing::error!("SSO user resolution failed");
173                        failure()
174                    })?;
175                    if !decision.valid() {
176                        return Err(failure());
177                    }
178                    if let SsoUserResolution::Reject { code, message } = &decision {
179                        return Err(alibi_core::AuthError::Api {
180                            status: 403,
181                            code: Some(code.clone()),
182                            message: message.clone().unwrap_or_default(),
183                        });
184                    }
185                    let identity = crate::oauth::handlers::transactional_identity::Identity {
186                        provider: &name,
187                        user: &user,
188                        profile: &profile,
189                        policy: &policy,
190                        tokens: &tokens,
191                        disable_signup: disabled,
192                        meta: &meta,
193                        ctx: &scoped,
194                    };
195                    crate::oauth::handlers::transactional_identity::authenticate(
196                        &identity, &decision, database,
197                    )
198                    .await
199                })
200            },
201        )
202        .await?;
203        crate::oauth::handlers::transactional_identity::publish(&ctx, authenticated).await
204    }
205}