1use 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#[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 pub client_id: Option<OneTapClientId>,
52 pub disable_signup: bool,
53 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 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#[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#[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}