Skip to main content

alibi_api/plugins/oauth/
account.rs

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
304///
305/// # Errors
306/// Returns an error when validation, storage, or an application callback fails.
307async 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
340///
341/// # Errors
342/// Returns an error when validation, storage, or an application callback fails.
343async 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
470///
471/// # Errors
472/// Returns an error when validation, storage, or an application callback fails.
473async 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    // Source's account helper disables browser cookie-cache reads for stateful
484    // accounts while retaining a genuinely established virtual principal.
485    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
573/// Trusted direct account operations. The caller supplies the authorized user;
574/// these methods are not HTTP routes. HTTP handlers always resolve the session
575/// first, and cannot select a principal with a `userId` body/query field.
576pub struct OAuthAccountApi;
577impl OAuthAccountApi {
578    /// # Errors
579    /// Returns storage, provider, selection, or token errors.
580    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    /// # Errors
589    /// Returns storage, provider, selection, or refresh errors.
590    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    /// # Errors
599    /// Returns storage, provider, selection, or profile errors.
600    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}