Skip to main content

cloudillo_profile/
register.rs

1//! Registration and email verification handlers
2
3use 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/// Domain validation response (public for reuse in community profile creation)
25#[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, // '' if no error, else 'invalid', 'used', 'nodns', 'address'
33	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/// IDP availability check response
42#[derive(Debug, Clone, serde::Deserialize)]
43pub struct IdpAvailabilityResponse {
44	pub available: bool,
45	pub id_tag: String,
46}
47
48/// Get list of trusted identity providers from settings
49pub 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			// Parse comma-separated list and filter out empty strings
53			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			// Setting not found or error, return empty list
64			Vec::new()
65		}
66	}
67}
68
69/// Verify domain and id_tag before registration
70pub 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	// Determine address type from local addresses (all same type, guaranteed by startup validation)
78	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, // Should not happen due to startup validation
84		}
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	// Validate format
98	match typ {
99		"domain" => {
100			// Regex for domain: alphanumeric and hyphens, with at least one dot
101			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			// DNS validation - use recursive resolver from root nameservers
119			let Ok(resolver) = create_recursive_resolver() else {
120				// If we can't create resolver, return nodns error
121				response.id_tag_error = "nodns".to_string();
122				return Ok(response);
123			};
124
125			// Check if id_tag already registered
126			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			// Check if app_domain certificate already exists
133			if let Some(_app_domain) = app_domain {
134				// Note: This would need a method to check cert by domain in auth adapter
135				// For now, we'll skip this check
136			}
137
138			// DNS lookups for API domain (cl-o.<id_tag>)
139			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					// Still show what was resolved so user can debug
147					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			// DNS lookups for app domain
157			// Use provided app_domain or default to id_tag if not provided
158			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					// Still show what was resolved so user can debug
172					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			// Regex for idp: alphanumeric, hyphens, and dots, but must end with .cloudillo.net or similar
183			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			// Check if id_tag already registered locally
192			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			// Extract the IDP domain from id_tag
202			// Format: "alice.cloudillo.net" -> domain is "cloudillo.net"
203			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				// Make network request to IDP server to check availability
212				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
241/// POST /api/profiles/verify - Validate domain/id_tag before profile creation
242/// Requires either authentication OR a valid registration token
243pub 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	// Require either authentication OR valid token
249	let is_authenticated = auth.is_some();
250
251	if !is_authenticated {
252		// Token required for unauthenticated requests
253		let token = req.token.as_ref().ok_or_else(|| {
254			Error::ValidationError("Token required for unauthenticated requests".into())
255		})?;
256		// Validate the ref without consuming it
257		app.meta_adapter.validate_ref(token, &["register"]).await?;
258	}
259
260	let id_tag_lower = req.id_tag.to_lowercase();
261
262	// Get identity providers list (use TnId(1) for base tenant settings)
263	let providers = get_identity_providers(&app, TnId(1)).await;
264
265	// For "ref" type, just return identity providers
266	if req.typ == "ref" {
267		// Determine address type from local addresses
268		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	// Validate domain/local and get validation errors
292	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
299/// Handle IDP registration flow
300async 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	// Extract the IDP domain from id_tag (e.g., "alice.cloudillo.net" -> "cloudillo.net")
312	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	// Get the BASE_ID_TAG (this host's identifier)
320	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); // 30 days
327															// Include all local addresses from the app configuration (comma-separated)
328	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, // Default to registrar
338		address,
339		lang: lang.clone(), // Pass language preference to IDP
340	};
341
342	// Create action to generate JWT token
343	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(&reg_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	// Generate action JWT token
359	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	// POST to IDP provider's /inbox/sync endpoint
364	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	// Parse the IDP response
387	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	// Check if registration was successful
397	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	// IMPORTANT: Create tenant first to get the tn_id, then create the welcome ref
413	// We need to do this in two steps because create_ref_internal needs the tn_id
414
415	// Derive display name from id_tag (capitalize first letter of prefix)
416	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	// Create tenant via extension function
429	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	// Save language preference if provided
452	if let Some(ref lang_code) = lang {
453		// Use empty roles - PermissionLevel::User always allows any authenticated user
454		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	// Now create the welcome reference using the new create_ref_internal function
470	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)), // 30 days
478			path_prefix: "/onboarding/welcome",
479			resource_id: None,
480			count: None,
481		},
482	)
483	.await?;
484
485	// Profile is already created by create_tenant in meta adapter
486	// Send welcome email with the welcome link
487	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, // Subject is defined in the template frontmatter
501			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	// Store IDP API key if provided
529	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			// Continue anyway - this is not critical for basic functionality
541		}
542	}
543
544	// Return empty response
545	let response = json!({});
546	Ok((StatusCode::CREATED, Json(response)))
547}
548
549/// Handle domain registration flow
550async 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	// Validate domain again before creating account
559	let validation_result =
560		verify_register_data(app, "domain", &id_tag_lower, app_domain.as_deref(), providers)
561			.await?;
562
563	// Check for validation errors
564	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	// Derive display name from id_tag (capitalize first letter of prefix)
570	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	// Create tenant via extension function
583	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	// Save language preference if provided
606	if let Some(ref lang_code) = lang {
607		// Use empty roles - PermissionLevel::User always allows any authenticated user
608		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	// Create welcome reference using the new create_ref_internal function
624	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)), // 30 days
632			path_prefix: "/onboarding/welcome",
633			resource_id: None,
634			count: None,
635		},
636	)
637	.await?;
638
639	// Profile is already created by create_tenant in meta adapter
640	// Send welcome email with the welcome link
641	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, // Subject is defined in the template frontmatter
661			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	// Return empty response (user must login separately)
689	let response = json!({});
690	Ok((StatusCode::CREATED, Json(response)))
691}
692
693/// POST /api/profiles/register - Create profile after validation
694/// Requires a valid registration token (invitation ref)
695pub 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	// Validate request fields
700	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	// Validate the registration token (ref) before processing
705	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	// Get identity providers list (use TnId(1) as default for global settings)
711	let providers = get_identity_providers(&app, TnId(1)).await;
712
713	// Route to appropriate registration handler
714	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 registration succeeded, consume the token
722	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			// Continue anyway - registration already succeeded
729		}
730	}
731
732	result
733}
734
735// vim: ts=4