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