1use super::{OAuthAccessToken, OAuthAccountProfile, OAuthAccountSelection, OAuthPlugin};
3use alibi_core::endpoint::{
4 EndpointCall, EndpointContextPatch, EndpointDefinition, EndpointInput, EndpointResponse,
5 ServerEndpoint,
6};
7use alibi_core::utils::json::JsValue;
8use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, HttpMethod};
9
10pub(super) fn definitions() -> Vec<EndpointDefinition> {
11 vec![
12 crate::endpoint::definition(
13 "callbackOAuth",
14 "handleOAuthCallback",
15 Some("/callback/:id"),
16 HttpMethod::Get,
17 ),
18 crate::endpoint::definition(
19 "getAccessToken",
20 "getAccessToken",
21 Some("/get-access-token"),
22 HttpMethod::Post,
23 ),
24 crate::endpoint::definition(
25 "refreshToken",
26 "refreshToken",
27 Some("/refresh-token"),
28 HttpMethod::Post,
29 ),
30 crate::endpoint::definition(
31 "accountInfo",
32 "accountInfo",
33 Some("/account-info"),
34 HttpMethod::Get,
35 ),
36 crate::endpoint::definition(
37 "signInSocial",
38 "socialSignIn",
39 Some("/sign-in/social"),
40 HttpMethod::Post,
41 ),
42 crate::endpoint::definition(
43 "linkSocialAccount",
44 "linkSocialAccount",
45 Some("/link-social"),
46 HttpMethod::Post,
47 ),
48 ]
49}
50#[derive(Debug, serde::Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct OAuthRefreshResult {
55 pub access_token: Option<serde_json::Value>,
56 pub refresh_token: Option<serde_json::Value>,
57 pub id_token: Option<serde_json::Value>,
58 pub access_token_expires_at: Option<String>,
59 pub refresh_token_expires_at: Option<String>,
60 pub scope: Option<String>,
61 pub provider_id: String,
62 pub account_id: Option<String>,
63}
64
65fn selection_input(call: &EndpointCall) -> AuthResult<serde_json::Value> {
66 Ok(alibi_core::utils::json::from_value(
67 if call.operation_id() == "accountInfo" {
68 call.query()
69 } else {
70 call.body()
71 }
72 .cloned()
73 .unwrap_or(JsValue::Null),
74 )?)
75}
76pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
77 if call.operation_id() == "handleOAuthCallback" {
78 return validate_callback(call);
79 }
80 if matches!(call.operation_id(), "socialSignIn" | "linkSocialAccount") {
81 return validate_social(call);
82 }
83 _ = OAuthAccountSelection::from_value(&selection_input(call)?).map_err(|message| {
84 crate::endpoint::validation(format!(
85 "[{}] {message}",
86 if call.operation_id() == "accountInfo" {
87 "query"
88 } else {
89 "body"
90 }
91 ))
92 })?;
93 Ok(EndpointInput {
94 body: call.body().cloned(),
95 query: call.query().cloned(),
96 })
97}
98impl OAuthPlugin {
99 #[must_use]
101 pub fn get_access_token_endpoint(
102 selection: OAuthAccountSelection,
103 user_id: Option<&str>,
104 ) -> ServerEndpoint<OAuthAccessToken> {
105 Self::account_endpoint("getAccessToken", selection, user_id)
106 }
107 #[must_use]
109 pub fn refresh_token_endpoint(
110 selection: OAuthAccountSelection,
111 user_id: Option<&str>,
112 ) -> ServerEndpoint<OAuthRefreshResult> {
113 Self::account_endpoint("refreshToken", selection, user_id)
114 }
115 #[must_use]
117 pub fn account_info_endpoint(
118 selection: OAuthAccountSelection,
119 user_id: Option<&str>,
120 ) -> ServerEndpoint<OAuthAccountProfile> {
121 Self::account_endpoint("accountInfo", selection, user_id)
122 }
123 fn account_endpoint<T>(
124 operation: &'static str,
125 selection: OAuthAccountSelection,
126 user_id: Option<&str>,
127 ) -> ServerEndpoint<T> {
128 let mut input = JsValue::Object(match selection {
129 OAuthAccountSelection::Id(id) => [("accountId".into(), JsValue::String(id))]
130 .into_iter()
131 .collect(),
132 OAuthAccountSelection::Cookie => [("useAccountCookie".into(), JsValue::Bool(true))]
133 .into_iter()
134 .collect(),
135 });
136 if let (Some(user), JsValue::Object(input)) = (user_id, &mut input) {
137 let _ = input.insert("userId".into(), JsValue::String(user.into()));
138 }
139 let endpoint = ServerEndpoint::new("oauth", operation);
140 if operation == "accountInfo" {
141 endpoint.with_query_value(input)
142 } else {
143 endpoint.with_body_value(input)
144 }
145 }
146 pub(super) async fn call_account_endpoint(
147 &self,
148 call: &EndpointCall,
149 ctx: &AuthContext<impl AuthSchema>,
150 ) -> AuthResult<EndpointResponse> {
151 let json = selection_input(call)?;
152 let selection =
153 OAuthAccountSelection::from_value(&json).map_err(crate::endpoint::validation)?;
154 let mut input = call.clone();
155 if ctx.config.session.has_server_session_store() {
156 EndpointContextPatch {
157 query: Some(JsValue::Object(
158 [("disableCookieCache".into(), JsValue::Bool(true))]
159 .into_iter()
160 .collect(),
161 )),
162 ..Default::default()
163 }
164 .apply(&mut input);
165 }
166 let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, &input, false)
167 .await
168 .ok()
169 .flatten();
170 let user_id = if let Some(read) = read {
171 let user = ctx.user_view(&read.user);
172 call.record_authenticated_session(user.clone(), read.session);
173 user.id
174 } else {
175 if call.request().is_some() || call.headers().is_some() {
176 return Ok(EndpointResponse::error(AuthError::Api {
177 status: 401,
178 code: None,
179 message: String::new(),
180 })
181 .without_error_body());
182 }
183 json.get("userId")
184 .and_then(serde_json::Value::as_str)
185 .filter(|user| !user.is_empty())
186 .ok_or(AuthError::Upstream {
187 status: 400,
188 code: "USER_ID_OR_SESSION_REQUIRED",
189 message: "Either userId or session is required",
190 })?
191 .to_owned()
192 };
193 let (value, cookies) = if call.operation_id() == "accountInfo" {
194 let (value, cookies) = match Box::pin(super::account::account_info_for_selection(
195 &self.config,
196 call,
197 ctx,
198 &user_id,
199 selection,
200 ))
201 .await
202 {
203 Ok(response) => response,
204 Err(AuthError::Api {
205 code: Some(code), ..
206 }) if code == "OAUTH_PROFILE_EXCEPTION" => {
207 return Ok(EndpointResponse::error(AuthError::Api {
208 status: 500,
209 code: None,
210 message: String::new(),
211 })
212 .without_error_body());
213 }
214 Err(error) => return Err(error),
215 };
216 (
217 serde_json::to_value(value)
218 .map_err(|error| AuthError::internal(error.to_string()))?,
219 cookies,
220 )
221 } else if call.operation_id() == "refreshToken" {
222 let (tokens, account, cookies) = Box::pin(super::account::refresh_token_for_selection(
223 &self.config,
224 call,
225 ctx,
226 &user_id,
227 selection,
228 ))
229 .await?;
230 (
231 super::account::refresh_token_body(&self.config, &tokens, &account)?,
232 cookies,
233 )
234 } else {
235 let (tokens, cookies) = super::account::get_access_token_for_selection(
236 &self.config,
237 call,
238 ctx,
239 &user_id,
240 selection,
241 )
242 .await?;
243 (
244 serde_json::to_value(OAuthAccessToken::from(tokens))
245 .map_err(|error| AuthError::internal(error.to_string()))?,
246 cookies,
247 )
248 };
249 let mut response = EndpointResponse::json(&value)?;
250 for cookie in cookies {
251 response = response.with_header("set-cookie", cookie);
252 }
253 Ok(response)
254 }
255}
256
257fn validate_social(call: &EndpointCall) -> AuthResult<EndpointInput> {
258 use crate::authentication_helpers::{JsonField, JsonFieldKind};
259 if call.body().is_some_and(|body| body.as_object().is_some())
260 && !call
261 .body()
262 .and_then(|body| body.get("provider"))
263 .is_some_and(JsValue::is_string)
264 {
265 return Err(crate::endpoint::validation("[body.provider] Invalid input"));
266 }
267 let fields = &[
268 JsonField::string("provider", true),
269 JsonField::string("callbackURL", false),
270 JsonField::string("newUserCallbackURL", false),
271 JsonField::string("errorCallbackURL", false),
272 JsonField::string("loginHint", false),
273 JsonField {
274 name: "disableRedirect",
275 kind: JsonFieldKind::Boolean,
276 required: false,
277 },
278 JsonField {
279 name: "requestSignUp",
280 kind: JsonFieldKind::Boolean,
281 required: false,
282 },
283 JsonField {
284 name: "idToken",
285 kind: JsonFieldKind::Record,
286 required: false,
287 },
288 JsonField {
289 name: "additionalData",
290 kind: JsonFieldKind::Record,
291 required: false,
292 },
293 JsonField {
294 name: "additionalParams",
295 kind: JsonFieldKind::Record,
296 required: false,
297 },
298 ];
299 let linking = call.operation_id() == "linkSocialAccount";
300 let fields = fields
301 .iter()
302 .filter(|field| !linking || field.name != "newUserCallbackURL")
303 .map(|field| JsonField {
304 name: field.name,
305 kind: field.kind,
306 required: field.required,
307 })
308 .collect::<Vec<_>>();
309 let mut body = crate::endpoint::validate_fields(call.body(), "body", &fields)?;
310 if let Some(scopes) = call.body().and_then(|body| body.get("scopes")) {
311 let values = scopes.as_array().ok_or_else(|| {
312 crate::endpoint::validation(format!(
313 "[body.scopes] Invalid input: expected array, received {}",
314 crate::authentication_helpers::json_type(Some(scopes))
315 ))
316 })?;
317 for (index, value) in values.iter().enumerate() {
318 if !value.is_string() {
319 return Err(crate::endpoint::validation(format!(
320 "[body.scopes.{index}] Invalid input: expected string, received {}",
321 crate::authentication_helpers::json_type(Some(value))
322 )));
323 }
324 }
325 if let JsValue::Object(body) = &mut body {
326 _ = body.insert("scopes".into(), scopes.clone());
327 }
328 }
329 if let Some(params) = body.get("additionalParams").and_then(JsValue::as_object) {
330 for (key, value) in params {
331 if !value.is_string() {
332 return Err(crate::endpoint::validation(format!(
333 "[body.additionalParams.{key}] Invalid input: expected string, received {}",
334 crate::authentication_helpers::json_type(Some(value))
335 )));
336 }
337 }
338 }
339 if let Some(token) = body.get("idToken") {
340 let token_fields = &[
341 JsonField::string("token", true),
342 JsonField::string("nonce", false),
343 JsonField::string("accessToken", false),
344 JsonField::string("refreshToken", false),
345 JsonField {
346 name: "user",
347 kind: JsonFieldKind::Record,
348 required: false,
349 },
350 ];
351 let token_fields = token_fields
352 .iter()
353 .filter(|field| !linking || field.name != "user")
354 .map(|field| JsonField {
355 name: field.name,
356 kind: field.kind,
357 required: field.required,
358 })
359 .collect::<Vec<_>>();
360 let parsed_token =
361 crate::endpoint::validate_fields(Some(token), "body.idToken", &token_fields)?;
362 if linking && let JsValue::Object(body) = &mut body {
363 _ = body.insert("idToken".into(), parsed_token);
364 }
365 }
366 let parsed: super::OAuthSocialSignInRequest =
367 alibi_core::utils::json::from_value(body.clone())?;
368 super::handlers::validate_authorization_params(parsed.additional_params.as_ref())?;
369 if linking && call.headers().is_none() {
370 return Err(crate::endpoint::validation("Headers is required"));
371 }
372 Ok(EndpointInput {
373 body: Some(body),
374 query: call.query().cloned(),
375 })
376}
377
378impl OAuthPlugin {
379 pub fn link_social_account_endpoint(
381 body: &super::OAuthLinkSocialRequest,
382 ) -> AuthResult<ServerEndpoint<super::OAuthSocialSignInResponse>> {
383 ServerEndpoint::new("oauth", "linkSocialAccount").with_body(body)
384 }
385 pub(super) async fn call_link_endpoint(
386 &self,
387 call: &EndpointCall,
388 ctx: &AuthContext<impl AuthSchema>,
389 ) -> AuthResult<EndpointResponse> {
390 let body: super::OAuthLinkSocialRequest = call.body_as()?;
391 let (user, session) =
392 ctx.require_cached_session(call)
393 .await
394 .map_err(|_| AuthError::Upstream {
395 status: 401,
396 code: "UNAUTHORIZED",
397 message: "Unauthorized",
398 })?;
399 call.record_authenticated_session(ctx.user_view(&user), session.clone());
400 if let Some(token) = &body.id_token {
401 let provider = self
402 .config
403 .providers
404 .get(&body.provider)
405 .ok_or_else(|| AuthError::not_found("Provider not found"))?;
406 return EndpointResponse::json(
407 &Box::pin(super::handlers::link_with_id_token_core(
408 &body, token, provider, &session, ctx,
409 ))
410 .await?,
411 );
412 }
413 let flow = match Box::pin(super::handlers::link_social_core(
414 &body,
415 &session,
416 &self.config,
417 call.request(),
418 ctx,
419 ))
420 .await
421 {
422 Ok(flow) => flow,
423 Err(AuthError::Config(_)) => {
424 return Ok(EndpointResponse::error(AuthError::Api {
425 status: 500,
426 code: None,
427 message: String::new(),
428 })
429 .without_error_body());
430 }
431 Err(error) => return Err(error),
432 };
433 flow_response(&flow, &ctx.config)
434 }
435 pub fn sign_in_social_endpoint(
437 body: &super::OAuthSocialSignInRequest,
438 ) -> AuthResult<ServerEndpoint<super::OAuthSocialSignInResponse>> {
439 ServerEndpoint::new("oauth", "signInSocial").with_body(body)
440 }
441 pub(super) async fn call_social_endpoint(
442 &self,
443 call: &EndpointCall,
444 ctx: &AuthContext<impl AuthSchema>,
445 ) -> AuthResult<EndpointResponse> {
446 let body: super::OAuthSocialSignInRequest = call.body_as()?;
447 let headers = call.headers().cloned().unwrap_or_default();
448 let meta = alibi_core::RequestMeta {
449 ip_address: Some(
450 ctx.config
451 .advanced
452 .ip_address
453 .resolve_ip(&headers)
454 .unwrap_or_default(),
455 ),
456 user_agent: Some(headers.get("user-agent").cloned().unwrap_or_default()),
457 };
458 if let Some(token) = &body.id_token {
459 let provider = self
460 .config
461 .providers
462 .get(&body.provider)
463 .ok_or_else(|| AuthError::not_found("Provider not found"))?;
464 let value =
465 super::handlers::sign_in_with_id_token_core(&body, token, provider, &meta, ctx)
466 .await?;
467 let mut response = EndpointResponse::json(&value)?;
468 if let Some(token) = &value.token {
469 response.merge_headers(crate::authentication_helpers::session_cookie_headers(
470 &ctx.config,
471 token,
472 false,
473 )?);
474 }
475 return Ok(response);
476 }
477 start_social_flow_response(&body, &self.config, call.request(), ctx).await
478 }
479}
480
481pub(crate) async fn start_social_flow_response(
482 body: &super::OAuthSocialSignInRequest,
483 config: &super::OAuthConfig,
484 request: Option<&alibi_core::AuthRequest>,
485 ctx: &AuthContext<impl AuthSchema>,
486) -> AuthResult<EndpointResponse> {
487 let flow = match Box::pin(super::handlers::social_sign_in_core(
488 body, config, request, ctx,
489 ))
490 .await
491 {
492 Ok(flow) => flow,
493 Err(AuthError::Config(_)) => {
494 return Ok(EndpointResponse::error(AuthError::Api {
495 status: 500,
496 code: None,
497 message: String::new(),
498 })
499 .without_error_body());
500 }
501 Err(error) => return Err(error),
502 };
503 flow_response(&flow, &ctx.config)
504}
505
506fn flow_response(
507 flow: &super::handlers::InitiatedOAuthFlow,
508 config: &alibi_core::AuthConfig,
509) -> AuthResult<EndpointResponse> {
510 let mut response = EndpointResponse::json(&flow.response)?;
511 if flow.response.redirect
512 && let Some(url) = &flow.response.url
513 {
514 response = response.with_header("location", url);
515 }
516 let cookie = match config.account.store_state_strategy {
517 alibi_core::OAuthStateStrategy::Automatic => {
518 return Err(AuthError::config(
519 "OAuth state strategy has not been initialized",
520 ));
521 }
522 alibi_core::OAuthStateStrategy::Database => {
523 super::handlers::state_cookie_header(config, config.current_secret(), &flow.state)?
524 }
525 alibi_core::OAuthStateStrategy::Cookie => {
526 super::handlers::cookie_state_header(config, &flow.payload)?
527 }
528 };
529 Ok(response.with_header("set-cookie", cookie))
530}
531
532fn validate_callback(call: &EndpointCall) -> AuthResult<EndpointInput> {
533 let fields = [
534 "code",
535 "error",
536 "device_id",
537 "error_description",
538 "state",
539 "user",
540 "iss",
541 ]
542 .map(|name| crate::authentication_helpers::JsonField::string(name, false));
543 let parse = |value: Option<&JsValue>, location: &str| {
544 value
545 .map(|value| crate::endpoint::validate_fields(Some(value), location, &fields))
546 .transpose()
547 };
548 Ok(EndpointInput {
549 body: parse(call.body(), "body")?,
550 query: parse(call.query(), "query")?,
551 })
552}
553impl OAuthPlugin {
554 pub fn callback_oauth_endpoint(
557 provider: &str,
558 query: Option<&super::OAuthCallbackInput>,
559 ) -> AuthResult<ServerEndpoint<JsValue>> {
560 let endpoint = ServerEndpoint::new("oauth", "callbackOAuth")
561 .with_params(&serde_json::json!({"id":provider}))?;
562 match query {
563 Some(query) => endpoint.with_query(query),
564 None => Ok(endpoint),
565 }
566 }
567 pub(super) async fn call_callback_endpoint(
568 &self,
569 call: &EndpointCall,
570 ctx: &AuthContext<impl AuthSchema>,
571 ) -> AuthResult<EndpointResponse> {
572 let invalid =
573 || super::handlers::callback_failure_redirect(ctx, "invalid_callback_request");
574 let method = call.method().unwrap_or(&HttpMethod::Get);
575 let response = if !matches!(method, HttpMethod::Get | HttpMethod::Post)
576 || (method == &HttpMethod::Get && call.query().is_none())
577 {
578 invalid()
579 } else {
580 let provider = call
581 .params()
582 .and_then(|params| params.get("id"))
583 .map(JsValue::coerce_string)
584 .transpose()
585 .map_err(AuthError::internal)?
586 .ok_or_else(|| AuthError::internal("Missing OAuth provider parameters"))?;
587 let map =
588 |value: Option<&JsValue>| -> AuthResult<std::collections::HashMap<String, String>> {
589 value
590 .cloned()
591 .map_or_else(
592 || Ok(std::collections::HashMap::new()),
593 alibi_core::utils::json::from_value,
594 )
595 .map_err(AuthError::from)
596 };
597 let query = map(call.query())?;
598 if method == &HttpMethod::Post {
599 let mut data = map(call.body())?;
600 data.extend(query);
601 super::handlers::callback_post_response(ctx, &provider, &data)
602 } else {
603 Box::pin(super::handlers::callback_core(
604 &self.config,
605 &provider,
606 &query,
607 call,
608 ctx,
609 ))
610 .await?
611 }
612 };
613 let mut output = if response.status == 302 {
614 EndpointResponse::redirect(
615 response
616 .headers
617 .get("location")
618 .cloned()
619 .unwrap_or_default(),
620 )
621 } else {
622 EndpointResponse::error(AuthError::Api {
623 status: response.status,
624 code: None,
625 message: String::new(),
626 })
627 .without_error_body()
628 };
629 for (name, value) in response.headers {
630 if !name.eq_ignore_ascii_case("content-type") {
633 output = output.with_header(name, value);
634 }
635 }
636 Ok(output)
637 }
638}