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_else(
151 || {
152 tokens
153 .refresh_token
154 .as_ref()
155 .is_some_and(|token| !token.is_empty())
156 },
157 |raw| {
158 raw.get("refresh_token")
159 .is_some_and(super::providers::remaining_profile::truthy)
160 },
161 );
162 let id_incoming = tokens.raw.as_ref().filter(|_| preserve_raw).map_or_else(
163 || {
164 tokens
165 .id_token
166 .as_ref()
167 .is_some_and(|token| !token.is_empty())
168 },
169 |raw| {
170 if raw_policy.is_some_and(|policy| policy.token_response_omits_id_token) {
171 return false;
172 }
173 raw.get("id_token")
174 .is_some_and(super::providers::remaining_profile::truthy)
175 },
176 );
177 let mut nulls = provider_token_nulls(tokens, raw_policy);
178 nulls[0] |= tokens
179 .raw
180 .as_ref()
181 .and_then(|raw| raw.get("access_token"))
182 .is_some_and(serde_json::Value::is_null);
183 let update = UpdateAccount {
184 provider_token_nulls: [nulls[0], false, false],
185 access_token: encrypted
186 .access_token
187 .or_else(|| (!nulls[0]).then(|| account.access_token.clone()).flatten()),
188 refresh_token: if refresh_incoming {
189 encrypted.refresh_token
190 } else {
191 account.refresh_token.clone()
192 },
193 id_token: if id_incoming {
194 encrypted.id_token
195 } else {
196 account.id_token.clone()
197 },
198 access_token_expires_at: tokens
199 .access_token_expires_at
200 .or(account.access_token_expires_at),
201 refresh_token_expires_at: tokens
202 .refresh_token_expires_at
203 .or(account.refresh_token_expires_at),
204 ..Default::default()
205 };
206 if let Some(id) = account.id.as_deref() {
207 *account = AccountCookiePayload::from_account(
208 &ctx.database.update_account_record(id, update).await?,
209 );
210 } else {
211 account.access_token = update.access_token;
212 account.refresh_token = update.refresh_token;
213 account.id_token = update.id_token;
214 account.access_token_expires_at = update.access_token_expires_at;
215 account.refresh_token_expires_at = update.refresh_token_expires_at;
216 }
217 Ok(())
218}
219
220async fn valid_access_token(
221 account: &mut AccountCookiePayload,
222 config: &OAuthConfig,
223 req: &AuthRequest,
224 ctx: &AuthContext<impl alibi_core::AuthSchema>,
225) -> AuthResult<(AccessTokenResponse, bool)> {
226 let provider = config.providers.get(&account.provider_id).ok_or_else(|| {
227 AuthError::bad_request(format!(
228 "Provider {} is not supported.",
229 account.provider_id
230 ))
231 })?;
232 let original = account.clone();
233 let encrypted = ctx.config.account.encrypt_oauth_tokens;
234 let expired = account.access_token_expires_at.is_some_and(|expires_at| {
235 expires_at.timestamp_millis() - Utc::now().timestamp_millis() < 5_000
236 });
237 let refreshed = if expired
238 && (provider.refresh_access_token.is_some()
239 || provider
240 .authorization
241 .as_ref()
242 .is_none_or(|policy| policy.supports_refresh))
243 && let Some(stored_refresh) = account
244 .refresh_token
245 .as_deref()
246 .filter(|token| !token.is_empty())
247 {
248 let refresh_token = maybe_decrypt_with_config(Some(stored_refresh), encrypted, &ctx.config)
249 .map_err(|_error| access_token_failure())?
250 .unwrap_or_default();
251 let tokens = refresh_tokens_via_provider(
252 provider,
253 &refresh_token,
254 Some(super::providers::OAuthRefreshContext { request: req }),
255 )
256 .await
257 .map_err(|_error| access_token_failure())?;
258 persist_tokens(account, &tokens, ctx, provider)
259 .await
260 .map_err(|_error| access_token_failure())?;
261 Some(tokens)
262 } else {
263 None
264 };
265 let access_token = match refreshed
266 .as_ref()
267 .and_then(|tokens| tokens.access_token.clone())
268 {
269 Some(token) => token,
270 None => maybe_decrypt_with_config(original.access_token.as_deref(), encrypted, &ctx.config)
271 .map_err(|_error| access_token_failure())?
272 .unwrap_or_default(),
273 };
274 Ok((
275 AccessTokenResponse {
276 access_token: Some(access_token),
277 access_token_expires_at: refreshed
278 .as_ref()
279 .and_then(|tokens| tokens.access_token_expires_at)
280 .or(original.access_token_expires_at)
281 .map(|value| value.to_rfc3339()),
282 scopes: scopes(account),
283 id_token: refreshed
284 .as_ref()
285 .and_then(|tokens| tokens.id_token.clone())
286 .or(original.id_token),
287 },
288 refreshed.is_some(),
289 ))
290}
291
292fn token_response(
293 value: &impl serde::Serialize,
294 account: &AccountCookiePayload,
295 set_cookie: bool,
296 req: &AuthRequest,
297 ctx: &AuthContext<impl alibi_core::AuthSchema>,
298) -> AuthResult<AuthResponse> {
299 let mut response = AuthResponse::json(200, value)?;
300 if set_cookie && ctx.config.account.store_account_cookie {
301 for header in create_account_cookie_headers(&ctx.config, account, req)? {
302 response.headers.append("Set-Cookie", header);
303 }
304 }
305 Ok(response)
306}
307
308async fn handle_get_access_token_for_user(
309 config: &OAuthConfig,
310 req: &AuthRequest,
311 ctx: &AuthContext<impl alibi_core::AuthSchema>,
312 server_user: Option<&str>,
313) -> AuthResult<AuthResponse> {
314 let selection = match OAuthAccountSelection::from_body(req) {
315 Ok(selection) => selection,
316 Err(message) => return invalid_selection("body", &message),
317 };
318 let mut session_request = req.clone();
319 _ = session_request
320 .query
321 .insert("disableCookieCache".into(), "true".into());
322 let user_id = if let Some(user_id) = server_user {
323 user_id.to_owned()
324 } else {
325 let (_, session) = match ctx.require_cached_session(&session_request).await {
326 Ok(session) => session,
327 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
328 return Ok(AuthResponse::new(401).with_header("Content-Type", "application/json"));
329 }
330 Err(error) => return Err(error),
331 };
332 session.user_id
333 };
334 let mut account = selection.resolve(req, &user_id, ctx).await?;
335 let (response, refreshed) = valid_access_token(&mut account, config, req, ctx).await?;
336 token_response(&response, &account, refreshed, req, ctx)
337}
338
339async fn handle_refresh_token_for_user(
340 config: &OAuthConfig,
341 req: &AuthRequest,
342 ctx: &AuthContext<impl alibi_core::AuthSchema>,
343 server_user: Option<&str>,
344) -> AuthResult<AuthResponse> {
345 let selection = match OAuthAccountSelection::from_body(req) {
346 Ok(selection) => selection,
347 Err(message) => return invalid_selection("body", &message),
348 };
349 let mut session_request = req.clone();
350 _ = session_request
351 .query
352 .insert("disableCookieCache".into(), "true".into());
353 let user_id = if let Some(user_id) = server_user {
354 user_id.to_owned()
355 } else {
356 let (_, session) = match ctx.require_cached_session(&session_request).await {
357 Ok(session) => session,
358 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
359 return Ok(AuthResponse::new(401).with_header("Content-Type", "application/json"));
360 }
361 Err(error) => return Err(error),
362 };
363 session.user_id
364 };
365 let mut account = selection.resolve(req, &user_id, ctx).await?;
366 let provider = config.providers.get(&account.provider_id).ok_or_else(|| {
367 AuthError::bad_request(format!(
368 "Provider {} is not supported.",
369 account.provider_id
370 ))
371 })?;
372 if provider.refresh_access_token.is_none()
373 && provider
374 .authorization
375 .as_ref()
376 .is_some_and(|policy| !policy.supports_refresh)
377 {
378 return Ok(AuthResponse::json(
379 400,
380 &serde_json::json!({"code":"TOKEN_REFRESH_NOT_SUPPORTED","message":format!("Provider {} does not support token refreshing.",account.provider_id)}),
381 )?);
382 }
383 let stored_refresh = account
384 .refresh_token
385 .as_deref()
386 .filter(|value| !value.is_empty())
387 .ok_or_else(|| AuthError::bad_request("Refresh token not found"))?;
388 let refresh_token = maybe_decrypt_with_config(
389 Some(stored_refresh),
390 ctx.config.account.encrypt_oauth_tokens,
391 &ctx.config,
392 )
393 .map_err(|_error| refresh_token_failure())?
394 .unwrap_or_default();
395 let tokens = refresh_tokens_via_provider(
396 provider,
397 &refresh_token,
398 Some(super::providers::OAuthRefreshContext { request: req }),
399 )
400 .await
401 .map_err(|_error| refresh_token_failure())?;
402 persist_tokens(&mut account, &tokens, ctx, provider)
403 .await
404 .map_err(|_error| refresh_token_failure())?;
405 let raw_grant = tokens.raw.clone().filter(|_| {
406 provider
407 .authorization
408 .as_ref()
409 .is_some_and(|policy| policy.preserve_raw_profile_scalars)
410 });
411 let response = RefreshTokenResponse {
412 access_token: tokens.access_token,
413 access_token_expires_at: tokens
414 .access_token_expires_at
415 .map(|value| value.to_rfc3339()),
416 refresh_token: Some(tokens.refresh_token.unwrap_or(refresh_token)),
417 refresh_token_expires_at: account
418 .refresh_token_expires_at
419 .map(|value| value.to_rfc3339()),
420 scope: account.scope.clone(),
421 id_token: account.id_token.clone(),
422 provider_id: account.provider_id.clone(),
423 account_id: account.id.clone(),
424 };
425 let mut response =
426 serde_json::to_value(response).map_err(|error| AuthError::internal(error.to_string()))?;
427 if let Some(raw) = raw_grant {
428 let object = response
429 .as_object_mut()
430 .ok_or_else(|| AuthError::internal("Invalid refresh output"))?;
431 match raw.get("access_token") {
432 Some(value) => {
433 _ = object.insert("accessToken".into(), value.clone());
434 }
435 None => {
436 _ = object.remove("accessToken");
437 }
438 }
439 if let Some(value) = raw.get("refresh_token").filter(|value| !value.is_null()) {
440 _ = object.insert("refreshToken".into(), value.clone());
441 }
442 if let Some(value) = raw
443 .get("id_token")
444 .filter(|_| {
445 !provider
446 .authorization
447 .as_ref()
448 .is_some_and(|policy| policy.token_response_omits_id_token)
449 })
450 .filter(|value| super::providers::remaining_profile::truthy(value))
451 {
452 _ = object.insert("idToken".into(), value.clone());
453 }
454 }
455 token_response(
456 &response,
457 &account,
458 matches!(selection, OAuthAccountSelection::Cookie),
459 req,
460 ctx,
461 )
462}
463
464async fn handle_account_info_for_user(
465 config: &OAuthConfig,
466 req: &AuthRequest,
467 ctx: &AuthContext<impl alibi_core::AuthSchema>,
468 server_user: Option<&str>,
469) -> AuthResult<AuthResponse> {
470 let selection = match OAuthAccountSelection::from_query(req) {
471 Ok(selection) => selection,
472 Err(message) => return invalid_selection("query", &(message)),
473 };
474 let mut session_request = req.clone();
477 _ = session_request
478 .query
479 .insert("disableCookieCache".into(), "true".into());
480 let user_id = if let Some(user_id) = server_user {
481 user_id.to_owned()
482 } else {
483 let (_, session) = match ctx.require_cached_session(&session_request).await {
484 Ok(session) => session,
485 Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
486 return Ok(AuthResponse::new(401).with_header("Content-Type", "application/json"));
487 }
488 Err(error) => return Err(error),
489 };
490 session.user_id
491 };
492 let mut account = selection.resolve(req, &user_id, ctx).await?;
493 let provider = config
494 .providers
495 .get(&account.provider_id)
496 .ok_or(AuthError::Upstream {
497 status: 400,
498 code: "PROVIDER_NOT_CONFIGURED",
499 message: "Account is not associated with a configured social provider.",
500 })?;
501 let (tokens, refreshed) = valid_access_token(&mut account, config, req, ctx).await?;
502 let access_token = tokens
503 .access_token
504 .filter(|token| !token.is_empty())
505 .ok_or_else(|| AuthError::bad_request("Access token not found"))?;
506 let info = match fetch_user_info_from_provider(
507 provider,
508 OAuthUserInfoRequest {
509 access_token: Some(access_token),
510 access_token_expires_at: account.access_token_expires_at,
511 scopes: tokens.scopes,
512 id_token: tokens.id_token,
513 ..Default::default()
514 },
515 )
516 .await
517 {
518 Ok(info) => info,
519 Err(AuthError::Api {
520 code: Some(code), ..
521 }) if code == "OAUTH_PROFILE_EXCEPTION" => return Ok(AuthResponse::new(500)),
522 Err(error) => return Err(error),
523 };
524 let response = AccountInfoResponse {
525 user: info
526 .user_output
527 .unwrap_or_else(|| info.user.public_profile(false)),
528 data: info.data,
529 account: AccountInfoAccount {
530 id: account.id.clone(),
531 provider_id: account.provider_id.clone(),
532 account_id: account.account_id.clone(),
533 },
534 };
535 token_response(&response, &account, refreshed, req, ctx)
536}
537
538pub(super) async fn handle_get_access_token(
539 config: &OAuthConfig,
540 req: &AuthRequest,
541 ctx: &AuthContext<impl alibi_core::AuthSchema>,
542) -> AuthResult<AuthResponse> {
543 handle_get_access_token_for_user(config, req, ctx, None).await
544}
545
546pub(super) async fn handle_refresh_token(
547 config: &OAuthConfig,
548 req: &AuthRequest,
549 ctx: &AuthContext<impl alibi_core::AuthSchema>,
550) -> AuthResult<AuthResponse> {
551 handle_refresh_token_for_user(config, req, ctx, None).await
552}
553
554pub(super) async fn handle_account_info(
555 config: &OAuthConfig,
556 req: &AuthRequest,
557 ctx: &AuthContext<impl alibi_core::AuthSchema>,
558) -> AuthResult<AuthResponse> {
559 handle_account_info_for_user(config, req, ctx, None).await
560}
561
562pub struct OAuthAccountApi;
566impl OAuthAccountApi {
567 pub async fn get_access_token(
568 user_id: &str,
569 selection: OAuthAccountSelection,
570 ctx: &AuthContext<impl alibi_core::AuthSchema>,
571 ) -> AuthResult<AuthResponse> {
572 let (config, req) = server_request(user_id, selection, "/get-access-token", ctx)?;
573 handle_get_access_token_for_user(&config, &req, ctx, Some(user_id)).await
574 }
575 pub async fn refresh_token(
576 user_id: &str,
577 selection: OAuthAccountSelection,
578 ctx: &AuthContext<impl alibi_core::AuthSchema>,
579 ) -> AuthResult<AuthResponse> {
580 let (config, req) = server_request(user_id, selection, "/refresh-token", ctx)?;
581 handle_refresh_token_for_user(&config, &req, ctx, Some(user_id)).await
582 }
583 pub async fn account_info(
584 user_id: &str,
585 selection: OAuthAccountSelection,
586 ctx: &AuthContext<impl alibi_core::AuthSchema>,
587 ) -> AuthResult<AuthResponse> {
588 let (config, req) = server_request(user_id, selection, "/account-info", ctx)?;
589 handle_account_info_for_user(&config, &req, ctx, Some(user_id)).await
590 }
591}
592fn server_request(
593 user_id: &str,
594 selection: OAuthAccountSelection,
595 path: &str,
596 ctx: &AuthContext<impl alibi_core::AuthSchema>,
597) -> AuthResult<(std::sync::Arc<OAuthConfig>, AuthRequest)> {
598 if user_id.is_empty() {
599 return Err(AuthError::Upstream {
600 status: 400,
601 code: "USER_ID_OR_SESSION_REQUIRED",
602 message: "Either userId or session is required",
603 });
604 }
605 let config = ctx
606 .extensions
607 .get::<OAuthConfig>()
608 .ok_or_else(|| AuthError::config("OAuth plugin is not initialized"))?;
609 let mut req = AuthRequest::new(
610 if path == "/account-info" {
611 alibi_core::HttpMethod::Get
612 } else {
613 alibi_core::HttpMethod::Post
614 },
615 path,
616 );
617 match selection {
618 OAuthAccountSelection::Id(id) => {
619 _ = req.query.insert("accountId".into(), id.clone());
620 req.body = Some(
621 serde_json::to_vec(&serde_json::json!({"accountId":id}))
622 .map_err(|e| AuthError::internal(e.to_string()))?,
623 );
624 }
625 OAuthAccountSelection::Cookie => return Err(AuthError::bad_request("Account not found")),
626 }
627 Ok((config, req))
628}