1mod 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}