1use super::OAuthAccountProfile;
2use super::encryption::{
3 encrypt_provider_token_set, maybe_decrypt_with_config, provider_token_nulls,
4};
5use super::handlers::{
6 create_account_cookie_headers, decode_account_cookie, fetch_user_info_from_provider,
7 refresh_tokens_via_provider,
8};
9use super::providers::{OAuthConfig, OAuthTokenSet, OAuthUserInfoRequest};
10use super::state::AccountCookiePayload;
11use super::types::{
12 AccessTokenResponse, AccountInfoAccount, AccountInfoResponse, RefreshTokenResponse,
13};
14use alibi_core::entity::AuthAccount;
15use alibi_core::error::codes;
16use alibi_core::{AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, UpdateAccount};
17use chrono::Utc;
18
19#[derive(Debug, Clone)]
20pub enum OAuthAccountSelection {
21 Id(String),
22 Cookie,
23}
24
25pub(super) trait AccountRequest: alibi_core::session::SessionRequest {
26 fn refresh_context(&self) -> Option<super::providers::OAuthRefreshContext<'_>>;
27}
28impl AccountRequest for AuthRequest {
29 fn refresh_context(&self) -> Option<super::providers::OAuthRefreshContext<'_>> {
30 Some(super::providers::OAuthRefreshContext { request: self })
31 }
32}
33impl AccountRequest for alibi_core::endpoint::EndpointCall {
34 fn refresh_context(&self) -> Option<super::providers::OAuthRefreshContext<'_>> {
35 self.request()
36 .map(|request| super::providers::OAuthRefreshContext { request })
37 }
38}
39
40impl OAuthAccountSelection {
41 fn from_body(req: &AuthRequest) -> Result<Self, String> {
42 let value = req
43 .body_as_json()
44 .map_err(|_error| "Invalid input".to_owned())?;
45 Self::from_value(&value)
46 }
47
48 fn from_query(req: &AuthRequest) -> Result<Self, String> {
49 Self::from_value(&serde_json::Value::Object(
50 req.query
51 .iter()
52 .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
53 .collect(),
54 ))
55 }
56
57 pub(super) fn from_value(value: &serde_json::Value) -> Result<Self, String> {
58 let object = value
59 .as_object()
60 .ok_or_else(|| "Invalid input".to_owned())?;
61 if object
62 .get("userId")
63 .is_some_and(|value_2| !value_2.is_string())
64 {
65 return Err("Invalid input".into());
66 }
67 let account_id = object.get("accountId").and_then(serde_json::Value::as_str);
68 let use_cookie = object
69 .get("useAccountCookie")
70 .and_then(serde_json::Value::as_bool)
71 == Some(true);
72 let (selection, field) = match (account_id, use_cookie) {
73 (Some(id), false) => (Self::Id(id.to_owned()), "accountId"),
74 (None, true) => (Self::Cookie, "useAccountCookie"),
75 _ => return Err("Invalid input".into()),
76 };
77 let unknown: Vec<_> = object
78 .keys()
79 .filter(|key| key.as_str() != field && key.as_str() != "userId")
80 .collect();
81 if !unknown.is_empty() {
82 let suffix = if unknown.len() == 1 { "" } else { "s" };
83 let names = unknown
84 .into_iter()
85 .map(|key| serde_json::Value::String(key.clone()).to_string())
86 .collect::<Vec<_>>()
87 .join(", ");
88 return Err(format!("Unrecognized key{suffix}: {names}"));
89 }
90 Ok(selection)
91 }
92
93 async fn resolve(
94 &self,
95 req: &impl alibi_core::session::SessionRequest,
96 user_id: &str,
97 ctx: &AuthContext<impl alibi_core::AuthSchema>,
98 ) -> AuthResult<AccountCookiePayload> {
99 let account = match self {
100 Self::Id(account_id) => ctx
101 .database
102 .get_user_accounts_record(user_id)
103 .await?
104 .iter()
105 .find(|account| account.id().as_ref() == account_id.as_str())
106 .map(AccountCookiePayload::from_account),
107 Self::Cookie if ctx.config.account.store_account_cookie => {
108 decode_account_cookie(req, &ctx.config)?
109 .filter(|account| account.user_id == user_id)
110 }
111 Self::Cookie => None,
112 };
113 account.ok_or_else(|| AuthError::bad_request("Account not found"))
114 }
115}
116
117const fn access_token_failure() -> AuthError {
118 AuthError::Upstream {
119 status: 400,
120 code: codes::FAILED_TO_GET_ACCESS_TOKEN,
121 message: "Failed to get a valid access token",
122 }
123}
124
125const fn refresh_token_failure() -> AuthError {
126 AuthError::Upstream {
127 status: 400,
128 code: codes::FAILED_TO_REFRESH_ACCESS_TOKEN,
129 message: "Failed to refresh access token",
130 }
131}
132
133fn invalid_selection(location: &str, message: &str) -> AuthResult<AuthResponse> {
134 Ok(AuthResponse::json(
135 400,
136 &alibi_core::ErrorCodeMessageResponse {
137 code: Some(codes::VALIDATION_ERROR.into()),
138 message: format!("[{location}] {message}"),
139 },
140 )?)
141}
142
143fn scopes(account: &AccountCookiePayload) -> Vec<String> {
144 account
145 .scope
146 .as_deref()
147 .unwrap_or_default()
148 .split(',')
149 .map(|scope| scope.trim_matches(super::providers::remaining_profile::js_whitespace))
150 .filter(|scope| !scope.is_empty())
151 .map(str::to_owned)
152 .collect()
153}
154
155async fn persist_tokens(
156 account: &mut AccountCookiePayload,
157 tokens: &OAuthTokenSet,
158 ctx: &AuthContext<impl alibi_core::AuthSchema>,
159 provider: &super::providers::OAuthProvider,
160) -> AuthResult<()> {
161 let raw_policy = provider.authorization.as_ref();
162 let encrypted = encrypt_provider_token_set(ctx, tokens, raw_policy).await?;
163 let preserve_raw = provider
164 .authorization
165 .as_ref()
166 .is_some_and(|policy| policy.preserve_raw_profile_scalars);
167 let refresh_incoming = tokens.raw.as_ref().filter(|_| preserve_raw).map_or_else(
168 || {
169 tokens
170 .refresh_token
171 .as_ref()
172 .is_some_and(|token| !token.is_empty())
173 },
174 |raw| {
175 raw.get("refresh_token")
176 .is_some_and(super::providers::remaining_profile::truthy)
177 },
178 );
179 let id_incoming = tokens.raw.as_ref().filter(|_| preserve_raw).map_or_else(
180 || {
181 tokens
182 .id_token
183 .as_ref()
184 .is_some_and(|token| !token.is_empty())
185 },
186 |raw| {
187 if raw_policy.is_some_and(|policy| policy.token_response_omits_id_token) {
188 return false;
189 }
190 raw.get("id_token")
191 .is_some_and(super::providers::remaining_profile::truthy)
192 },
193 );
194 let mut nulls = provider_token_nulls(tokens, raw_policy);
195 nulls[0] |= tokens
196 .raw
197 .as_ref()
198 .and_then(|raw| raw.get("access_token"))
199 .is_some_and(serde_json::Value::is_null);
200 let update = UpdateAccount {
201 provider_token_nulls: [nulls[0], false, false],
202 access_token: encrypted
203 .access_token
204 .or_else(|| (!nulls[0]).then(|| account.access_token.clone()).flatten()),
205 refresh_token: if refresh_incoming {
206 encrypted.refresh_token
207 } else {
208 account.refresh_token.clone()
209 },
210 id_token: if id_incoming {
211 encrypted.id_token
212 } else {
213 account.id_token.clone()
214 },
215 access_token_expires_at: tokens
216 .access_token_expires_at
217 .or(account.access_token_expires_at),
218 refresh_token_expires_at: tokens
219 .refresh_token_expires_at
220 .or(account.refresh_token_expires_at),
221 ..Default::default()
222 };
223 if let Some(id) = account.id.as_deref() {
224 *account = AccountCookiePayload::from_account(
225 &ctx.database.update_account_record(id, update).await?,
226 );
227 } else {
228 account.access_token = update.access_token;
229 account.refresh_token = update.refresh_token;
230 account.id_token = update.id_token;
231 account.access_token_expires_at = update.access_token_expires_at;
232 account.refresh_token_expires_at = update.refresh_token_expires_at;
233 }
234 Ok(())
235}
236
237async fn valid_access_token(
238 account: &mut AccountCookiePayload,
239 config: &OAuthConfig,
240 req: &impl AccountRequest,
241 ctx: &AuthContext<impl alibi_core::AuthSchema>,
242) -> AuthResult<(OAuthTokenSet, bool)> {
243 let provider = config.providers.get(&account.provider_id).ok_or_else(|| {
244 AuthError::bad_request(format!(
245 "Provider {} is not supported.",
246 account.provider_id
247 ))
248 })?;
249 let original = account.clone();
250 let encrypted = ctx.config.account.encrypt_oauth_tokens;
251 let expired = account.access_token_expires_at.is_some_and(|expires_at| {
252 expires_at.timestamp_millis() - Utc::now().timestamp_millis() < 5_000
253 });
254 let refreshed = if expired
255 && (provider.refresh_access_token.is_some()
256 || provider
257 .authorization
258 .as_ref()
259 .is_none_or(|policy| policy.supports_refresh))
260 && let Some(stored_refresh) = account
261 .refresh_token
262 .as_deref()
263 .filter(|token| !token.is_empty())
264 {
265 let refresh_token = maybe_decrypt_with_config(Some(stored_refresh), encrypted, &ctx.config)
266 .map_err(|_error| access_token_failure())?
267 .unwrap_or_default();
268 let tokens = refresh_tokens_via_provider(provider, &refresh_token, req.refresh_context())
269 .await
270 .map_err(|_error| access_token_failure())?;
271 persist_tokens(account, &tokens, ctx, provider)
272 .await
273 .map_err(|_error| access_token_failure())?;
274 Some(tokens)
275 } else {
276 None
277 };
278 let access_token = match refreshed
279 .as_ref()
280 .and_then(|tokens| tokens.access_token.clone())
281 {
282 Some(token) => token,
283 None => maybe_decrypt_with_config(original.access_token.as_deref(), encrypted, &ctx.config)
284 .map_err(|_error| access_token_failure())?
285 .unwrap_or_default(),
286 };
287 Ok((
288 OAuthTokenSet {
289 access_token: Some(access_token),
290 access_token_expires_at: refreshed
291 .as_ref()
292 .and_then(|tokens| tokens.access_token_expires_at)
293 .or(original.access_token_expires_at),
294 scopes: scopes(account),
295 id_token: refreshed
296 .as_ref()
297 .and_then(|tokens| tokens.id_token.clone())
298 .or(original.id_token),
299 ..Default::default()
300 },
301 refreshed.is_some(),
302 ))
303}
304
305pub(super) async fn handle_get_access_token(
306 config: &OAuthConfig,
307 req: &AuthRequest,
308 ctx: &AuthContext<impl alibi_core::AuthSchema>,
309) -> AuthResult<AuthResponse> {
310 let selection = match OAuthAccountSelection::from_body(req) {
311 Ok(selection) => selection,
312 Err(message) => return invalid_selection("body", &message),
313 };
314 let mut session_request = req.clone();
315 _ = session_request
316 .query
317 .insert("disableCookieCache".into(), "true".into());
318 let user_id = match ctx.require_cached_session(&session_request).await {
319 Ok((_, session)) => session.user_id,
320 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
321 return Ok(AuthResponse::new(401).with_header("Content-Type", "application/json"));
322 }
323 Err(error) => return Err(error),
324 };
325 let (tokens, headers) =
326 get_access_token_for_selection(config, req, ctx, &user_id, selection).await?;
327 let mut response = AuthResponse::json(200, &AccessTokenResponse::from(tokens))?;
328 for header in headers {
329 response.headers.append("set-cookie", header);
330 }
331 Ok(response)
332}
333
334pub(super) async fn get_access_token_for_selection(
335 config: &OAuthConfig,
336 request: &impl AccountRequest,
337 ctx: &AuthContext<impl alibi_core::AuthSchema>,
338 user_id: &str,
339 selection: OAuthAccountSelection,
340) -> AuthResult<(OAuthTokenSet, Vec<String>)> {
341 let mut account = selection.resolve(request, user_id, ctx).await?;
342 let (value, refreshed) = valid_access_token(&mut account, config, request, ctx).await?;
343 let headers = if refreshed && ctx.config.account.store_account_cookie {
344 create_account_cookie_headers(&ctx.config, &account, request)?
345 } else {
346 Vec::new()
347 };
348 Ok((value, headers))
349}
350
351pub(super) async fn handle_refresh_token(
352 config: &OAuthConfig,
353 req: &AuthRequest,
354 ctx: &AuthContext<impl alibi_core::AuthSchema>,
355) -> AuthResult<AuthResponse> {
356 let selection = match OAuthAccountSelection::from_body(req) {
357 Ok(selection) => selection,
358 Err(message) => return invalid_selection("body", &message),
359 };
360 let mut session_request = req.clone();
361 _ = session_request
362 .query
363 .insert("disableCookieCache".into(), "true".into());
364 let user_id = match ctx.require_cached_session(&session_request).await {
365 Ok((_, session)) => session.user_id,
366 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
367 return Ok(AuthResponse::new(401).with_header("Content-Type", "application/json"));
368 }
369 Err(error) => return Err(error),
370 };
371 let (tokens, account, headers) =
372 refresh_token_for_selection(config, req, ctx, &user_id, selection).await?;
373 let mut response = AuthResponse::json(200, &refresh_token_body(config, &tokens, &account)?)?;
374 for header in headers {
375 response.headers.append("set-cookie", header);
376 }
377 Ok(response)
378}
379
380pub(super) async fn refresh_token_for_selection(
381 config: &OAuthConfig,
382 req: &impl AccountRequest,
383 ctx: &AuthContext<impl alibi_core::AuthSchema>,
384 user_id: &str,
385 selection: OAuthAccountSelection,
386) -> AuthResult<(OAuthTokenSet, AccountCookiePayload, Vec<String>)> {
387 let mut account = selection.resolve(req, user_id, ctx).await?;
388 let provider = config.providers.get(&account.provider_id).ok_or_else(|| {
389 AuthError::bad_request(format!(
390 "Provider {} is not supported.",
391 account.provider_id
392 ))
393 })?;
394 if provider.refresh_access_token.is_none()
395 && provider
396 .authorization
397 .as_ref()
398 .is_some_and(|policy| !policy.supports_refresh)
399 {
400 return Err(AuthError::Api {
401 status: 400,
402 code: Some(codes::TOKEN_REFRESH_NOT_SUPPORTED.into()),
403 message: format!(
404 "Provider {} does not support token refreshing.",
405 account.provider_id
406 ),
407 });
408 }
409 let stored_refresh = account
410 .refresh_token
411 .as_deref()
412 .filter(|value| !value.is_empty())
413 .ok_or_else(|| AuthError::bad_request("Refresh token not found"))?;
414 let refresh_token = maybe_decrypt_with_config(
415 Some(stored_refresh),
416 ctx.config.account.encrypt_oauth_tokens,
417 &ctx.config,
418 )
419 .map_err(|_error| refresh_token_failure())?
420 .unwrap_or_default();
421 let tokens = refresh_tokens_via_provider(provider, &refresh_token, req.refresh_context())
422 .await
423 .map_err(|_error| refresh_token_failure())?;
424 persist_tokens(&mut account, &tokens, ctx, provider)
425 .await
426 .map_err(|_error| refresh_token_failure())?;
427 let tokens = OAuthTokenSet {
428 refresh_token: tokens.refresh_token.or(Some(refresh_token)),
429 refresh_token_expires_at: account.refresh_token_expires_at,
430 scopes: scopes(&account),
431 id_token: account.id_token.clone(),
432 ..tokens
433 };
434 let headers = if matches!(selection, OAuthAccountSelection::Cookie)
435 && ctx.config.account.store_account_cookie
436 {
437 create_account_cookie_headers(&ctx.config, &account, req)?
438 } else {
439 Vec::new()
440 };
441 Ok((tokens, account, headers))
442}
443
444pub(super) fn refresh_token_body(
447 config: &OAuthConfig,
448 tokens: &OAuthTokenSet,
449 account: &AccountCookiePayload,
450) -> AuthResult<serde_json::Value> {
451 let policy = config
452 .providers
453 .get(&account.provider_id)
454 .and_then(|provider| provider.authorization.as_ref());
455 let response = RefreshTokenResponse {
456 access_token: tokens.access_token.clone(),
457 access_token_expires_at: tokens
458 .access_token_expires_at
459 .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
460 refresh_token: tokens.refresh_token.clone(),
461 refresh_token_expires_at: tokens
462 .refresh_token_expires_at
463 .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
464 scope: account.scope.clone(),
465 id_token: tokens.id_token.clone(),
466 provider_id: account.provider_id.clone(),
467 account_id: account.id.clone(),
468 };
469 let mut response =
470 serde_json::to_value(response).map_err(|error| AuthError::internal(error.to_string()))?;
471 if let Some(raw) = tokens
472 .raw
473 .as_ref()
474 .filter(|_| policy.is_some_and(|policy| policy.preserve_raw_profile_scalars))
475 {
476 let object = response
477 .as_object_mut()
478 .ok_or_else(|| AuthError::internal("Invalid refresh output"))?;
479 match raw.get("access_token") {
480 Some(value) => {
481 _ = object.insert("accessToken".into(), value.clone());
482 }
483 None => {
484 _ = object.remove("accessToken");
485 }
486 }
487 if let Some(value) = raw.get("refresh_token").filter(|value| !value.is_null()) {
488 _ = object.insert("refreshToken".into(), value.clone());
489 }
490 if let Some(value) = raw
491 .get("id_token")
492 .filter(|_| !policy.is_some_and(|policy| policy.token_response_omits_id_token))
493 .filter(|value| super::providers::remaining_profile::truthy(value))
494 {
495 _ = object.insert("idToken".into(), value.clone());
496 }
497 }
498 Ok(response)
499}
500
501pub(super) async fn handle_account_info(
502 config: &OAuthConfig,
503 req: &AuthRequest,
504 ctx: &AuthContext<impl alibi_core::AuthSchema>,
505) -> AuthResult<AuthResponse> {
506 let selection = match OAuthAccountSelection::from_query(req) {
507 Ok(selection) => selection,
508 Err(message) => return invalid_selection("query", &(message)),
509 };
510 let mut session_request = req.clone();
513 _ = session_request
514 .query
515 .insert("disableCookieCache".into(), "true".into());
516 let user_id = match ctx.require_cached_session(&session_request).await {
517 Ok((_, session)) => session.user_id,
518 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
519 return Ok(AuthResponse::new(401).with_header("Content-Type", "application/json"));
520 }
521 Err(error) => return Err(error),
522 };
523 let (value, headers) =
524 match account_info_for_selection(config, req, ctx, &user_id, selection).await {
525 Ok(response) => response,
526 Err(AuthError::Api {
527 code: Some(code), ..
528 }) if code == "OAUTH_PROFILE_EXCEPTION" => return Ok(AuthResponse::new(500)),
529 Err(error) => return Err(error),
530 };
531 let mut response = AuthResponse::json(200, &value)?;
532 for header in headers {
533 response.headers.append("set-cookie", header);
534 }
535 Ok(response)
536}
537
538pub(super) async fn account_info_for_selection(
539 config: &OAuthConfig,
540 req: &impl AccountRequest,
541 ctx: &AuthContext<impl alibi_core::AuthSchema>,
542 user_id: &str,
543 selection: OAuthAccountSelection,
544) -> AuthResult<(AccountInfoResponse, Vec<String>)> {
545 let mut account = selection.resolve(req, user_id, ctx).await?;
546 let provider = config
547 .providers
548 .get(&account.provider_id)
549 .ok_or(AuthError::Upstream {
550 status: 400,
551 code: codes::PROVIDER_NOT_CONFIGURED,
552 message: "Account is not associated with a configured social provider.",
553 })?;
554 let (tokens, refreshed) = valid_access_token(&mut account, config, req, ctx).await?;
555 let access_token = tokens
556 .access_token
557 .filter(|token| !token.is_empty())
558 .ok_or_else(|| AuthError::bad_request("Access token not found"))?;
559 let info = fetch_user_info_from_provider(
560 provider,
561 OAuthUserInfoRequest {
562 access_token: Some(access_token),
563 access_token_expires_at: account.access_token_expires_at,
564 scopes: tokens.scopes,
565 id_token: tokens.id_token,
566 ..Default::default()
567 },
568 )
569 .await?;
570 let response = AccountInfoResponse {
571 user: info
572 .user_output
573 .unwrap_or_else(|| info.user.public_profile(false)),
574 data: info.data,
575 account: AccountInfoAccount {
576 id: account.id.clone(),
577 provider_id: account.provider_id.clone(),
578 account_id: account.account_id.clone(),
579 },
580 };
581 let headers = if refreshed && ctx.config.account.store_account_cookie {
582 create_account_cookie_headers(&ctx.config, &account, req)?
583 } else {
584 Vec::new()
585 };
586 Ok((response, headers))
587}
588
589pub struct OAuthAccountApi;
593impl OAuthAccountApi {
594 pub async fn get_access_token(
598 user_id: &str,
599 selection: OAuthAccountSelection,
600 ctx: &AuthContext<impl alibi_core::AuthSchema>,
601 ) -> AuthResult<OAuthTokenSet> {
602 let (config, req) = server_request(user_id, &selection, "/get-access-token", ctx)?;
603 let (tokens, _) =
604 get_access_token_for_selection(&config, &req, ctx, user_id, selection).await?;
605 Ok(tokens)
606 }
607 pub async fn refresh_token(
610 user_id: &str,
611 selection: OAuthAccountSelection,
612 ctx: &AuthContext<impl alibi_core::AuthSchema>,
613 ) -> AuthResult<OAuthTokenSet> {
614 let (config, req) = server_request(user_id, &selection, "/refresh-token", ctx)?;
615 let (tokens, _, _) = Box::pin(refresh_token_for_selection(
616 &config, &req, ctx, user_id, selection,
617 ))
618 .await?;
619 Ok(tokens)
620 }
621 pub async fn account_info(
622 user_id: &str,
623 selection: OAuthAccountSelection,
624 ctx: &AuthContext<impl alibi_core::AuthSchema>,
625 ) -> AuthResult<OAuthAccountProfile> {
626 let (config, req) = server_request(user_id, &selection, "/account-info", ctx)?;
627 let (profile, _) = Box::pin(account_info_for_selection(
628 &config, &req, ctx, user_id, selection,
629 ))
630 .await?;
631 Ok(profile)
632 }
633}
634fn server_request(
635 user_id: &str,
636 selection: &OAuthAccountSelection,
637 path: &str,
638 ctx: &AuthContext<impl alibi_core::AuthSchema>,
639) -> AuthResult<(std::sync::Arc<OAuthConfig>, AuthRequest)> {
640 if user_id.is_empty() {
641 return Err(AuthError::Upstream {
642 status: 400,
643 code: codes::USER_ID_OR_SESSION_REQUIRED,
644 message: "Either userId or session is required",
645 });
646 }
647 let config = ctx
648 .extensions
649 .get::<OAuthConfig>()
650 .ok_or_else(|| AuthError::config("OAuth plugin is not initialized"))?;
651 let mut req = AuthRequest::new(
652 if path == "/account-info" {
653 alibi_core::HttpMethod::Get
654 } else {
655 alibi_core::HttpMethod::Post
656 },
657 path,
658 );
659 match selection {
660 OAuthAccountSelection::Id(id) => {
661 _ = req.query.insert("accountId".into(), id.clone());
662 req.body = Some(
663 serde_json::to_vec(&serde_json::json!({"accountId":id}))
664 .map_err(|e| AuthError::internal(e.to_string()))?,
665 );
666 }
667 OAuthAccountSelection::Cookie => return Err(AuthError::bad_request("Account not found")),
668 }
669 Ok((config, req))
670}