Skip to main content

alibi_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_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    // Source's account helper disables browser cookie-cache reads for stateful
475    // accounts while retaining a genuinely established virtual principal.
476    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
562/// Trusted direct account operations. The caller supplies the authorized user;
563/// these methods are not HTTP routes. HTTP handlers always resolve the session
564/// first, and cannot select a principal with a `userId` body/query field.
565pub 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}