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