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    #[expect(
95        clippy::too_many_lines,
96        reason = "Keep provider token validation, account linking policy, and session issuance in their request order"
97    )]
98    async fn callback<S: AuthSchema>(
99        &self,
100        req: &AuthRequest,
101        ctx: &AuthContext<S>,
102    ) -> AuthResult<AuthResponse> {
103        use alibi_core::utils::cookie_utils::{
104            create_session_cookie_with_max_age, create_session_like_cookie, related_cookie_name,
105            sign_cookie_value, verify_cookie_value,
106        };
107
108        let content_type = req.headers.get("content-type");
109        let media = content_type.map(|value| {
110            value
111                .split(';')
112                .next()
113                .unwrap_or_default()
114                .trim()
115                .to_ascii_lowercase()
116        });
117        if media.as_deref() != Some("application/json") {
118            let message = media.map_or_else(
119                || "Content-Type is required. Allowed types: application/json".to_owned(),
120                |media| {
121                    format!(
122                        "Content-Type \"{media}\" is not allowed. Allowed types: application/json"
123                    )
124                },
125            );
126            return AuthResponse::json(
127                415,
128                &json!({"code":"UNSUPPORTED_MEDIA_TYPE","message":message}),
129            )
130            .map_err(Into::into);
131        }
132        let body: CallbackBody = match parse_body(req) {
133            Ok(body) => body,
134            Err(response) => return Ok(response),
135        };
136        if !ctx.config.current_origin_check_disabled()
137            && body
138                .callback_url
139                .as_deref()
140                .is_some_and(|url| !ctx.config.is_redirect_target_trusted(url))
141        {
142            return AuthResponse::json(
143                403,
144                &json!({"code":"INVALID_CALLBACK_URL","message":"Invalid callbackURL"}),
145            )
146            .map_err(Into::into);
147        }
148        let oauth = ctx.extensions.get::<OAuthConfig>();
149        let provider = oauth
150            .as_ref()
151            .and_then(|config| config.providers.get("google"));
152        let audiences = match &self.config.client_id {
153            Some(OneTapClientId::Single(value)) if !value.is_empty() => vec![value.clone()],
154            Some(OneTapClientId::Multiple(values)) => values.clone(),
155            _ => provider
156                .map(|provider| {
157                    let mut values = vec![provider.client_id.clone()];
158                    values.extend(provider.additional_client_ids.clone());
159                    values
160                })
161                .unwrap_or_default(),
162        };
163        if audiences.is_empty()
164            || !matches!(&self.config.client_id, Some(OneTapClientId::Multiple(_)))
165                && audiences.len() == 1
166                && audiences.first().is_some_and(String::is_empty)
167        {
168            return message(400, MISSING_CLIENT);
169        }
170        let Some(payload) = self.verify(&body.id_token, &audiences).await else {
171            return message(400, "invalid id token");
172        };
173        if !payload.get("sub").is_some_and(js_truthy) {
174            return message(400, "invalid id token");
175        }
176        let hosted_domain = provider
177            .and_then(|provider| provider.hosted_domain.as_deref())
178            .filter(|domain| !domain.is_empty());
179        if let Some(domain) = hosted_domain {
180            let token_domain = payload
181                .get("hd")
182                .and_then(JsValue::as_str)
183                .filter(|value| !value.is_empty());
184            if token_domain.is_none() || domain != "*" && token_domain != Some(domain) {
185                return message(400, "invalid id token");
186            }
187        }
188        let Some(email) = payload
189            .get("email")
190            .and_then(JsValue::as_str)
191            .filter(|value| !value.is_empty())
192        else {
193            return message(400, "Email not available in token");
194        };
195        let Some(sub) = payload
196            .get("sub")
197            .and_then(JsValue::as_str)
198            .filter(|value| !value.is_empty())
199        else {
200            return message(400, "invalid id token");
201        };
202        let user = OAuthUserInfo {
203            additional_fields: Map::default(),
204            id: sub.into(),
205            email: email.to_lowercase(),
206            name: Some(
207                payload
208                    .get("name")
209                    .and_then(JsValue::as_str)
210                    .unwrap_or_default()
211                    .into(),
212            ),
213            image: payload
214                .get("picture")
215                .and_then(JsValue::as_str)
216                .map(str::to_owned),
217            email_verified: payload.get("email_verified").is_some_and(|value| {
218                value.as_bool() == Some(true) || value.as_str() == Some("true")
219            }),
220        };
221        let policy = OAuthProcessPolicy {
222            override_user_info: false,
223            require_email_verification: provider
224                .is_some_and(|provider| provider.require_email_verification),
225            callback_url: None,
226            use_updated_user: false,
227        };
228        let tokens = OAuthTokenSet {
229            id_token: Some(body.id_token),
230            scopes: vec!["openid".into(), "profile".into(), "email".into()],
231            ..Default::default()
232        };
233        let result = process_oauth_sign_in(
234            super::oauth::handlers::OAuthIdentity {
235                provider_name: "google",
236                user: &user,
237                profile: &payload.to_json_value()?,
238            },
239            &policy,
240            &tokens,
241            self.config.disable_signup || provider.is_some_and(|provider| provider.disable_sign_up),
242            &alibi_core::RequestMeta::from_request(req),
243            ctx,
244        )
245        .await;
246        let outcome = match result {
247            Ok(outcome) => outcome,
248            Err(OAuthSignInError::IdentityDenied { code, message }) => {
249                return AuthResponse::json(403, &json!({"code":code,"message":message}))
250                    .map_err(Into::into);
251            }
252            Err(OAuthSignInError::AccountLookup(_)) => {
253                return Ok(crate::oauth::handlers::ambiguous_account_sign_in_response(
254                    ctx,
255                ));
256            }
257            Err(OAuthSignInError::Generic(error)) => return message(401, &error),
258            Err(OAuthSignInError::SessionAuth(error)) => return message(401, &error.to_string()),
259            Err(OAuthSignInError::Banned(error)) => {
260                return AuthResponse::json(403, &json!({"code":"BANNED_USER","message":error}))
261                    .map_err(Into::into);
262            }
263            Err(OAuthSignInError::EmailNotVerified) => {
264                return AuthResponse::json(
265                    403,
266                    &json!({"code":"EMAIL_NOT_VERIFIED","message":"Email not verified"}),
267                )
268                .map_err(Into::into);
269            }
270        };
271
272        let dont_remember_name = related_cookie_name(&ctx.config, "dont_remember");
273        let dont_remember = crate::helpers::get_cookie(req, &dont_remember_name)
274            .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
275            .is_some_and(|value| !value.is_empty());
276        let max_age = (!dont_remember).then(|| ctx.config.session.expires_in.num_seconds());
277        let mut response = AuthResponse::json(
278            200,
279            &json!({"token":outcome.session.token,"user":outcome.user}),
280        )?
281        .with_appended_header(
282            "Set-Cookie",
283            create_session_cookie_with_max_age(Some(&outcome.session.token), max_age, &ctx.config)?,
284        );
285        if dont_remember {
286            response = response.with_appended_header(
287                "Set-Cookie",
288                create_session_like_cookie(
289                    &dont_remember_name,
290                    &sign_cookie_value("true", ctx.config.current_secret()),
291                    None,
292                    &ctx.config,
293                )?,
294            );
295        }
296        if let Some(cookie) = outcome.account_cookie.as_ref() {
297            for header in crate::oauth::create_account_cookie_headers(&ctx.config, cookie, req)? {
298                response.headers.append("Set-Cookie", header);
299            }
300        }
301        Ok(response)
302    }
303    async fn verify(&self, token: &str, audience: &[String]) -> Option<JsValue> {
304        let mut policy = crate::oauth::OAuthIdTokenConfig::google();
305        policy.selection = crate::oauth::OAuthJwksSelection::AllMatching;
306        policy.jwks_source = self.keys.clone();
307        crate::oauth::id_token::verify_jwks_token(token, audience, None, &policy).await
308    }
309}
310
311#[derive(Deserialize)]
312#[serde(rename_all = "camelCase")]
313struct CallbackBody {
314    id_token: String,
315    callback_url: Option<String>,
316}
317
318impl RequestBody for CallbackBody {
319    const FIELDS: &'static [JsonField] = &[
320        JsonField::string("idToken", true),
321        JsonField::string("callbackURL", false),
322    ];
323}
324
325#[async_trait]
326impl<S: AuthSchema> AuthPlugin<S> for OneTapPlugin {
327    route_openapi_metadata!(S);
328
329    fn name(&self) -> &'static str {
330        "one-tap"
331    }
332    fn routes(&self) -> Vec<AuthRoute> {
333        vec![AuthRoute::post("/one-tap/callback", "one_tap_callback")]
334    }
335    async fn on_request(
336        &self,
337        req: &AuthRequest,
338        ctx: &AuthContext<S>,
339    ) -> AuthResult<Option<AuthResponse>> {
340        if req.method() == &HttpMethod::Post && req.path() == "/one-tap/callback" {
341            return self.callback(req, ctx).await.map(Some);
342        }
343        Ok(None)
344    }
345}
346
347fn js_truthy(value: &JsValue) -> bool {
348    match value {
349        JsValue::Null => false,
350        JsValue::Bool(value) => *value,
351        JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
352        JsValue::String(value) => !value.is_empty(),
353        JsValue::Array(_) | JsValue::Object(_) => true,
354    }
355}
356
357fn message(status: u16, message: &str) -> AuthResult<AuthResponse> {
358    AuthResponse::json(status, &json!({"message":message})).map_err(Into::into)
359}