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