Skip to main content

alibi_plugins/oauth/
account.rs

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