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