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