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