1use axum::{
4 extract::{Json, State},
5 http::StatusCode,
6};
7use regex::Regex;
8use serde_json::json;
9use serde_with::skip_serializing_none;
10
11use crate::prelude::*;
12use cloudillo_core::settings::SettingValue;
13use cloudillo_core::{
14 bootstrap_types::CreateCompleteTenantOptions,
15 dns::{create_recursive_resolver, resolve_domain_addresses, validate_domain_address},
16 extract::OptionalAuth,
17 CreateCompleteTenantFn,
18};
19use cloudillo_types::action_types::CreateAction;
20use cloudillo_types::address::parse_address_type;
21use cloudillo_types::types::{ApiResponse, RegisterRequest, RegisterVerifyCheckRequest};
22
23#[skip_serializing_none]
25#[derive(Debug, serde::Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct DomainValidationResponse {
28 pub address: Vec<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub address_type: Option<String>,
31 pub id_tag_error: String, pub app_domain_error: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub api_address: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub app_address: Option<String>,
37 pub identity_providers: Vec<String>,
38}
39
40#[derive(Debug, Clone, serde::Deserialize)]
42pub struct IdpAvailabilityResponse {
43 pub available: bool,
44 pub id_tag: String,
45}
46
47pub async fn get_identity_providers(app: &cloudillo_core::app::App, tn_id: TnId) -> Vec<String> {
49 match app.settings.get(tn_id, "idp.list").await {
50 Ok(SettingValue::String(list)) => {
51 list.split(',')
53 .map(|s| s.trim().to_string())
54 .filter(|s| !s.is_empty())
55 .collect::<Vec<String>>()
56 }
57 Ok(_) => {
58 warn!("Invalid idp.list setting value (expected string)");
59 Vec::new()
60 }
61 Err(_) => {
62 Vec::new()
64 }
65 }
66}
67
68pub async fn verify_register_data(
70 app: &cloudillo_core::app::App,
71 typ: &str,
72 id_tag: &str,
73 app_domain: Option<&str>,
74 identity_providers: Vec<String>,
75) -> ClResult<DomainValidationResponse> {
76 let address_type = if app.opts.local_address.is_empty() {
78 None
79 } else {
80 match parse_address_type(app.opts.local_address[0].as_ref()) {
81 Ok(addr_type) => Some(addr_type.to_string()),
82 Err(_) => None, }
84 };
85
86 let mut response = DomainValidationResponse {
87 address: app.opts.local_address.iter().map(|s| s.to_string()).collect(),
88 address_type,
89 id_tag_error: String::new(),
90 app_domain_error: String::new(),
91 api_address: None,
92 app_address: None,
93 identity_providers,
94 };
95
96 match typ {
98 "domain" => {
99 let domain_regex = Regex::new(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$")
101 .map_err(|e| Error::Internal(format!("domain regex compilation failed: {}", e)))?;
102
103 if !domain_regex.is_match(id_tag) {
104 response.id_tag_error = "invalid".to_string();
105 }
106
107 if let Some(app_domain) = app_domain {
108 if app_domain.starts_with("cl-o.") || !domain_regex.is_match(app_domain) {
109 response.app_domain_error = "invalid".to_string();
110 }
111 }
112
113 if !response.id_tag_error.is_empty() || !response.app_domain_error.is_empty() {
114 return Ok(response);
115 }
116
117 let resolver = match create_recursive_resolver() {
119 Ok(r) => r,
120 Err(_) => {
121 response.id_tag_error = "nodns".to_string();
123 return Ok(response);
124 }
125 };
126
127 match app.auth_adapter.read_tn_id(id_tag).await {
129 Ok(_) => response.id_tag_error = "used".to_string(),
130 Err(Error::NotFound) => {}
131 Err(e) => return Err(e),
132 }
133
134 if let Some(_app_domain) = app_domain {
136 }
139
140 let api_domain = format!("cl-o.{}", id_tag);
142 match validate_domain_address(&api_domain, &app.opts.local_address, &resolver).await {
143 Ok((address, _addr_type)) => {
144 response.api_address = Some(address);
145 }
146 Err(Error::ValidationError(err_code)) => {
147 response.id_tag_error = err_code;
148 if let Ok(Some(address)) =
150 resolve_domain_addresses(&api_domain, &resolver).await
151 {
152 response.api_address = Some(address);
153 }
154 }
155 Err(e) => return Err(e),
156 }
157
158 let app_domain_to_validate = app_domain.unwrap_or(id_tag);
161 match validate_domain_address(
162 app_domain_to_validate,
163 &app.opts.local_address,
164 &resolver,
165 )
166 .await
167 {
168 Ok((address, _addr_type)) => {
169 response.app_address = Some(address);
170 }
171 Err(Error::ValidationError(err_code)) => {
172 response.app_domain_error = err_code;
173 if let Ok(Some(address)) =
175 resolve_domain_addresses(app_domain_to_validate, &resolver).await
176 {
177 response.app_address = Some(address);
178 }
179 }
180 Err(e) => return Err(e),
181 }
182 }
183 "idp" => {
184 let idp_regex = Regex::new(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$")
186 .map_err(|e| Error::Internal(format!("idp regex compilation failed: {}", e)))?;
187
188 if !idp_regex.is_match(id_tag) {
189 response.id_tag_error = "invalid".to_string();
190 return Ok(response);
191 }
192
193 match app.auth_adapter.read_tn_id(id_tag).await {
195 Ok(_) => {
196 response.id_tag_error = "used".to_string();
197 return Ok(response);
198 }
199 Err(Error::NotFound) => {}
200 Err(e) => return Err(e),
201 }
202
203 if let Some(first_dot_pos) = id_tag.find('.') {
206 let idp_domain = &id_tag[first_dot_pos + 1..];
207
208 if idp_domain.is_empty() {
209 response.id_tag_error = "invalid".to_string();
210 return Ok(response);
211 }
212
213 let check_path = format!("/idp/check-availability?idTag={}", id_tag);
215
216 match app
217 .request
218 .get_public::<ApiResponse<IdpAvailabilityResponse>>(idp_domain, &check_path)
219 .await
220 {
221 Ok(idp_response) => {
222 if !idp_response.data.available {
223 response.id_tag_error = "used".to_string();
224 }
225 }
226 Err(e) => {
227 warn!("Failed to check IDP availability for {}: {}", id_tag, e);
228 response.id_tag_error = "nodns".to_string();
229 }
230 }
231 } else {
232 response.id_tag_error = "invalid".to_string();
233 }
234 }
235 _ => {
236 return Err(Error::ValidationError("invalid registration type".into()));
237 }
238 }
239
240 Ok(response)
241}
242
243pub async fn post_verify_profile(
246 State(app): State<cloudillo_core::app::App>,
247 OptionalAuth(auth): OptionalAuth,
248 Json(req): Json<RegisterVerifyCheckRequest>,
249) -> ClResult<(StatusCode, Json<DomainValidationResponse>)> {
250 let is_authenticated = auth.is_some();
252
253 if !is_authenticated {
254 let token = req.token.as_ref().ok_or_else(|| {
256 Error::ValidationError("Token required for unauthenticated requests".into())
257 })?;
258 app.meta_adapter.validate_ref(token, &["register"]).await?;
260 }
261
262 let id_tag_lower = req.id_tag.to_lowercase();
263
264 let providers = get_identity_providers(&app, TnId(1)).await;
266
267 if req.typ == "ref" {
269 let address_type = if app.opts.local_address.is_empty() {
271 None
272 } else {
273 match parse_address_type(app.opts.local_address[0].as_ref()) {
274 Ok(addr_type) => Some(addr_type.to_string()),
275 Err(_) => None,
276 }
277 };
278
279 return Ok((
280 StatusCode::OK,
281 Json(DomainValidationResponse {
282 address: app.opts.local_address.iter().map(|s| s.to_string()).collect(),
283 address_type,
284 id_tag_error: String::new(),
285 app_domain_error: String::new(),
286 api_address: None,
287 app_address: None,
288 identity_providers: providers,
289 }),
290 ));
291 }
292
293 let validation_result =
295 verify_register_data(&app, &req.typ, &id_tag_lower, req.app_domain.as_deref(), providers)
296 .await?;
297
298 Ok((StatusCode::OK, Json(validation_result)))
299}
300
301async fn handle_idp_registration(
303 app: &cloudillo_core::app::App,
304 id_tag_lower: String,
305 email: String,
306 lang: Option<String>,
307) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
308 let idp_domain = match id_tag_lower.find('.') {
310 Some(pos) => &id_tag_lower[pos + 1..],
311 None => {
312 return Err(Error::ValidationError("Invalid IDP id_tag format".to_string()));
313 }
314 };
315
316 let base_id_tag = app
318 .opts
319 .base_id_tag
320 .as_ref()
321 .ok_or_else(|| Error::ConfigError("BASE_ID_TAG not configured".into()))?;
322
323 use cloudillo_idp::registration::IdpRegContent;
325
326 let expires_at = Timestamp::now().add_seconds(86400 * 30); let address = if app.opts.local_address.is_empty() {
329 None
330 } else {
331 Some(app.opts.local_address.iter().map(|s| s.as_ref()).collect::<Vec<_>>().join(","))
332 };
333 let reg_content = IdpRegContent {
334 id_tag: id_tag_lower.clone(),
335 email: Some(email.clone()),
336 owner_id_tag: None,
337 issuer: None, address,
339 lang: lang.clone(), };
341
342 let action = CreateAction {
344 typ: "IDP:REG".into(),
345 sub_typ: None,
346 parent_id: None,
347 audience_tag: Some(idp_domain.to_string().into()),
348 content: Some(serde_json::to_value(®_content)?),
349 attachments: None,
350 subject: None,
351 expires_at: Some(expires_at),
352 visibility: None,
353 flags: None,
354 x: None,
355 };
356
357 let action_token = app.auth_adapter.create_action_token(TnId(1), action).await?;
359
360 #[derive(serde::Serialize)]
362 struct InboxRequest {
363 token: String,
364 }
365
366 let inbox_request = InboxRequest { token: action_token.to_string() };
367
368 info!(
370 id_tag = %id_tag_lower,
371 idp_domain = %idp_domain,
372 base_id_tag = %base_id_tag,
373 "Posting IDP:REG action token to identity provider"
374 );
375
376 let idp_response: cloudillo_types::types::ApiResponse<serde_json::Value> = app
377 .request
378 .post_public(idp_domain, "/inbox/sync", &inbox_request)
379 .await
380 .map_err(|e| {
381 warn!(
382 error = %e,
383 idp_domain = %idp_domain,
384 "Failed to register with identity provider"
385 );
386 Error::ValidationError(
387 "Identity provider registration failed - please try again later".to_string(),
388 )
389 })?;
390
391 use cloudillo_idp::registration::IdpRegResponse;
393 let idp_reg_result: IdpRegResponse =
394 serde_json::from_value(idp_response.data).map_err(|e| {
395 warn!(
396 error = %e,
397 "Failed to parse IDP registration response"
398 );
399 Error::Internal(format!("IDP response parsing failed: {}", e))
400 })?;
401
402 if !idp_reg_result.success {
404 warn!(
405 id_tag = %id_tag_lower,
406 message = %idp_reg_result.message,
407 "IDP registration failed"
408 );
409 return Err(Error::ValidationError(idp_reg_result.message));
410 }
411
412 info!(
413 id_tag = %id_tag_lower,
414 activation_ref = ?idp_reg_result.activation_ref,
415 "IDP registration successful, creating local tenant"
416 );
417
418 let display_name = if id_tag_lower.contains('.') {
423 let parts: Vec<&str> = id_tag_lower.split('.').collect();
424 if !parts.is_empty() {
425 let name = parts[0];
426 format!("{}{}", name.chars().next().unwrap_or('U').to_uppercase(), &name[1..])
427 } else {
428 id_tag_lower.clone()
429 }
430 } else {
431 id_tag_lower.clone()
432 };
433
434 let create_tenant = app.ext::<CreateCompleteTenantFn>()?;
436 let tn_id = create_tenant(
437 app,
438 CreateCompleteTenantOptions {
439 id_tag: &id_tag_lower,
440 email: Some(&email),
441 password: None,
442 roles: None,
443 display_name: Some(&display_name),
444 create_acme_cert: app.opts.acme_email.is_some(),
445 acme_email: app.opts.acme_email.as_deref(),
446 app_domain: None,
447 },
448 )
449 .await?;
450
451 info!(
452 id_tag = %id_tag_lower,
453 tn_id = ?tn_id,
454 "Tenant created successfully for IDP registration"
455 );
456
457 if let Some(ref lang_code) = lang {
459 let empty_roles: &[&str] = &[];
461 if let Err(e) = app
462 .settings
463 .set(tn_id, "profile.lang", SettingValue::String(lang_code.clone()), empty_roles)
464 .await
465 {
466 warn!(
467 error = %e,
468 tn_id = ?tn_id,
469 lang = %lang_code,
470 "Failed to save language preference, continuing registration"
471 );
472 }
473 }
474
475 let (_ref_id, welcome_link) = cloudillo_ref::service::create_ref_internal(
477 app,
478 tn_id,
479 cloudillo_ref::service::CreateRefInternalParams {
480 id_tag: &id_tag_lower,
481 typ: "welcome",
482 description: Some("Welcome to Cloudillo"),
483 expires_at: Some(Timestamp::now().add_seconds(86400 * 30)), path_prefix: "/onboarding/welcome",
485 resource_id: None,
486 count: None,
487 },
488 )
489 .await?;
490
491 let template_vars = serde_json::json!({
494 "identity_tag": id_tag_lower,
495 "base_id_tag": base_id_tag.as_ref(),
496 "instance_name": "Cloudillo",
497 "welcome_link": welcome_link,
498 });
499
500 match cloudillo_email::EmailModule::schedule_email_task(
501 &app.scheduler,
502 &app.settings,
503 tn_id,
504 cloudillo_email::EmailTaskParams {
505 to: email.clone(),
506 subject: None, template_name: "welcome".to_string(),
508 template_vars,
509 lang: lang.clone(),
510 custom_key: None,
511 from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
512 },
513 )
514 .await
515 {
516 Ok(_) => {
517 info!(
518 email = %email,
519 id_tag = %id_tag_lower,
520 lang = ?lang,
521 "Welcome email queued for IDP registration"
522 );
523 }
524 Err(e) => {
525 warn!(
526 error = %e,
527 email = %email,
528 id_tag = %id_tag_lower,
529 "Failed to queue welcome email, continuing registration"
530 );
531 }
532 }
533
534 if let Some(api_key) = &idp_reg_result.api_key {
536 info!(
537 id_tag = %id_tag_lower,
538 "Storing IDP API key for federated identity"
539 );
540 if let Err(e) = app.auth_adapter.update_idp_api_key(&id_tag_lower, api_key).await {
541 warn!(
542 error = %e,
543 id_tag = %id_tag_lower,
544 "Failed to store IDP API key - continuing anyway"
545 );
546 }
548 }
549
550 let response = json!({});
552 Ok((StatusCode::CREATED, Json(response)))
553}
554
555async fn handle_domain_registration(
557 app: &cloudillo_core::app::App,
558 id_tag_lower: String,
559 app_domain: Option<String>,
560 email: String,
561 providers: Vec<String>,
562 lang: Option<String>,
563) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
564 let validation_result =
566 verify_register_data(app, "domain", &id_tag_lower, app_domain.as_deref(), providers)
567 .await?;
568
569 if !validation_result.id_tag_error.is_empty() || !validation_result.app_domain_error.is_empty()
571 {
572 return Err(Error::ValidationError("invalid id_tag or app_domain".into()));
573 }
574
575 let display_name = if id_tag_lower.contains('.') {
577 let parts: Vec<&str> = id_tag_lower.split('.').collect();
578 if !parts.is_empty() {
579 let name = parts[0];
580 format!("{}{}", name.chars().next().unwrap_or('U').to_uppercase(), &name[1..])
581 } else {
582 id_tag_lower.clone()
583 }
584 } else {
585 id_tag_lower.clone()
586 };
587
588 let create_tenant = app.ext::<CreateCompleteTenantFn>()?;
590 let tn_id = create_tenant(
591 app,
592 CreateCompleteTenantOptions {
593 id_tag: &id_tag_lower,
594 email: Some(&email),
595 password: None,
596 roles: None,
597 display_name: Some(&display_name),
598 create_acme_cert: app.opts.acme_email.is_some(),
599 acme_email: app.opts.acme_email.as_deref(),
600 app_domain: app_domain.as_deref(),
601 },
602 )
603 .await?;
604
605 info!(
606 id_tag = %id_tag_lower,
607 tn_id = ?tn_id,
608 "Tenant created successfully for domain registration"
609 );
610
611 if let Some(ref lang_code) = lang {
613 let empty_roles: &[&str] = &[];
615 if let Err(e) = app
616 .settings
617 .set(tn_id, "profile.lang", SettingValue::String(lang_code.clone()), empty_roles)
618 .await
619 {
620 warn!(
621 error = %e,
622 tn_id = ?tn_id,
623 lang = %lang_code,
624 "Failed to save language preference, continuing registration"
625 );
626 }
627 }
628
629 let (_ref_id, welcome_link) = cloudillo_ref::service::create_ref_internal(
631 app,
632 tn_id,
633 cloudillo_ref::service::CreateRefInternalParams {
634 id_tag: &id_tag_lower,
635 typ: "welcome",
636 description: Some("Welcome to Cloudillo"),
637 expires_at: Some(Timestamp::now().add_seconds(86400 * 30)), path_prefix: "/onboarding/welcome",
639 resource_id: None,
640 count: None,
641 },
642 )
643 .await?;
644
645 let base_id_tag = app
648 .opts
649 .base_id_tag
650 .as_ref()
651 .ok_or_else(|| Error::ConfigError("BASE_ID_TAG not configured".into()))?;
652
653 let template_vars = serde_json::json!({
654 "identity_tag": id_tag_lower,
655 "base_id_tag": base_id_tag.as_ref(),
656 "instance_name": "Cloudillo",
657 "welcome_link": welcome_link,
658 });
659
660 match cloudillo_email::EmailModule::schedule_email_task(
661 &app.scheduler,
662 &app.settings,
663 tn_id,
664 cloudillo_email::EmailTaskParams {
665 to: email.clone(),
666 subject: None, template_name: "welcome".to_string(),
668 template_vars,
669 lang: lang.clone(),
670 custom_key: None,
671 from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
672 },
673 )
674 .await
675 {
676 Ok(_) => {
677 info!(
678 email = %email,
679 id_tag = %id_tag_lower,
680 lang = ?lang,
681 "Welcome email queued for domain registration"
682 );
683 }
684 Err(e) => {
685 warn!(
686 error = %e,
687 email = %email,
688 id_tag = %id_tag_lower,
689 "Failed to queue welcome email, continuing registration"
690 );
691 }
692 }
693
694 let response = json!({});
696 Ok((StatusCode::CREATED, Json(response)))
697}
698
699pub async fn post_register(
702 State(app): State<cloudillo_core::app::App>,
703 Json(req): Json<RegisterRequest>,
704) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
705 if req.id_tag.is_empty() || req.token.is_empty() || req.email.is_empty() {
707 return Err(Error::ValidationError("id_tag, token, and email are required".into()));
708 }
709
710 app.meta_adapter.validate_ref(&req.token, &["register"]).await?;
712
713 let id_tag_lower = req.id_tag.to_lowercase();
714 let app_domain = req.app_domain.map(|d| d.to_lowercase());
715
716 let providers = get_identity_providers(&app, TnId(1)).await;
718
719 let result = if req.typ == "idp" {
721 handle_idp_registration(&app, id_tag_lower, req.email, req.lang).await
722 } else {
723 handle_domain_registration(&app, id_tag_lower, app_domain, req.email, providers, req.lang)
724 .await
725 };
726
727 if result.is_ok() {
729 if let Err(e) = app.meta_adapter.use_ref(&req.token, &["register"]).await {
730 warn!(
731 error = %e,
732 "Failed to consume registration token after successful registration"
733 );
734 }
736 }
737
738 result
739}
740
741