Skip to main content

better_auth_api/plugins/
one_tap.rs

1//! Google One Tap sign-in with verified Google ID tokens.
2
3use better_auth_core::{AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema};
4use serde_json::Value;
5
6use super::{
7    json_body,
8    oauth::{OAuthConfig, OAuthProvider, OAuthTokenSet, OAuthUserInfo},
9};
10
11/// Google One Tap options.
12#[derive(Clone, better_auth_core::PluginConfig)]
13#[plugin(name = "OneTapPlugin")]
14pub struct OneTapConfig {
15    /// Accepted client IDs; defaults to all configured Google OAuth client IDs.
16    #[config(default = None)]
17    pub client_id: Option<Vec<String>>,
18    /// Reject registration of new accounts.
19    #[config(default = false)]
20    pub disable_signup: bool,
21    /// Google public key endpoint. Override only with a trusted Google key mirror.
22    #[config(default = "https://www.googleapis.com/oauth2/v3/certs".to_owned())]
23    pub google_jwks_url: String,
24}
25
26/// Verify Google ID tokens and use the shared OAuth account-linking flow.
27pub struct OneTapPlugin {
28    config: OneTapConfig,
29}
30
31better_auth_core::impl_auth_plugin! {
32    OneTapPlugin, "one-tap";
33    routes { post "/one-tap/callback" => callback, "oneTapCallback", body = request_body; }
34}
35
36impl OneTapPlugin {
37    async fn callback<S: AuthSchema>(
38        &self,
39        req: &AuthRequest,
40        ctx: &AuthContext<S>,
41    ) -> AuthResult<AuthResponse> {
42        let body = match req.validated_body::<CallbackBody>() {
43            Some(body) => body.clone(),
44            None => parse_body(req)?.0,
45        };
46        let token = body.id_token.as_str();
47        if let Some(callback) = body.callback_url.as_deref() {
48            super::oauth::validate_redirect_target(callback, ctx, "Invalid callbackURL")?;
49        }
50        let provider = ctx
51            .extensions
52            .get::<OAuthConfig>()
53            .and_then(|config| config.providers.get("google"));
54        let audience = self.config.client_id.clone().unwrap_or_else(|| {
55            provider
56                .filter(|provider| !provider.client_id.is_empty())
57                .map(OAuthProvider::google_client_ids)
58                .unwrap_or_default()
59        });
60        let Some(first_audience) = audience.first() else {
61            return Err(AuthError::bad_request(
62                "Google client ID is required for One Tap. Set it on the oneTap plugin (clientId) or on socialProviders.google.",
63            ));
64        };
65        let payload =
66            super::oauth::google::verify(token, &audience, None, &self.config.google_jwks_url)
67                .await
68                .ok_or_else(|| AuthError::bad_request("invalid id token"))?
69                .into_value();
70        let claim = |name| payload.get(name).unwrap_or(&Value::Null);
71        if !json_body::is_truthy(claim("sub")) {
72            return Err(AuthError::bad_request("invalid id token"));
73        }
74        let hosted_domain = provider
75            .and_then(|provider| {
76                provider
77                    .authorization_params
78                    .iter()
79                    .find(|(name, _)| name == "hd")
80                    .map(|(_, value)| value.as_str())
81            })
82            .filter(|value| !value.is_empty());
83        if !super::oauth::google::hosted_domain_allowed(hosted_domain, &payload) {
84            return Err(AuthError::bad_request("invalid id token"));
85        }
86        let email = claim("email")
87            .as_str()
88            .filter(|value| !value.is_empty())
89            .ok_or_else(|| AuthError::bad_request("Email not available in token"))?;
90        let Some(subject) = claim("sub").as_str().filter(|value| !value.is_empty()) else {
91            return Err(AuthError::bad_request("invalid id token"));
92        };
93        let user = OAuthUserInfo {
94            additional_fields: Default::default(),
95            id: subject.to_owned(),
96            email: Some(email.to_lowercase()).into(),
97            email_verified: Some(
98                claim("email_verified").as_bool() == Some(true)
99                    || claim("email_verified").as_str() == Some("true"),
100            )
101            .into(),
102            name: Some(claim("name").as_str().unwrap_or_default().to_owned()).into(),
103            image: claim("picture")
104                .as_str()
105                .map(|value| Some(value.to_owned())),
106        };
107        let fallback = OAuthProvider::google(first_audience, "");
108        let provider = provider.unwrap_or(&fallback);
109        super::oauth::sign_in_verified_profile(
110            "google",
111            provider,
112            super::oauth::OAuthUserInfoResponse {
113                user,
114                data: payload,
115            },
116            OAuthTokenSet {
117                id_token: Some(token.to_owned()),
118                scopes: vec!["openid".into(), "profile".into(), "email".into()],
119                ..Default::default()
120            },
121            self.config.disable_signup || provider.disable_sign_up(),
122            req,
123            ctx,
124        )
125        .await
126    }
127}
128
129#[derive(Clone, serde::Deserialize)]
130struct CallbackBody {
131    #[serde(rename = "idToken")]
132    id_token: String,
133    #[serde(rename = "callbackURL")]
134    callback_url: Option<String>,
135}
136fn parse_body(req: &AuthRequest) -> AuthResult<(CallbackBody, Value)> {
137    json_body::string_input(req, &[("idToken", true), ("callbackURL", false)])
138}
139fn request_body(req: &AuthRequest) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
140    let (body, projection) = parse_body(req)?;
141    Ok(better_auth_core::endpoint_input::ValidatedBody::new(
142        Some(projection),
143        body,
144    ))
145}