Skip to main content

alibi_plugins/one_tap/
mod.rs

1//! Google One Tap authentication with verified Google ID tokens.
2use crate::authentication_helpers::{JsonField, RequestBody, parse_body};
3use crate::oauth::handlers::fetch_user_info_from_provider;
4use crate::oauth::{
5    OAuthConfig, OAuthProcessPolicy, OAuthSignInError, OAuthTokenSet, OAuthUserInfo,
6    OAuthUserInfoRequest, process_oauth_sign_in,
7};
8use alibi_core::error::codes;
9use alibi_core::utils::json::JsValue;
10use alibi_core::{
11    AuthContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute, AuthSchema,
12    HttpMethod,
13};
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::Map;
17use serde_json::json;
18use std::sync::Arc;
19
20const MISSING_CLIENT: &str = "Google client ID is required for One Tap. Set it on the oneTap plugin (clientId) or on socialProviders.google.";
21
22/// Google client IDs accepted as the ID-token audience.
23#[derive(Clone, Debug, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum OneTapClientId {
26    Single(String),
27    Multiple(Vec<String>),
28}
29
30impl From<String> for OneTapClientId {
31    fn from(value: String) -> Self {
32        Self::Single(value)
33    }
34}
35
36impl From<&str> for OneTapClientId {
37    fn from(value: &str) -> Self {
38        Self::Single(value.into())
39    }
40}
41
42impl From<Vec<String>> for OneTapClientId {
43    fn from(value: Vec<String>) -> Self {
44        Self::Multiple(value)
45    }
46}
47
48pub use crate::oauth::OAuthJwksSource;
49
50#[derive(Clone, Default)]
51pub struct OneTapConfig {
52    /// Overrides the registered Google provider's client IDs when truthy.
53    pub client_id: Option<OneTapClientId>,
54    pub disable_signup: bool,
55    /// Optional application transport/cache; defaults to Google's official JWKS endpoint.
56    pub jwks_source: Option<Arc<dyn OAuthJwksSource>>,
57}
58
59impl std::fmt::Debug for OneTapConfig {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("OneTapConfig").finish_non_exhaustive()
62    }
63}
64
65pub struct OneTapPlugin {
66    config: OneTapConfig,
67    keys: Arc<dyn OAuthJwksSource>,
68}
69
70impl std::fmt::Debug for OneTapPlugin {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("OneTapPlugin").finish_non_exhaustive()
73    }
74}
75
76impl Default for OneTapPlugin {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl OneTapPlugin {
83    #[must_use]
84    pub fn new() -> Self {
85        Self::with_config(OneTapConfig::default())
86    }
87    #[must_use]
88    pub fn with_config(config: OneTapConfig) -> Self {
89        let keys = config.jwks_source.clone().unwrap_or_else(|| {
90            Arc::new(crate::oauth::HttpOAuthJwksSource::new(
91                "https://www.googleapis.com/oauth2/v3/certs",
92            ))
93        });
94        Self { config, keys }
95    }
96    async fn callback<S: AuthSchema>(
97        &self,
98        req: &AuthRequest,
99        ctx: &AuthContext<S>,
100    ) -> AuthResult<AuthResponse> {
101        let content_type = req.headers.get("content-type");
102        let media = content_type.map(|value| {
103            value
104                .split(';')
105                .next()
106                .unwrap_or_default()
107                .trim()
108                .to_ascii_lowercase()
109        });
110        if media.as_deref() != Some("application/json") {
111            let message = media.map_or_else(
112                || "Content-Type is required. Allowed types: application/json".to_owned(),
113                |media| {
114                    format!(
115                        "Content-Type \"{media}\" is not allowed. Allowed types: application/json"
116                    )
117                },
118            );
119            return AuthResponse::json(
120                415,
121                &json!({"code":codes::UNSUPPORTED_MEDIA_TYPE,"message":message}),
122            )
123            .map_err(Into::into);
124        }
125        let body: OneTapCallbackInput = match parse_body(req) {
126            Ok(body) => body,
127            Err(response) => return Ok(response),
128        };
129        if !ctx.config.current_origin_check_disabled()
130            && body
131                .callback_url
132                .as_deref()
133                .is_some_and(|url| !ctx.config.is_redirect_target_trusted(url))
134        {
135            return AuthResponse::json(
136                403,
137                &json!({"code":codes::INVALID_CALLBACK_URL,"message":"Invalid callbackURL"}),
138            )
139            .map_err(Into::into);
140        }
141        self.callback_core(req, ctx, body).await
142    }
143
144    #[expect(
145        clippy::too_many_lines,
146        reason = "Keep provider token validation, account linking policy, and session issuance in their request order"
147    )]
148    async fn callback_core<S: AuthSchema>(
149        &self,
150        req: &impl alibi_core::session::SessionRequest,
151        ctx: &AuthContext<S>,
152        body: OneTapCallbackInput,
153    ) -> AuthResult<AuthResponse> {
154        use alibi_core::utils::cookie_utils::{
155            create_session_cookie_with_max_age, create_session_like_cookie, related_cookie_name,
156            sign_cookie_value, verify_cookie_value,
157        };
158        let oauth = ctx.extensions.get::<OAuthConfig>();
159        let provider = oauth.as_ref().and_then(|config| config.provider("google"));
160        let audiences = match &self.config.client_id {
161            Some(OneTapClientId::Single(value)) if !value.is_empty() => vec![value.clone()],
162            Some(OneTapClientId::Multiple(values)) => values.clone(),
163            _ => provider
164                .map(|provider| {
165                    let mut values = vec![provider.client_id.clone()];
166                    values.extend(provider.additional_client_ids.clone());
167                    values
168                })
169                .unwrap_or_default(),
170        };
171        if audiences.is_empty()
172            || !matches!(&self.config.client_id, Some(OneTapClientId::Multiple(_)))
173                && audiences.len() == 1
174                && audiences.first().is_some_and(String::is_empty)
175        {
176            return message(400, MISSING_CLIENT);
177        }
178        let Some(payload) = self.verify(&body.id_token, &audiences).await else {
179            return message(400, "invalid id token");
180        };
181        if !payload.get("sub").is_some_and(js_truthy) {
182            return message(400, "invalid id token");
183        }
184        let hosted_domain = provider
185            .and_then(|provider| provider.hosted_domain.as_deref())
186            .filter(|domain| !domain.is_empty());
187        if let Some(domain) = hosted_domain {
188            let token_domain = payload
189                .get("hd")
190                .and_then(JsValue::as_str)
191                .filter(|value| !value.is_empty());
192            if token_domain.is_none() || domain != "*" && token_domain != Some(domain) {
193                return message(400, "invalid id token");
194            }
195        }
196        // Map the verified token the way the redirect and `signIn.social` flows
197        // do, so the Google provider's mapping or user-info handler applies.
198        let (provider_user, profile) = if let Some(provider) = provider {
199            match fetch_user_info_from_provider(
200                provider,
201                OAuthUserInfoRequest {
202                    id_token: Some(body.id_token.clone()),
203                    ..OAuthUserInfoRequest::default()
204                },
205            )
206            .await
207            {
208                Ok(info) => (info.user, info.data),
209                Err(_) => return message(400, "invalid id token"),
210            }
211        } else {
212            let text = |claim: &str| {
213                payload
214                    .get(claim)
215                    .and_then(JsValue::as_str)
216                    .map(str::to_owned)
217            };
218            let user = OAuthUserInfo {
219                additional_fields: Map::default(),
220                id: String::new(),
221                email: text("email").unwrap_or_default(),
222                name: text("name"),
223                image: text("picture"),
224                email_verified: payload.get("email_verified").is_some_and(|value| {
225                    value.as_bool() == Some(true) || value.as_str() == Some("true")
226                }),
227            };
228            (user, payload.to_json_value()?)
229        };
230        if provider_user.email.is_empty() {
231            return message(400, "Email not available in token");
232        }
233        let Some(sub) = payload
234            .get("sub")
235            .and_then(JsValue::as_str)
236            .filter(|value| !value.is_empty())
237        else {
238            return message(400, "invalid id token");
239        };
240        let user = OAuthUserInfo {
241            id: sub.into(),
242            email: provider_user.email.to_lowercase(),
243            name: Some(provider_user.name.unwrap_or_default()),
244            ..provider_user
245        };
246        let policy = OAuthProcessPolicy {
247            user_validation_method: None,
248            override_user_info: provider
249                .is_some_and(|provider| provider.override_user_info_on_sign_in),
250            require_email_verification: provider
251                .is_some_and(|provider| provider.require_email_verification),
252            callback_url: None,
253            use_updated_user: false,
254        };
255        let tokens = OAuthTokenSet {
256            id_token: Some(body.id_token),
257            scopes: vec!["openid".into(), "profile".into(), "email".into()],
258            ..Default::default()
259        };
260        let headers = req.session_headers();
261        let meta = if let Some(request) = req.physical_request() {
262            alibi_core::RequestMeta::from_request(request)
263        } else {
264            alibi_core::RequestMeta {
265                ip_address: Some(
266                    ctx.config
267                        .advanced
268                        .ip_address
269                        .resolve_ip(headers)
270                        .unwrap_or_default(),
271                ),
272                user_agent: Some(headers.get("user-agent").cloned().unwrap_or_default()),
273            }
274        };
275        let result = process_oauth_sign_in(
276            super::oauth::handlers::OAuthIdentity {
277                provider_name: "google",
278                user: &user,
279                profile: &profile,
280            },
281            &policy,
282            &tokens,
283            self.config.disable_signup || provider.is_some_and(|provider| provider.disable_sign_up),
284            &meta,
285            ctx,
286        )
287        .await;
288        let outcome = match result {
289            Ok(outcome) => outcome,
290            Err(OAuthSignInError::IdentityDenied { code, message }) => {
291                return AuthResponse::json(403, &json!({"code":code,"message":message}))
292                    .map_err(Into::into);
293            }
294            Err(OAuthSignInError::AccountLookup(_)) => {
295                return Ok(crate::oauth::handlers::ambiguous_account_sign_in_response(
296                    ctx,
297                ));
298            }
299            Err(OAuthSignInError::Generic(error)) => return message(401, &error),
300            Err(OAuthSignInError::SessionAuth(error)) => return message(401, &error.to_string()),
301            Err(OAuthSignInError::Banned(error)) => {
302                return AuthResponse::json(
303                    403,
304                    &json!({"code":codes::BANNED_USER,"message":error}),
305                )
306                .map_err(Into::into);
307            }
308            Err(OAuthSignInError::EmailNotVerified) => {
309                return AuthResponse::json(
310                    403,
311                    &json!({"code":codes::EMAIL_NOT_VERIFIED,"message":"Email not verified"}),
312                )
313                .map_err(Into::into);
314            }
315        };
316
317        let dont_remember_name = related_cookie_name(&ctx.config, "dont_remember");
318        let dont_remember = crate::helpers::get_cookie(req, &dont_remember_name)
319            .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
320            .is_some_and(|value| !value.is_empty());
321        let max_age = (!dont_remember).then(|| ctx.config.session.expires_in.num_seconds());
322        let mut response = AuthResponse::json(
323            200,
324            &json!({"token":outcome.session.token,"user":outcome.user}),
325        )?
326        .with_appended_header(
327            "Set-Cookie",
328            create_session_cookie_with_max_age(Some(&outcome.session.token), max_age, &ctx.config)?,
329        );
330        if dont_remember {
331            response = response.with_appended_header(
332                "Set-Cookie",
333                create_session_like_cookie(
334                    &dont_remember_name,
335                    &sign_cookie_value("true", ctx.config.current_secret()),
336                    None,
337                    &ctx.config,
338                )?,
339            );
340        }
341        if let Some(cookie) = outcome.account_cookie.as_ref() {
342            for header in crate::oauth::create_account_cookie_headers(&ctx.config, cookie, req)? {
343                response.headers.append("Set-Cookie", header);
344            }
345        }
346        Ok(response)
347    }
348    /// Authenticate a verified Google credential through the public server API.
349    /// # Errors
350    /// Returns an input serialization error.
351    pub fn one_tap_callback_endpoint(
352        input: &OneTapCallbackInput,
353    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<OneTapAuthenticated>> {
354        alibi_core::endpoint::ServerEndpoint::new("one-tap", "oneTapCallback").with_body(input)
355    }
356
357    async fn verify(&self, token: &str, audience: &[String]) -> Option<JsValue> {
358        let mut policy = crate::oauth::OAuthIdTokenConfig::google();
359        policy.selection = crate::oauth::OAuthJwksSelection::AllMatching;
360        policy.jwks_source = self.keys.clone();
361        crate::oauth::id_token::verify_jwks_token(token, audience, None, &policy).await
362    }
363}
364
365/// Signed Google credential and optional callback URL for a One Tap server call.
366#[derive(Debug, Deserialize, Serialize)]
367#[serde(rename_all = "camelCase")]
368pub struct OneTapCallbackInput {
369    pub id_token: String,
370    #[serde(rename = "callbackURL", skip_serializing_if = "Option::is_none")]
371    pub callback_url: Option<String>,
372}
373
374/// The Google account owner's new session token and public user view.
375#[derive(Debug, Deserialize)]
376pub struct OneTapAuthenticated {
377    pub token: String,
378    pub user: Box<alibi_core::UserView>,
379}
380
381impl RequestBody for OneTapCallbackInput {
382    const FIELDS: &'static [JsonField] = &[
383        JsonField::string("idToken", true),
384        JsonField::string("callbackURL", false),
385    ];
386}
387
388#[async_trait]
389impl<S: AuthSchema> AuthPlugin<S> for OneTapPlugin {
390    route_openapi_metadata!(S);
391
392    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
393        vec![crate::endpoint::definition(
394            "oneTapCallback",
395            "oneTapCallback",
396            Some("/one-tap/callback"),
397            HttpMethod::Post,
398        )]
399    }
400    fn validate_endpoint(
401        &self,
402        call: &alibi_core::endpoint::EndpointCall,
403        _ctx: &AuthContext<S>,
404    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
405        Ok(alibi_core::endpoint::EndpointInput {
406            body: Some(crate::endpoint::validate_fields(
407                call.body(),
408                "body",
409                OneTapCallbackInput::FIELDS,
410            )?),
411            query: call.query().cloned(),
412        })
413    }
414    async fn on_endpoint(
415        &self,
416        call: &alibi_core::endpoint::EndpointCall,
417        ctx: &AuthContext<S>,
418    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
419        let output = self.callback_core(call, ctx, call.body_as()?).await?;
420        let value = alibi_core::utils::json::from_slice::<JsValue>(&output.body)?;
421        let mut response = if output.status >= 400 {
422            alibi_core::endpoint::EndpointResponse::error(crate::endpoint::error_response(
423                output.clone(),
424            ))
425            .with_error_body(value)
426        } else {
427            alibi_core::endpoint::EndpointResponse::value(value)
428        };
429        response.set_status(Some(output.status));
430        response.merge_headers(output.headers);
431        Ok(response)
432    }
433    fn name(&self) -> &'static str {
434        "one-tap"
435    }
436    fn routes(&self) -> Vec<AuthRoute> {
437        vec![AuthRoute::post("/one-tap/callback", "one_tap_callback")]
438    }
439    async fn on_request(
440        &self,
441        req: &AuthRequest,
442        ctx: &AuthContext<S>,
443    ) -> AuthResult<Option<AuthResponse>> {
444        if req.method() == &HttpMethod::Post && req.path() == "/one-tap/callback" {
445            return self.callback(req, ctx).await.map(Some);
446        }
447        Ok(None)
448    }
449}
450
451fn js_truthy(value: &JsValue) -> bool {
452    match value {
453        JsValue::Null => false,
454        JsValue::Bool(value) => *value,
455        JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
456        JsValue::String(value) => !value.is_empty(),
457        JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_) => true,
458    }
459}
460
461fn message(status: u16, message: &str) -> AuthResult<AuthResponse> {
462    AuthResponse::json(status, &json!({"message":message})).map_err(Into::into)
463}