1use base64::Engine;
2use core_db_types::models::{
3 MagicLinkRequest, MagicLinkRequestId, Organization, OrganizationMember, User, UserGoogleAccount, UserId,
4};
5use core_db_types::schema::{magic_link_requests, organization_members, organizations, user_google_accounts, users};
6use core_traits::EmailServiceClient;
7use diesel::{BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
8use diesel_async::RunQueryDsl;
9use ext_traits::{OptionExt, RequestExt, ResultExt};
10use geo_ip::GeoIpRequestExt;
11use pb::scufflecloud::core::v1::CaptchaProvider;
12use sha2::Digest;
13use tonic::Code;
14use tonic_types::{ErrorDetails, StatusExt};
15
16use crate::cedar::{Action, CoreApplication, Unauthenticated};
17use crate::common::normalize_email;
18use crate::http_ext::CoreRequestExt;
19use crate::operations::{Operation, OperationDriver};
20use crate::{captcha, common, google_api};
21
22impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::LoginWithMagicLinkRequest> {
23 type Principal = Unauthenticated;
24 type Resource = CoreApplication;
25 type Response = ();
26
27 const ACTION: Action = Action::RequestMagicLink;
28
29 async fn validate(&mut self) -> Result<(), tonic::Status> {
30 let global = &self.global::<G>()?;
31 let captcha = self.get_ref().captcha.clone().require("captcha")?;
32
33 match captcha.provider() {
35 CaptchaProvider::Unspecified => {
36 return Err(tonic::Status::with_error_details(
37 Code::InvalidArgument,
38 "captcha provider must be set",
39 ErrorDetails::new(),
40 ));
41 }
42 CaptchaProvider::Turnstile => {
43 captcha::turnstile::verify_in_tonic(global, self.ip_address_info()?.ip_address, &captcha.token).await?;
44 }
45 }
46
47 Ok(())
48 }
49
50 async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
51 Ok(Unauthenticated)
52 }
53
54 async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
55 Ok(CoreApplication)
56 }
57
58 async fn execute(
59 self,
60 driver: &mut OperationDriver<'_, G>,
61 _principal: Self::Principal,
62 _resource: Self::Resource,
63 ) -> Result<Self::Response, tonic::Status> {
64 let global = &self.global::<G>()?;
65
66 let email = normalize_email(&self.get_ref().email);
67
68 let conn = driver.conn().await?;
69
70 let user = common::get_user_by_email(conn, &email).await?;
71 let user_id = user.as_ref().map(|u| u.id);
72
73 let code = common::generate_random_bytes().into_tonic_internal_err("failed to generate magic link code")?;
74 let code_base64 = base64::prelude::BASE64_URL_SAFE.encode(code);
75
76 let timeout = global.timeout_config().magic_link_request;
77
78 let session_request = MagicLinkRequest {
80 id: MagicLinkRequestId::new(),
81 user_id,
82 email: email.clone(),
83 code: code.to_vec(),
84 expires_at: chrono::Utc::now() + timeout,
85 };
86 diesel::insert_into(magic_link_requests::dsl::magic_link_requests)
87 .values(session_request)
88 .execute(conn)
89 .await
90 .into_tonic_internal_err("failed to insert magic link user session request")?;
91
92 let email_msg = if user_id.is_none() {
94 core_emails::register_with_email_email(&self.dashboard_origin::<G>()?, code_base64, timeout)
95 .into_tonic_internal_err("failed to render registration email")?
96 } else {
97 core_emails::magic_link_email(&self.dashboard_origin::<G>()?, code_base64, timeout)
98 .into_tonic_internal_err("failed to render magic link email")?
99 };
100
101 let email_msg = common::email_to_pb(global, email, user.and_then(|u| u.preferred_name), email_msg);
102
103 global
104 .email_service()
105 .send_email(email_msg)
106 .await
107 .into_tonic_internal_err("failed to send magic link email")?;
108
109 Ok(())
110 }
111}
112
113#[derive(Clone)]
114struct CompleteLoginWithMagicLinkState {
115 create_user: bool,
116}
117
118impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteLoginWithMagicLinkRequest> {
119 type Principal = User;
120 type Resource = CoreApplication;
121 type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
122
123 const ACTION: Action = Action::LoginWithMagicLink;
124
125 async fn load_principal(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
126 let conn = driver.conn().await?;
127
128 let Some(magic_link_request) = diesel::delete(magic_link_requests::dsl::magic_link_requests)
130 .filter(
131 magic_link_requests::dsl::code
132 .eq(&self.get_ref().code)
133 .and(magic_link_requests::dsl::expires_at.gt(chrono::Utc::now())),
134 )
135 .returning(MagicLinkRequest::as_select())
136 .get_result::<MagicLinkRequest>(conn)
137 .await
138 .optional()
139 .into_tonic_internal_err("failed to delete magic link request")?
140 else {
141 return Err(tonic::Status::with_error_details(
142 Code::NotFound,
143 "unknown code",
144 ErrorDetails::new(),
145 ));
146 };
147
148 let mut state = CompleteLoginWithMagicLinkState { create_user: false };
149
150 let user = if let Some(user_id) = magic_link_request.user_id {
152 users::dsl::users
153 .find(user_id)
154 .first::<User>(conn)
155 .await
156 .into_tonic_internal_err("failed to query user")?
157 } else {
158 state.create_user = true;
159
160 let hash = sha2::Sha256::digest(&magic_link_request.email);
161 let avatar_url = format!("https://gravatar.com/avatar/{:x}?s=80&d=identicon", hash);
162
163 User {
164 id: UserId::new(),
165 preferred_name: None,
166 first_name: None,
167 last_name: None,
168 password_hash: None,
169 primary_email: Some(magic_link_request.email),
170 avatar_url: Some(avatar_url),
171 }
172 };
173
174 self.extensions_mut().insert(state);
175
176 Ok(user)
177 }
178
179 async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
180 Ok(CoreApplication)
181 }
182
183 async fn execute(
184 mut self,
185 driver: &mut OperationDriver<'_, G>,
186 principal: Self::Principal,
187 _resource: Self::Resource,
188 ) -> Result<Self::Response, tonic::Status> {
189 let global = &self.global::<G>()?;
190 let ip_info = self.ip_address_info()?;
191 let dashboard_origin = self.dashboard_origin::<G>()?;
192 let state: CompleteLoginWithMagicLinkState = self
193 .extensions_mut()
194 .remove()
195 .into_tonic_internal_err("missing CompleteLoginWithMagicLinkState state")?;
196
197 let device = self.into_inner().device.require("device")?;
198
199 let conn = driver.conn().await?;
200
201 if state.create_user {
202 common::create_user(conn, &principal).await?;
203 }
204
205 let new_token = common::create_session(
206 global,
207 conn,
208 &dashboard_origin,
209 &principal,
210 device,
211 &ip_info,
212 !state.create_user,
213 )
214 .await?;
215 Ok(new_token)
216 }
217}
218
219impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::LoginWithEmailAndPasswordRequest> {
220 type Principal = User;
221 type Resource = CoreApplication;
222 type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
223
224 const ACTION: Action = Action::LoginWithEmailPassword;
225
226 async fn validate(&mut self) -> Result<(), tonic::Status> {
227 let global = &self.global::<G>()?;
228 let captcha = self.get_ref().captcha.clone().require("captcha")?;
229
230 match captcha.provider() {
232 CaptchaProvider::Unspecified => {
233 return Err(tonic::Status::with_error_details(
234 Code::InvalidArgument,
235 "captcha provider must be set",
236 ErrorDetails::new(),
237 ));
238 }
239 CaptchaProvider::Turnstile => {
240 captcha::turnstile::verify_in_tonic(global, self.ip_address_info()?.ip_address, &captcha.token).await?;
241 }
242 }
243
244 Ok(())
245 }
246
247 async fn load_principal(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
248 let conn = driver.conn().await?;
249 let Some(user) = common::get_user_by_email(conn, &self.get_ref().email).await? else {
250 return Err(tonic::Status::with_error_details(
251 tonic::Code::NotFound,
252 "user not found",
253 ErrorDetails::new(),
254 ));
255 };
256
257 Ok(user)
258 }
259
260 async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
261 Ok(CoreApplication)
262 }
263
264 async fn execute(
265 self,
266 driver: &mut OperationDriver<'_, G>,
267 principal: Self::Principal,
268 _resource: Self::Resource,
269 ) -> Result<Self::Response, tonic::Status> {
270 let global = &self.global::<G>()?;
271 let ip_info = self.ip_address_info()?;
272 let dashboard_origin = self.dashboard_origin::<G>()?;
273 let payload = self.into_inner();
274
275 let conn = driver.conn().await?;
276
277 let device = payload.device.require("device")?;
278
279 let Some(password_hash) = &principal.password_hash else {
281 return Err(tonic::Status::with_error_details(
282 tonic::Code::FailedPrecondition,
283 "user does not have a password set",
284 ErrorDetails::new(),
285 ));
286 };
287
288 common::verify_password(password_hash, &payload.password)?;
289
290 common::create_session(global, conn, &dashboard_origin, &principal, device, &ip_info, true).await
291 }
292}
293
294#[derive(Clone, Default)]
295struct CompleteLoginWithGoogleState {
296 first_login: bool,
297 google_workspace: Option<pb::scufflecloud::core::v1::complete_login_with_google_response::GoogleWorkspace>,
298}
299
300impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteLoginWithGoogleRequest> {
301 type Principal = User;
302 type Resource = CoreApplication;
303 type Response = pb::scufflecloud::core::v1::CompleteLoginWithGoogleResponse;
304
305 const ACTION: Action = Action::LoginWithGoogle;
306
307 async fn validate(&mut self) -> Result<(), tonic::Status> {
308 let device = self.get_ref().device.clone().require("device")?;
309 let device_fingerprint = sha2::Sha256::digest(&device.public_key_data);
310 let state = urlencoding::decode(&self.get_ref().state).into_tonic_internal_err("failed to decode state")?;
311 let state = base64::prelude::BASE64_URL_SAFE
312 .decode(state.as_ref())
313 .into_tonic_internal_err("failed to decode state")?;
314
315 if *device_fingerprint != state {
316 return Err(tonic::Status::with_error_details(
317 tonic::Code::FailedPrecondition,
318 "device fingerprint does not match state",
319 ErrorDetails::new(),
320 ));
321 }
322
323 Ok(())
324 }
325
326 async fn load_principal(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
327 let global = &self.global::<G>()?;
328
329 let google_token = google_api::request_tokens(global, &self.dashboard_origin::<G>()?, &self.get_ref().code)
330 .await
331 .into_tonic_err_with_field_violation("code", "failed to request google token")?;
332
333 let workspace_user = if google_token.scope.contains(google_api::ADMIN_DIRECTORY_API_USER_SCOPE) {
335 if let Some(hd) = google_token.id_token.hd.clone() {
336 google_api::request_google_workspace_user(global, &google_token.access_token, &google_token.id_token.sub)
337 .await
338 .into_tonic_internal_err("failed to request Google Workspace user")?
339 .map(|u| (u, hd))
340 } else {
341 None
342 }
343 } else {
344 None
345 };
346
347 let mut state = CompleteLoginWithGoogleState {
348 first_login: false,
349 google_workspace: None,
350 };
351
352 let conn = driver.conn().await?;
353
354 if let Some((workspace_user, hd)) = workspace_user
356 && workspace_user.is_admin
357 {
358 let n = diesel::update(organizations::dsl::organizations)
359 .filter(organizations::dsl::google_customer_id.eq(&workspace_user.customer_id))
360 .set(organizations::dsl::google_hosted_domain.eq(&google_token.id_token.hd))
361 .execute(conn)
362 .await
363 .into_tonic_internal_err("failed to update organization")?;
364
365 if n == 0 {
366 state.google_workspace = Some(pb::scufflecloud::core::v1::complete_login_with_google_response::GoogleWorkspace::UnassociatedGoogleHostedDomain(hd));
367 }
368 }
369
370 let google_account = user_google_accounts::dsl::user_google_accounts
371 .find(&google_token.id_token.sub)
372 .first::<UserGoogleAccount>(conn)
373 .await
374 .optional()
375 .into_tonic_internal_err("failed to query google account")?;
376
377 match google_account {
378 Some(google_account) => {
379 let user = diesel::update(users::dsl::users)
381 .filter(users::dsl::id.eq(google_account.user_id))
382 .set(users::dsl::avatar_url.eq(google_token.id_token.picture))
383 .returning(User::as_select())
384 .get_result::<User>(conn)
385 .await
386 .into_tonic_internal_err("failed to update user")?;
387
388 self.extensions_mut().insert(state);
389
390 Ok(user)
391 }
392 None => {
393 let registered_user = if google_token.id_token.email_verified {
397 common::get_user_by_email(conn, &google_token.id_token.email).await?
398 } else {
399 None
400 };
401
402 let user = match registered_user {
403 Some(user) => user, None => {
405 let user = User {
407 id: UserId::new(),
408 preferred_name: google_token.id_token.name,
409 first_name: google_token.id_token.given_name,
410 last_name: google_token.id_token.family_name,
411 password_hash: None,
412 primary_email: google_token
413 .id_token
414 .email_verified
415 .then(|| normalize_email(&google_token.id_token.email)),
416 avatar_url: google_token.id_token.picture,
417 };
418
419 common::create_user(conn, &user).await?;
420
421 user
422 }
423 };
424
425 let google_account = UserGoogleAccount {
426 sub: google_token.id_token.sub,
427 access_token: google_token.access_token,
428 access_token_expires_at: chrono::Utc::now() + chrono::Duration::seconds(google_token.expires_in as i64),
429 user_id: user.id,
430 created_at: chrono::Utc::now(),
431 };
432
433 diesel::insert_into(user_google_accounts::dsl::user_google_accounts)
434 .values(google_account)
435 .execute(conn)
436 .await
437 .into_tonic_internal_err("failed to insert user google account")?;
438
439 if let Some(hd) = google_token.id_token.hd {
440 let organization = organizations::dsl::organizations
442 .filter(organizations::dsl::google_hosted_domain.eq(hd))
443 .first::<Organization>(conn)
444 .await
445 .optional()
446 .into_tonic_internal_err("failed to query organization")?;
447
448 if let Some(org) = organization {
449 let membership = OrganizationMember {
451 organization_id: org.id,
452 user_id: user.id,
453 invited_by_id: None,
454 inline_policy: None,
455 created_at: chrono::Utc::now(),
456 };
457
458 diesel::insert_into(organization_members::dsl::organization_members)
459 .values(membership)
460 .execute(conn)
461 .await
462 .into_tonic_internal_err("failed to insert organization membership")?;
463
464 state.google_workspace = Some(
465 pb::scufflecloud::core::v1::complete_login_with_google_response::GoogleWorkspace::Joined(
466 org.into(),
467 ),
468 );
469 }
470 }
471
472 state.first_login = true;
473 self.extensions_mut().insert(state);
474
475 Ok(user)
476 }
477 }
478 }
479
480 async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
481 Ok(CoreApplication)
482 }
483
484 async fn execute(
485 mut self,
486 driver: &mut OperationDriver<'_, G>,
487 principal: Self::Principal,
488 _resource: Self::Resource,
489 ) -> Result<Self::Response, tonic::Status> {
490 let global = &self.global::<G>()?;
491 let ip_info = self.ip_address_info()?;
492 let dashboard_origin = self.dashboard_origin::<G>()?;
493
494 let state = self
495 .extensions_mut()
496 .remove::<CompleteLoginWithGoogleState>()
497 .into_tonic_internal_err("missing CompleteLoginWithGoogleState state")?;
498
499 let device = self.into_inner().device.require("device")?;
500
501 let conn = driver.conn().await?;
502
503 let token = common::create_session(global, conn, &dashboard_origin, &principal, device, &ip_info, false).await?;
505
506 Ok(pb::scufflecloud::core::v1::CompleteLoginWithGoogleResponse {
507 new_user_session_token: Some(token),
508 first_login: state.first_login,
509 google_workspace: state.google_workspace,
510 })
511 }
512}
513
514impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::LoginWithWebauthnRequest> {
515 type Principal = User;
516 type Resource = CoreApplication;
517 type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
518
519 const ACTION: Action = Action::LoginWithWebauthn;
520
521 async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
522 let global = &self.global::<G>()?;
523 let user_id: UserId = self
524 .get_ref()
525 .user_id
526 .parse()
527 .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
528
529 common::get_user_by_id(global, user_id).await
530 }
531
532 async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
533 Ok(CoreApplication)
534 }
535
536 async fn execute(
537 self,
538 driver: &mut OperationDriver<'_, G>,
539 principal: Self::Principal,
540 _resource: Self::Resource,
541 ) -> Result<Self::Response, tonic::Status> {
542 let global = &self.global::<G>()?;
543 let ip_info = self.ip_address_info()?;
544 let dashboard_origin = self.dashboard_origin::<G>()?;
545 let payload = self.into_inner();
546
547 let pk_cred: webauthn_rs::prelude::PublicKeyCredential = serde_json::from_str(&payload.response_json)
548 .into_tonic_err_with_field_violation("response_json", "invalid public key credential")?;
549 let device = payload.device.require("device")?;
550
551 let conn = driver.conn().await?;
552
553 common::finish_webauthn_authentication(global, conn, principal.id, &pk_cred).await?;
554
555 let new_token = common::create_session(global, conn, &dashboard_origin, &principal, device, &ip_info, false).await?;
557 Ok(new_token)
558 }
559}