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