Skip to main content

fraiseql_auth/
multi_provider.rs

1//! Multi-provider authentication — unified entry point for social login.
2//!
3//! Enables `GET /auth/v1/authorize?provider=github&redirect_uri=...` with
4//! automatic provider resolution and state-encoded provider tracking through
5//! the OAuth callback.
6
7use std::{collections::HashMap, sync::Arc};
8
9use axum::{
10    Json,
11    extract::{Query, State},
12    http::StatusCode,
13    response::{IntoResponse, Redirect, Response},
14};
15use serde::{Deserialize, Serialize};
16use url::Url;
17
18use crate::{
19    account_linking::{AccountStore, TrustedEmailProviders},
20    audit::logger::{AuditEventType, SecretType, get_audit_logger},
21    handlers::generate_secure_state,
22    provider::OAuthProvider,
23    session::SessionStore,
24    state_store::StateStore,
25};
26
27/// Maximum length for the `redirect_uri` query parameter.
28const MAX_REDIRECT_URI_BYTES: usize = 2_048;
29
30/// Maximum length for the `provider` query parameter.
31const MAX_PROVIDER_NAME_BYTES: usize = 128;
32
33/// Separator between the provider name and the bound `redirect_uri` in the stored CSRF
34/// state value (#427). A newline cannot appear in a provider name or a (percent-encoded)
35/// URI, so this round-trips unambiguously; a value with no separator is a legacy
36/// provider-only entry with no bound redirect.
37const STATE_VALUE_SEPARATOR: char = '\n';
38
39/// Returns `true` if `candidate` is permitted by the `redirect_uri` allow-list (#427).
40///
41/// A candidate is allowed when some allow-list entry has the **same scheme, host, and
42/// port**, and the entry's path is a **path-boundary prefix** of the candidate's path
43/// (an exact path, or the entry ends at a `/` boundary). Host comparison is exact:
44/// `https://app.example.com` does **not** match `https://app.example.com.evil.com`, and
45/// an entry path of `/cb` does not match `/cbEVIL`. An empty allow-list permits nothing;
46/// an unparseable candidate is rejected.
47#[must_use]
48pub fn is_redirect_uri_allowed(candidate: &str, allowlist: &[String]) -> bool {
49    let Ok(candidate_url) = Url::parse(candidate) else {
50        return false;
51    };
52    allowlist.iter().any(|entry| {
53        Url::parse(entry).is_ok_and(|entry_url| redirect_uri_matches(&candidate_url, &entry_url))
54    })
55}
56
57/// Match a single candidate URL against a single allow-list entry URL (see
58/// [`is_redirect_uri_allowed`]).
59fn redirect_uri_matches(candidate: &Url, entry: &Url) -> bool {
60    if candidate.scheme() != entry.scheme()
61        || candidate.host_str() != entry.host_str()
62        || candidate.port_or_known_default() != entry.port_or_known_default()
63    {
64        return false;
65    }
66    let (candidate_path, entry_path) = (candidate.path(), entry.path());
67    candidate_path == entry_path
68        || candidate_path
69            .strip_prefix(entry_path)
70            .is_some_and(|rest| entry_path.ends_with('/') || rest.starts_with('/'))
71}
72
73/// Encode the CSRF-state stored value, optionally binding a validated `redirect_uri`.
74fn encode_state_value(provider: &str, redirect_uri: Option<&str>) -> String {
75    match redirect_uri {
76        Some(uri) => format!("{provider}{STATE_VALUE_SEPARATOR}{uri}"),
77        None => provider.to_string(),
78    }
79}
80
81/// Decode the CSRF-state stored value into `(provider, bound_redirect_uri)`.
82fn decode_state_value(value: &str) -> (String, Option<String>) {
83    match value.split_once(STATE_VALUE_SEPARATOR) {
84        Some((provider, uri)) => (provider.to_string(), Some(uri.to_string())),
85        None => (value.to_string(), None),
86    }
87}
88
89/// Build the fragment-delivery redirect URL for the implicit-style token hand-off (#427).
90///
91/// Tokens are placed in the URL fragment (`#…`), which browsers neither send to servers nor
92/// include in the `Referer` header — the standard OAuth implicit-flow delivery tradeoff (the
93/// tokens remain visible in browser history). The `redirect_uri` has already been validated
94/// against the allow-list before reaching here.
95fn build_redirect_with_tokens(
96    redirect_uri: &str,
97    access_token: &str,
98    refresh_token: &str,
99    expires_in: u64,
100    provider: &str,
101) -> String {
102    format!(
103        "{redirect_uri}#access_token={}&token_type=Bearer&expires_in={expires_in}&refresh_token={}&provider={}",
104        urlencoding::encode(access_token),
105        urlencoding::encode(refresh_token),
106        urlencoding::encode(provider),
107    )
108}
109
110/// Shared state for the multi-provider auth endpoints.
111#[derive(Clone)]
112pub struct MultiProviderAuthState {
113    /// OAuth providers keyed by name (e.g., "github", "google").
114    providers:               HashMap<String, Arc<dyn OAuthProvider>>,
115    /// CSRF state store (in-memory or Redis).
116    state_store:             Arc<dyn StateStore>,
117    /// Session backend for creating sessions after successful auth.
118    session_store:           Arc<dyn SessionStore>,
119    /// Optional user store for account linking (same email → same user).
120    user_store:              Option<Arc<dyn AccountStore>>,
121    /// Providers trusted to assert email verification for cross-provider auto-linking (#368).
122    ///
123    /// A provider's `email_verified = true` is honored for merging onto an existing
124    /// email-keyed account **only** when the provider is in this set; otherwise the claim is
125    /// downgraded to unverified and the identity is keyed on `(provider, provider_id)`, so an
126    /// untrusted IdP can never collapse into another user's account (fail-closed, mirrors H26).
127    /// Defaults to [`TrustedEmailProviders::builtin_default`] (`google` + `apple`).
128    trusted_email_providers: TrustedEmailProviders,
129    /// Allow-list of permitted `redirect_uri` values (#427).
130    ///
131    /// When **empty** (the default), `callback` returns the session tokens as JSON and no
132    /// server-side redirect is performed — there is no open-redirect surface because the
133    /// client-supplied `redirect_uri` is never used as a redirect target. When **non-empty**,
134    /// `authorize` rejects any `redirect_uri` not matched by the list (400), binds the
135    /// validated URI to the CSRF state token, and `callback` performs an implicit-style
136    /// fragment redirect to it. Entries are matched by scheme + host + port + path-boundary
137    /// prefix (see [`is_redirect_uri_allowed`]).
138    redirect_uri_allowlist:  Vec<String>,
139}
140
141impl MultiProviderAuthState {
142    /// Create a new multi-provider auth state.
143    pub fn new(state_store: Arc<dyn StateStore>, session_store: Arc<dyn SessionStore>) -> Self {
144        Self {
145            providers: HashMap::new(),
146            state_store,
147            session_store,
148            user_store: None,
149            trusted_email_providers: TrustedEmailProviders::default(),
150            redirect_uri_allowlist: Vec::new(),
151        }
152    }
153
154    /// Set the user store for account linking.
155    ///
156    /// When set, the callback handler uses [`AccountStore::link_or_create_user`] to
157    /// resolve provider identities to local users, enabling automatic account
158    /// linking when the same email appears across different providers.
159    pub fn with_user_store(mut self, user_store: Arc<dyn AccountStore>) -> Self {
160        self.user_store = Some(user_store);
161        self
162    }
163
164    /// Set which providers are trusted to assert email verification for cross-provider
165    /// auto-linking (#368).
166    ///
167    /// Defaults to [`TrustedEmailProviders::builtin_default`] (`google` + `apple`). Pass
168    /// [`TrustedEmailProviders::none`] for a high-assurance "trust no one" posture, or
169    /// build a custom set with [`TrustedEmailProviders::trust`] / `distrust`. A provider not
170    /// in the set never auto-links on email even when it claims `email_verified = true`.
171    #[must_use = "builder method returns the modified state"]
172    pub fn with_trusted_email_providers(mut self, trusted: TrustedEmailProviders) -> Self {
173        self.trusted_email_providers = trusted;
174        self
175    }
176
177    /// Configure the allow-list of permitted `redirect_uri` values (#427).
178    ///
179    /// Enabling this turns on the server-side redirect flow: `authorize` rejects any
180    /// `redirect_uri` not on the list, and `callback` redirects the browser to the
181    /// validated URI with the tokens delivered in the URL fragment (OAuth implicit style).
182    /// With no allow-list configured, the legacy JSON-token response is preserved.
183    #[must_use = "builder method returns the modified state"]
184    pub fn with_redirect_uri_allowlist(mut self, allowlist: Vec<String>) -> Self {
185        self.redirect_uri_allowlist = allowlist;
186        self
187    }
188
189    /// Register an OAuth provider under the given name.
190    pub fn register_provider(&mut self, name: impl Into<String>, provider: Arc<dyn OAuthProvider>) {
191        self.providers.insert(name.into(), provider);
192    }
193
194    /// List the names of all registered providers.
195    #[must_use]
196    pub fn provider_names(&self) -> Vec<String> {
197        let mut names: Vec<String> = self.providers.keys().cloned().collect();
198        names.sort();
199        names
200    }
201
202    /// Look up a provider by name.
203    #[must_use]
204    pub fn get_provider(&self, name: &str) -> Option<&Arc<dyn OAuthProvider>> {
205        self.providers.get(name)
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Query / response types
211// ---------------------------------------------------------------------------
212
213/// Query parameters for `GET /auth/v1/authorize`.
214#[derive(Debug, Deserialize)]
215pub struct AuthorizeQuery {
216    /// Provider name (e.g., "github", "google").
217    pub provider:     String,
218    /// Client application callback URI.
219    pub redirect_uri: String,
220}
221
222/// Query parameters for `GET /auth/v1/callback`.
223#[derive(Debug, Deserialize)]
224pub struct CallbackQuery {
225    /// Authorization code from the provider.
226    pub code:              Option<String>,
227    /// CSRF state token.
228    pub state:             Option<String>,
229    /// Provider error code.
230    pub error:             Option<String>,
231    /// Provider error description.
232    pub error_description: Option<String>,
233}
234
235/// Response for `GET /auth/v1/providers`.
236#[derive(Debug, Serialize)]
237pub struct ProvidersResponse {
238    /// Available provider names.
239    pub providers: Vec<String>,
240}
241
242/// Token response returned after a successful callback.
243#[derive(Debug, Serialize)]
244pub struct AuthTokenResponse {
245    /// Access token for API requests.
246    pub access_token:  String,
247    /// Refresh token (if available).
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub refresh_token: Option<String>,
250    /// Token type (always "Bearer").
251    pub token_type:    String,
252    /// Seconds until the access token expires.
253    pub expires_in:    u64,
254    /// Provider that authenticated the user.
255    pub provider:      String,
256}
257
258impl AuthTokenResponse {
259    /// Returns a builder for `AuthTokenResponse`.
260    #[must_use = "builder does nothing until .build() is called"]
261    pub fn builder() -> AuthTokenResponseBuilder {
262        AuthTokenResponseBuilder::default()
263    }
264}
265
266/// Builder for [`AuthTokenResponse`].
267#[derive(Debug, Default)]
268pub struct AuthTokenResponseBuilder {
269    access_token:  Option<String>,
270    refresh_token: Option<String>,
271    token_type:    Option<String>,
272    expires_in:    Option<u64>,
273    provider:      Option<String>,
274}
275
276impl AuthTokenResponseBuilder {
277    /// Sets the access token.
278    pub fn access_token(mut self, access_token: impl Into<String>) -> Self {
279        self.access_token = Some(access_token.into());
280        self
281    }
282
283    /// Sets the refresh token.
284    pub fn refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
285        self.refresh_token = Some(refresh_token.into());
286        self
287    }
288
289    /// Sets the token type (typically `"Bearer"`).
290    pub fn token_type(mut self, token_type: impl Into<String>) -> Self {
291        self.token_type = Some(token_type.into());
292        self
293    }
294
295    /// Sets the number of seconds until the access token expires.
296    #[must_use = "builder method returns modified builder"]
297    pub const fn expires_in(mut self, expires_in: u64) -> Self {
298        self.expires_in = Some(expires_in);
299        self
300    }
301
302    /// Sets the provider that authenticated the user.
303    pub fn provider(mut self, provider: impl Into<String>) -> Self {
304        self.provider = Some(provider.into());
305        self
306    }
307
308    /// Builds the [`AuthTokenResponse`].
309    ///
310    /// # Errors
311    ///
312    /// Returns an error string if any required field (`access_token`, `token_type`,
313    /// `expires_in`, or `provider`) was not set.
314    pub fn build(self) -> Result<AuthTokenResponse, String> {
315        Ok(AuthTokenResponse {
316            access_token:  self
317                .access_token
318                .ok_or("AuthTokenResponse: access_token is required")?,
319            refresh_token: self.refresh_token,
320            token_type:    self.token_type.ok_or("AuthTokenResponse: token_type is required")?,
321            expires_in:    self.expires_in.ok_or("AuthTokenResponse: expires_in is required")?,
322            provider:      self.provider.ok_or("AuthTokenResponse: provider is required")?,
323        })
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Helpers
329// ---------------------------------------------------------------------------
330
331fn json_error(status: StatusCode, message: &str) -> Response {
332    (status, Json(serde_json::json!({ "error": message }))).into_response()
333}
334
335/// Effective email-verified flag for account linking (#368): a provider's claimed
336/// verification is honored only when the provider is trusted to assert it. An untrusted
337/// provider's `email_verified = true` is downgraded to `false`, so the identity is keyed on
338/// `(provider, provider_id)` and can never collapse into an existing email-keyed account.
339fn effective_email_verified(
340    trusted: &TrustedEmailProviders,
341    provider: &str,
342    claimed_verified: bool,
343) -> bool {
344    claimed_verified && trusted.is_trusted(provider)
345}
346
347// ---------------------------------------------------------------------------
348// GET /auth/v1/providers
349// ---------------------------------------------------------------------------
350
351/// List available authentication providers.
352///
353/// # Responses
354///
355/// - `200` JSON `{ providers: ["github", "google", ...] }`
356pub async fn list_providers(
357    State(state): State<Arc<MultiProviderAuthState>>,
358) -> Json<ProvidersResponse> {
359    Json(ProvidersResponse {
360        providers: state.provider_names(),
361    })
362}
363
364// ---------------------------------------------------------------------------
365// GET /auth/v1/authorize
366// ---------------------------------------------------------------------------
367
368/// Initiate the OAuth flow for a specific provider.
369///
370/// Generates a CSRF state token, stores it with the provider name, then
371/// redirects to the provider's authorization URL.
372///
373/// # Query parameters
374///
375/// - `provider` — **required**: provider name (must match a registered provider).
376/// - `redirect_uri` — **required**: client application callback URI. Always validated for presence
377///   and length. When a redirect-URI allow-list is configured (#427, via
378///   [`MultiProviderAuthState::with_redirect_uri_allowlist`]), it must additionally match the
379///   allow-list (else `400`); the validated URI is bound to the CSRF state and [`callback`]
380///   redirects the browser to it with the tokens in the fragment. With no allow-list configured,
381///   the URI is **not** used as a redirect target and [`callback`] returns the session tokens as
382///   JSON — so there is no open-redirect surface in that mode.
383///
384/// # Responses
385///
386/// - `302` — redirect to the provider's authorization endpoint.
387/// - `400` — missing or invalid parameters, unknown provider.
388///
389/// # Errors
390///
391/// Returns a `400` JSON error if the provider is unknown, redirect_uri is empty/oversized,
392/// or the state store is at capacity.
393pub async fn authorize(
394    State(state): State<Arc<MultiProviderAuthState>>,
395    Query(q): Query<AuthorizeQuery>,
396) -> Response {
397    // Validate provider name length
398    if q.provider.len() > MAX_PROVIDER_NAME_BYTES {
399        return json_error(StatusCode::BAD_REQUEST, "provider name exceeds maximum length");
400    }
401
402    // Validate redirect_uri
403    if q.redirect_uri.is_empty() {
404        return json_error(StatusCode::BAD_REQUEST, "redirect_uri is required");
405    }
406    if q.redirect_uri.len() > MAX_REDIRECT_URI_BYTES {
407        return json_error(StatusCode::BAD_REQUEST, "redirect_uri exceeds maximum length");
408    }
409
410    // #427: when an allow-list is configured the `redirect_uri` must match it, and is then
411    // bound to the CSRF state for a server-side redirect in `callback`. With no allow-list
412    // the URI is never used as a redirect target (JSON-token response), so there is no
413    // open-redirect surface and only presence/length are enforced.
414    let bound_redirect_uri = if state.redirect_uri_allowlist.is_empty() {
415        None
416    } else if is_redirect_uri_allowed(&q.redirect_uri, &state.redirect_uri_allowlist) {
417        Some(q.redirect_uri.clone())
418    } else {
419        return json_error(StatusCode::BAD_REQUEST, "redirect_uri is not allow-listed");
420    };
421
422    // Look up provider
423    let Some(provider) = state.get_provider(&q.provider) else {
424        return json_error(StatusCode::BAD_REQUEST, &format!("unknown provider: {}", q.provider));
425    };
426
427    // Generate state and store with provider name (and the bound redirect_uri, if any)
428    let state_value = generate_secure_state();
429
430    let Ok(now) = std::time::SystemTime::now()
431        .duration_since(std::time::UNIX_EPOCH)
432        .map(|d| d.as_secs())
433    else {
434        return json_error(StatusCode::INTERNAL_SERVER_ERROR, "system clock error");
435    };
436
437    let expiry = now + 600; // 10 minutes
438
439    let state_payload = encode_state_value(&q.provider, bound_redirect_uri.as_deref());
440    if let Err(e) = state.state_store.store(state_value.clone(), state_payload, expiry).await {
441        tracing::error!("state store failed: {e}");
442        return json_error(
443            StatusCode::INTERNAL_SERVER_ERROR,
444            "authorization flow could not be started",
445        );
446    }
447
448    // Generate authorization URL
449    let authorization_url = provider.authorization_url(&state_value);
450
451    Redirect::to(&authorization_url).into_response()
452}
453
454// ---------------------------------------------------------------------------
455// GET /auth/v1/callback
456// ---------------------------------------------------------------------------
457
458/// Complete the OAuth flow after the provider redirects back.
459///
460/// Validates the state token, resolves the provider from the stored state,
461/// exchanges the authorization code for tokens, retrieves user info, and
462/// creates a session.
463///
464/// # Query parameters
465///
466/// - `code` — authorization code from the provider.
467/// - `state` — CSRF state token.
468///
469/// # Responses
470///
471/// - `200` JSON `{ access_token, refresh_token?, token_type, expires_in, provider }`
472/// - `400` — invalid state, missing parameters, or provider error.
473/// - `502` — token exchange with the provider failed.
474///
475/// # Errors
476///
477/// Returns `400` if the state is invalid/expired, code is missing, or the provider
478/// returned an error. Returns `502` if the token exchange or user info fetch fails.
479#[allow(clippy::cognitive_complexity)] // Reason: OAuth callback with state validation, token exchange, user info, and session creation
480pub async fn callback(
481    State(state): State<Arc<MultiProviderAuthState>>,
482    Query(q): Query<CallbackQuery>,
483) -> Response {
484    // Surface provider errors
485    if let Some(err) = q.error {
486        let desc = q.error_description.as_deref().unwrap_or("(no description)");
487        tracing::warn!(provider_error = %err, description = %desc, "OAuth provider returned error");
488        let client_message = match err.as_str() {
489            "access_denied" => "Access was denied",
490            "login_required" => "Authentication is required",
491            "invalid_request" | "invalid_scope" => "Invalid authorization request",
492            "server_error" | "temporarily_unavailable" => "Authorization server error",
493            _ => "Authorization failed",
494        };
495        return json_error(StatusCode::BAD_REQUEST, client_message);
496    }
497
498    // Validate required parameters
499    let (Some(code), Some(state_token)) = (q.code, q.state) else {
500        return json_error(StatusCode::BAD_REQUEST, "missing code or state parameter");
501    };
502
503    // Consume state (atomic remove) and decode the provider name + any bound redirect_uri.
504    let Ok((state_payload, expiry)) = state.state_store.retrieve(&state_token).await else {
505        return json_error(StatusCode::BAD_REQUEST, "invalid or expired state token");
506    };
507    let (provider_name, bound_redirect_uri) = decode_state_value(&state_payload);
508
509    // Check state expiry. Fail-closed: if the clock cannot be read, reject rather than
510    // treat the (possibly expired) CSRF state as valid (matches the authorize path).
511    let Ok(now) = std::time::SystemTime::now()
512        .duration_since(std::time::UNIX_EPOCH)
513        .map(|d| d.as_secs())
514    else {
515        return json_error(StatusCode::INTERNAL_SERVER_ERROR, "system clock error");
516    };
517
518    if now > expiry {
519        return json_error(StatusCode::BAD_REQUEST, "state token expired");
520    }
521
522    // Look up provider
523    let Some(provider) = state.get_provider(&provider_name) else {
524        tracing::error!(provider = %provider_name, "provider from state not found in registry");
525        return json_error(StatusCode::INTERNAL_SERVER_ERROR, "provider configuration error");
526    };
527
528    // Exchange code for tokens
529    let token_response = match provider.exchange_code(&code).await {
530        Ok(t) => t,
531        Err(e) => {
532            tracing::error!(error = %e, "token exchange failed");
533            return json_error(StatusCode::BAD_GATEWAY, "token exchange with provider failed");
534        },
535    };
536
537    // Get user info from provider
538    let user_info = match provider.user_info(&token_response.access_token).await {
539        Ok(u) => u,
540        Err(e) => {
541            tracing::error!(error = %e, "user info fetch failed");
542            return json_error(StatusCode::BAD_GATEWAY, "failed to retrieve user information");
543        },
544    };
545
546    // #368: gate the provider's email-verified claim on the trust policy. An untrusted
547    // provider's `email_verified = true` is downgraded to unverified so it can never
548    // auto-link onto another user's email-keyed account (account takeover). Trusted
549    // providers (default: google + apple) keep their claim and link as before.
550    let provider_trusted = state.trusted_email_providers.is_trusted(&provider_name);
551    let email_verified = effective_email_verified(
552        &state.trusted_email_providers,
553        &provider_name,
554        user_info.email_verified,
555    );
556    if user_info.email_verified && !provider_trusted {
557        get_audit_logger().log_failure(
558            AuditEventType::AuthFailure,
559            SecretType::StateToken,
560            None,
561            "social_callback",
562            &format!(
563                "untrusted_provider_email_downgraded:{provider_name} — email_verified claim not \
564                 honored for account linking"
565            ),
566        );
567        tracing::warn!(
568            provider = %provider_name,
569            "provider asserted email_verified but is not in the trusted-email set; treating email \
570             as unverified for account linking (#368)"
571        );
572    }
573
574    // Resolve local user ID — use AccountStore for account linking when available,
575    // otherwise fall back to raw provider user ID.
576    let local_user_id = if let Some(account_store) = &state.user_store {
577        match account_store
578            .link_or_create_user(
579                user_info.email.as_deref(),
580                email_verified,
581                &provider_name,
582                &user_info.id,
583            )
584            .await
585        {
586            Ok(result) => result.user_id,
587            Err(e) => {
588                tracing::error!(error = %e, "account store lookup failed");
589                return json_error(StatusCode::INTERNAL_SERVER_ERROR, "user resolution failed");
590            },
591        }
592    } else {
593        user_info.id.clone()
594    };
595
596    // Create session (7-day expiry)
597    let session_expiry = now + (7 * 24 * 60 * 60);
598    let session_tokens = match state
599        .session_store
600        .create_session(&local_user_id, session_expiry)
601        .await
602    {
603        Ok(t) => t,
604        Err(e) => {
605            tracing::error!(error = %e, "session creation failed");
606            return json_error(StatusCode::INTERNAL_SERVER_ERROR, "session could not be created");
607        },
608    };
609
610    // #427: if a validated redirect_uri was bound at authorize time, hand the tokens off via
611    // an implicit-style fragment redirect; otherwise return the legacy JSON token response.
612    if let Some(redirect_uri) = bound_redirect_uri {
613        let location = build_redirect_with_tokens(
614            &redirect_uri,
615            &session_tokens.access_token,
616            &session_tokens.refresh_token,
617            session_tokens.expires_in,
618            &provider_name,
619        );
620        return Redirect::to(&location).into_response();
621    }
622
623    Json(AuthTokenResponse {
624        access_token:  session_tokens.access_token,
625        refresh_token: Some(session_tokens.refresh_token),
626        token_type:    "Bearer".to_string(),
627        expires_in:    session_tokens.expires_in,
628        provider:      provider_name,
629    })
630    .into_response()
631}
632
633// ---------------------------------------------------------------------------
634// Tests
635// ---------------------------------------------------------------------------
636
637#[cfg(test)]
638mod tests;