alibi_api/plugins/one_tap/
mod.rs1use 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#[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 pub client_id: Option<OneTapClientId>,
51 pub disable_signup: bool,
52 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}