Skip to main content

alibi_plugins/
sso.rs

1//! Enterprise single-sign-on provider registration.
2mod provider_list;
3mod provider_read;
4use alibi_core::endpoint::{
5    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
6};
7use alibi_core::session::SessionRequest;
8use alibi_core::store::SsoProvider;
9use alibi_core::utils::json::JsValue;
10use alibi_core::{
11    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
12    AuthSchema, AuthUser, HttpMethod,
13};
14use async_trait::async_trait;
15use serde_json::{Value, json};
16#[derive(Clone, Debug)]
17pub struct SsoConfig {
18    pub providers_limit: usize,
19    pub default_override_user_info: bool,
20    pub redirect_uri: Option<String>,
21}
22impl Default for SsoConfig {
23    fn default() -> Self {
24        Self {
25            providers_limit: 10,
26            default_override_user_info: false,
27            redirect_uri: None,
28        }
29    }
30}
31#[derive(Clone, Debug, Default)]
32pub struct SsoPlugin {
33    config: SsoConfig,
34}
35impl SsoPlugin {
36    #[must_use]
37    pub fn new() -> Self {
38        Self::default()
39    }
40    #[must_use]
41    pub fn with_config(config: SsoConfig) -> Self {
42        Self { config }
43    }
44    pub fn register_sso_provider_endpoint(input: &Value) -> AuthResult<ServerEndpoint<Value>> {
45        ServerEndpoint::new("sso", "registerSSOProvider").with_body(input)
46    }
47    async fn register<S: AuthSchema>(
48        &self,
49        input: &JsValue,
50        request: &impl SessionRequest,
51        ctx: &AuthContext<S>,
52    ) -> AuthResult<EndpointResponse> {
53        let (user, _) = super::helpers::ordinary_session(request, ctx).await?;
54        if self.config.providers_limit == 0 {
55            return Err(api(403, "SSO provider registration is disabled"));
56        }
57        if ctx
58            .database
59            .list_sso_providers_by_user(user.id().as_ref())
60            .await?
61            .len()
62            >= self.config.providers_limit
63        {
64            return Err(api(
65                403,
66                "You have reached the maximum number of SSO providers",
67            ));
68        }
69        let body = input.to_json_value()?;
70        let provider_id = required_string(&body, "providerId")?;
71        let issuer = required_string(&body, "issuer")?;
72        let domain = required_string(&body, "domain")?;
73        if let Some(organization_id) = body.get("organizationId").and_then(Value::as_str) {
74            let member = ctx
75                .database
76                .get_member(organization_id, user.id().as_ref())
77                .await?
78                .ok_or_else(|| api(400, "You are not a member of the organization"))?;
79            if ctx
80                .get_metadata(super::organization::METADATA_ENABLED)
81                .is_some()
82                && !member
83                    .role
84                    .split(',')
85                    .any(|r| matches!(r.trim(), "owner" | "admin"))
86            {
87                return Err(api(
88                    403,
89                    "You must be an organization owner or admin to register SSO providers",
90                ));
91            }
92        }
93        if [
94            "credential",
95            "email-otp",
96            "magic-link",
97            "phone-number",
98            "anonymous",
99            "siwe",
100        ]
101        .contains(&provider_id)
102            || ctx
103                .config
104                .account
105                .account_linking
106                .trusted_providers
107                .iter()
108                .any(|p| p == provider_id)
109        {
110            return Err(api(
111                422,
112                "This providerId is reserved and cannot be used for an SSO provider",
113            ));
114        }
115        if ctx.database.get_sso_provider(provider_id).await?.is_some() {
116            return Err(api(422, "SSO provider with this providerId already exists"));
117        }
118        let oidc=body.get("oidcConfig").map(|config| {
119            if config.get("skipDiscovery").and_then(Value::as_bool)!=Some(true) {
120                return Err(AuthError::not_implemented("OIDC discovery is not implemented"));
121            }
122            let method=config.get("tokenEndpointAuthentication").and_then(Value::as_str).unwrap_or("client_secret_basic");
123            if method!="private_key_jwt" && config.get("clientSecret").and_then(Value::as_str).is_none_or(str::is_empty) {
124                return Err(api(400,"clientSecret is required when using client_secret_basic or client_secret_post authentication"));
125            }
126            if method=="private_key_jwt" {return Err(api(400,"private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO"));}
127            let mut output=serde_json::Map::new();
128            for field in ["clientId","clientSecret","authorizationEndpoint","tokenEndpoint","privateKeyId","privateKeyAlgorithm","jwksEndpoint","pkce","mapping","scopes","userInfoEndpoint"] {
129                if let Some(value)=config.get(field) { _=output.insert(field.into(),value.clone()); }
130            }
131            _=output.insert("issuer".into(),json!(issuer));
132            _=output.insert("tokenEndpointAuthentication".into(),json!(method));
133            _=output.insert("discoveryEndpoint".into(),config.get("discoveryEndpoint").cloned().unwrap_or_else(||json!(format!("{issuer}/.well-known/openid-configuration"))));
134            _=output.insert("overrideUserInfo".into(),json!(body.get("overrideUserInfo").and_then(Value::as_bool).unwrap_or(false)||self.config.default_override_user_info));
135            serde_json::to_string(&output).map_err(Into::into)
136        }).transpose()?;
137        if body.get("samlConfig").is_some() {
138            return Err(AuthError::not_implemented(
139                "SAML registration is not implemented",
140            ));
141        }
142        let provider = ctx
143            .database
144            .create_sso_provider(SsoProvider {
145                id: uuid::Uuid::new_v4().to_string(),
146                issuer: issuer.into(),
147                domain: domain.into(),
148                provider_id: provider_id.into(),
149                user_id: Some(user.id().into_owned()),
150                organization_id: body
151                    .get("organizationId")
152                    .and_then(Value::as_str)
153                    .map(str::to_owned),
154                oidc_config: oidc,
155                saml_config: None,
156                domain_verified: None,
157            })
158            .await?;
159        let mut output = serde_json::to_value(&provider)?;
160        let object = output
161            .as_object_mut()
162            .ok_or_else(|| AuthError::internal("SSO provider serialization failed"))?;
163        _ = object.remove("domainVerified");
164        for (key, value) in [
165            ("oidcConfig", provider.oidc_config.as_deref()),
166            ("samlConfig", provider.saml_config.as_deref()),
167        ] {
168            _ = object.insert(
169                key.into(),
170                value
171                    .map(serde_json::from_str::<Value>)
172                    .transpose()?
173                    .unwrap_or(Value::Null),
174            );
175        }
176        let base = format!(
177            "{}{}",
178            ctx.config.base_url.trim_end_matches('/'),
179            ctx.config.base_path.trim_end_matches('/')
180        );
181        let redirect = self
182            .config
183            .redirect_uri
184            .as_ref()
185            .filter(|uri| !uri.trim().is_empty())
186            .map_or_else(
187                || format!("{base}/sso/callback/{provider_id}"),
188                |uri| {
189                    if url::Url::parse(uri).is_ok() {
190                        uri.clone()
191                    } else {
192                        format!("{base}/{}", uri.trim_start_matches('/'))
193                    }
194                },
195            );
196        _ = object.insert("redirectURI".into(), json!(redirect));
197        EndpointResponse::json(&output)
198    }
199}
200fn required_string<'a>(value: &'a Value, name: &str) -> AuthResult<&'a str> {
201    value
202        .get(name)
203        .and_then(Value::as_str)
204        .ok_or_else(|| super::endpoint::validation(format!("Invalid {name}")))
205}
206fn api(status: u16, message: &str) -> AuthError {
207    AuthError::Api {
208        status,
209        code: None,
210        message: message.into(),
211    }
212}
213fn validate(input: Option<&JsValue>) -> AuthResult<JsValue> {
214    use super::authentication_helpers::{JsonField, JsonFieldKind};
215    let output = super::endpoint::validate_fields(
216        input,
217        "body",
218        &[
219            JsonField::string("providerId", true),
220            JsonField::string("issuer", true),
221            JsonField::string("domain", true),
222            JsonField {
223                name: "oidcConfig",
224                kind: JsonFieldKind::Record,
225                required: false,
226            },
227            JsonField {
228                name: "samlConfig",
229                kind: JsonFieldKind::Record,
230                required: false,
231            },
232            JsonField::string("organizationId", false),
233            JsonField {
234                name: "overrideUserInfo",
235                kind: JsonFieldKind::Boolean,
236                required: false,
237            },
238        ],
239    )?;
240    if output
241        .get("issuer")
242        .and_then(JsValue::as_str)
243        .is_none_or(|issuer| url::Url::parse(issuer).is_err())
244    {
245        return Err(super::endpoint::validation("[body.issuer] Invalid URL"));
246    }
247    if let Some(config) = output.get("oidcConfig") {
248        _ = super::endpoint::validate_fields(
249            Some(config),
250            "body.oidcConfig",
251            &[
252                JsonField::string("clientId", true),
253                JsonField::string("clientSecret", false),
254                JsonField::string("authorizationEndpoint", false),
255                JsonField::string("tokenEndpoint", false),
256                JsonField::string("jwksEndpoint", false),
257                JsonField::string("discoveryEndpoint", false),
258                JsonField::string("userInfoEndpoint", false),
259                JsonField {
260                    name: "skipDiscovery",
261                    kind: JsonFieldKind::Boolean,
262                    required: false,
263                },
264                JsonField {
265                    name: "pkce",
266                    kind: JsonFieldKind::Boolean,
267                    required: false,
268                },
269            ],
270        )?;
271    }
272    Ok(output)
273}
274#[async_trait]
275impl<S: AuthSchema> AuthPlugin<S> for SsoPlugin {
276    fn name(&self) -> &'static str {
277        "sso"
278    }
279    fn routes(&self) -> Vec<AuthRoute> {
280        vec![
281            AuthRoute::post("/sso/register", "registerSSOProvider"),
282            AuthRoute::get("/sso/get-provider", "getSSOProvider"),
283            AuthRoute::get("/sso/providers", "listSSOProviders"),
284        ]
285    }
286    fn server_endpoints(&self) -> Vec<EndpointDefinition> {
287        vec![
288            super::endpoint::definition(
289                "listSSOProviders",
290                "listSSOProviders",
291                Some("/sso/providers"),
292                HttpMethod::Get,
293            ),
294            super::endpoint::definition(
295                "getSSOProvider",
296                "getSSOProvider",
297                Some("/sso/get-provider"),
298                HttpMethod::Get,
299            ),
300            super::endpoint::definition(
301                "registerSSOProvider",
302                "registerSSOProvider",
303                Some("/sso/register"),
304                HttpMethod::Post,
305            ),
306        ]
307    }
308    fn validate_endpoint(
309        &self,
310        call: &EndpointCall,
311        _ctx: &AuthContext<S>,
312    ) -> AuthResult<EndpointInput> {
313        if call.operation_id() == "listSSOProviders" {
314            return Ok(EndpointInput {
315                body: call.body().cloned(),
316                query: call.query().cloned(),
317            });
318        }
319        if call.operation_id() == "getSSOProvider" {
320            return Ok(EndpointInput {
321                body: None,
322                query: Some(provider_read::validate_query(call.query())?),
323            });
324        }
325        Ok(EndpointInput {
326            body: Some(validate(call.body())?),
327            query: call.query().cloned(),
328        })
329    }
330    async fn on_endpoint(
331        &self,
332        call: &EndpointCall,
333        ctx: &AuthContext<S>,
334    ) -> AuthResult<EndpointResponse> {
335        if call.operation_id() == "listSSOProviders" {
336            return self.list(call, ctx).await;
337        }
338        if call.operation_id() == "getSSOProvider" {
339            return self
340                .read(
341                    call.query()
342                        .ok_or_else(|| super::endpoint::validation("Missing query"))?,
343                    call,
344                    ctx,
345                )
346                .await;
347        }
348        self.register(
349            call.body()
350                .ok_or_else(|| super::endpoint::validation("Missing body"))?,
351            call,
352            ctx,
353        )
354        .await
355    }
356    async fn on_request(
357        &self,
358        req: &AuthRequest,
359        ctx: &AuthContext<S>,
360    ) -> AuthResult<Option<AuthResponse>> {
361        if req.path() == "/sso/providers" && *req.method() == HttpMethod::Get {
362            return Ok(Some(self.list(req, ctx).await?.to_response()?));
363        }
364        if req.path() == "/sso/get-provider" && *req.method() == HttpMethod::Get {
365            let query = alibi_core::utils::json::parse_value(&serde_json::to_string(&req.query)?)?;
366            return Ok(Some(
367                self.read(&provider_read::validate_query(Some(&query))?, req, ctx)
368                    .await?
369                    .to_response()?,
370            ));
371        }
372        if req.path() != "/sso/register" || *req.method() != HttpMethod::Post {
373            return Ok(None);
374        }
375        let input = alibi_core::utils::json::parse_value(
376            std::str::from_utf8(req.body.as_deref().unwrap_or_default())
377                .map_err(|_| super::endpoint::validation("Invalid JSON"))?,
378        )?;
379        Ok(Some(
380            self.register(&validate(Some(&input))?, req, ctx)
381                .await?
382                .to_response()?,
383        ))
384    }
385}