Skip to main content

alibi_api/plugins/one_tap/
mod.rs

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