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