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