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