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