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_types::action_types::CreateAction;
20use cloudillo_types::address::parse_address_type;
21use cloudillo_types::types::{ApiResponse, RegisterRequest, RegisterVerifyCheckRequest};
22
23/// Domain validation response (public for reuse in community profile creation)
24#[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, // '' if no error, else 'invalid', 'used', 'nodns', 'address'
32	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/// IDP availability check response
41#[derive(Debug, Clone, serde::Deserialize)]
42pub struct IdpAvailabilityResponse {
43	pub available: bool,
44	pub id_tag: String,
45}
46
47/// Get list of trusted identity providers from settings
48pub 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			// Parse comma-separated list and filter out empty strings
52			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			// Setting not found or error, return empty list
63			Vec::new()
64		}
65	}
66}
67
68/// Verify domain and id_tag before registration
69pub 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	// Determine address type from local addresses (all same type, guaranteed by startup validation)
77	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, // Should not happen due to startup validation
83		}
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	// Validate format
97	match typ {
98		"domain" => {
99			// Regex for domain: alphanumeric and hyphens, with at least one dot
100			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			// DNS validation - use recursive resolver from root nameservers
118			let resolver = match create_recursive_resolver() {
119				Ok(r) => r,
120				Err(_) => {
121					// If we can't create resolver, return nodns error
122					response.id_tag_error = "nodns".to_string();
123					return Ok(response);
124				}
125			};
126
127			// Check if id_tag already registered
128			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			// Check if app_domain certificate already exists
135			if let Some(_app_domain) = app_domain {
136				// Note: This would need a method to check cert by domain in auth adapter
137				// For now, we'll skip this check
138			}
139
140			// DNS lookups for API domain (cl-o.<id_tag>)
141			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					// Still show what was resolved so user can debug
149					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			// DNS lookups for app domain
159			// Use provided app_domain or default to id_tag if not provided
160			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					// Still show what was resolved so user can debug
174					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			// Regex for idp: alphanumeric, hyphens, and dots, but must end with .cloudillo.net or similar
185			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			// Check if id_tag already registered locally
194			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			// Extract the IDP domain from id_tag
204			// Format: "alice.cloudillo.net" -> domain is "cloudillo.net"
205			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				// Make network request to IDP server to check availability
214				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
243/// POST /api/profiles/verify - Validate domain/id_tag before profile creation
244/// Requires either authentication OR a valid registration token
245pub 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	// Require either authentication OR valid token
251	let is_authenticated = auth.is_some();
252
253	if !is_authenticated {
254		// Token required for unauthenticated requests
255		let token = req.token.as_ref().ok_or_else(|| {
256			Error::ValidationError("Token required for unauthenticated requests".into())
257		})?;
258		// Validate the ref without consuming it
259		app.meta_adapter.validate_ref(token, &["register"]).await?;
260	}
261
262	let id_tag_lower = req.id_tag.to_lowercase();
263
264	// Get identity providers list (use TnId(1) for base tenant settings)
265	let providers = get_identity_providers(&app, TnId(1)).await;
266
267	// For "ref" type, just return identity providers
268	if req.typ == "ref" {
269		// Determine address type from local addresses
270		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	// Validate domain/local and get validation errors
294	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
301/// Handle IDP registration flow
302async 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	// Extract the IDP domain from id_tag (e.g., "alice.cloudillo.net" -> "cloudillo.net")
309	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	// Get the BASE_ID_TAG (this host's identifier)
317	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	// Create IDP:REG action
324	use cloudillo_idp::registration::IdpRegContent;
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(|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, // 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	};
356
357	// Generate action JWT token
358	let action_token = app.auth_adapter.create_action_token(TnId(1), action).await?;
359
360	// Prepare inbox request with token
361	#[derive(serde::Serialize)]
362	struct InboxRequest {
363		token: String,
364	}
365
366	let inbox_request = InboxRequest { token: action_token.to_string() };
367
368	// POST to IDP provider's /inbox/sync endpoint
369	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	// Parse the IDP response
392	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	// Check if registration was successful
403	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	// IMPORTANT: Create tenant first to get the tn_id, then create the welcome ref
419	// We need to do this in two steps because create_ref_internal needs the tn_id
420
421	// Derive display name from id_tag (capitalize first letter of prefix)
422	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	// Create tenant via extension function
435	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	// Save language preference if provided
458	if let Some(ref lang_code) = lang {
459		// Use empty roles - PermissionLevel::User always allows any authenticated user
460		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	// Now create the welcome reference using the new create_ref_internal function
476	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)), // 30 days
484			path_prefix: "/onboarding/welcome",
485			resource_id: None,
486			count: None,
487		},
488	)
489	.await?;
490
491	// Profile is already created by create_tenant in meta adapter
492	// Send welcome email with the welcome link
493	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, // Subject is defined in the template frontmatter
507			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	// Store IDP API key if provided
535	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			// Continue anyway - this is not critical for basic functionality
547		}
548	}
549
550	// Return empty response
551	let response = json!({});
552	Ok((StatusCode::CREATED, Json(response)))
553}
554
555/// Handle domain registration flow
556async 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	// Validate domain again before creating account
565	let validation_result =
566		verify_register_data(app, "domain", &id_tag_lower, app_domain.as_deref(), providers)
567			.await?;
568
569	// Check for validation errors
570	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	// Derive display name from id_tag (capitalize first letter of prefix)
576	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	// Create tenant via extension function
589	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	// Save language preference if provided
612	if let Some(ref lang_code) = lang {
613		// Use empty roles - PermissionLevel::User always allows any authenticated user
614		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	// Create welcome reference using the new create_ref_internal function
630	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)), // 30 days
638			path_prefix: "/onboarding/welcome",
639			resource_id: None,
640			count: None,
641		},
642	)
643	.await?;
644
645	// Profile is already created by create_tenant in meta adapter
646	// Send welcome email with the welcome link
647	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, // Subject is defined in the template frontmatter
667			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	// Return empty response (user must login separately)
695	let response = json!({});
696	Ok((StatusCode::CREATED, Json(response)))
697}
698
699/// POST /api/profiles/register - Create profile after validation
700/// Requires a valid registration token (invitation ref)
701pub 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	// Validate request fields
706	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	// Validate the registration token (ref) before processing
711	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	// Get identity providers list (use TnId(1) as default for global settings)
717	let providers = get_identity_providers(&app, TnId(1)).await;
718
719	// Route to appropriate registration handler
720	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 registration succeeded, consume the token
728	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			// Continue anyway - registration already succeeded
735		}
736	}
737
738	result
739}
740
741// vim: ts=4