better_auth_api/plugins/
one_tap.rs1use 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#[derive(Clone, better_auth_core::PluginConfig)]
13#[plugin(name = "OneTapPlugin")]
14pub struct OneTapConfig {
15 #[config(default = None)]
17 pub client_id: Option<Vec<String>>,
18 #[config(default = false)]
20 pub disable_signup: bool,
21 #[config(default = "https://www.googleapis.com/oauth2/v3/certs".to_owned())]
23 pub google_jwks_url: String,
24}
25
26pub 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}