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