Skip to main content

cloudillo_auth/
handler.rs

1use axum::{
2	extract::{ConnectInfo, Query, State},
3	http::{HeaderMap, StatusCode},
4	Json,
5};
6use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL, Engine};
7use rand::RngExt;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use serde_with::skip_serializing_none;
11use std::net::SocketAddr;
12
13use cloudillo_core::{
14	extract::{IdTag, OptionalAuth, OptionalRequestId},
15	rate_limit::{PenaltyReason, RateLimitApi},
16	roles::expand_roles,
17	ActionVerifyFn, Auth,
18};
19use cloudillo_email::{get_tenant_lang, EmailModule, EmailTaskParams};
20use cloudillo_ref::service::{create_ref_internal, CreateRefInternalParams};
21use cloudillo_types::{
22	action_types::ACCESS_TOKEN_EXPIRY,
23	auth_adapter::{self, ListTenantsOptions},
24	meta_adapter::ListRefsOptions,
25	types::ApiResponse,
26	utils::decode_jwt_no_verify,
27};
28
29use crate::prelude::*;
30
31/// Service worker encryption key variable name
32const SW_ENCRYPTION_KEY_VAR: &str = "sw_encryption_key";
33
34/// Generate a new 256-bit encryption key for SW token protection
35/// Uses URL-safe base64 encoding (no padding) for safe inclusion in URLs
36fn generate_sw_encryption_key() -> String {
37	let key: [u8; 32] = rand::rng().random();
38	BASE64_URL.encode(key)
39}
40
41/// # Login
42#[skip_serializing_none]
43#[derive(Clone, Serialize)]
44pub struct Login {
45	// auth data
46	#[serde(rename = "tnId")]
47	tn_id: TnId,
48	#[serde(rename = "idTag")]
49	id_tag: String,
50	roles: Option<Vec<String>>,
51	token: String,
52	// profile data
53	name: String,
54	#[serde(rename = "profilePic")]
55	profile_pic: String,
56	settings: Vec<(String, String)>,
57	// SW encryption key for secure token storage
58	#[serde(rename = "swEncryptionKey")]
59	sw_encryption_key: Option<String>,
60}
61
62#[derive(Serialize)]
63pub struct IdTagRes {
64	#[serde(rename = "idTag")]
65	id_tag: String,
66}
67
68pub async fn get_id_tag(
69	State(app): State<App>,
70	OptionalRequestId(_req_id): OptionalRequestId,
71	req: axum::http::Request<axum::body::Body>,
72) -> ClResult<(StatusCode, Json<IdTagRes>)> {
73	let host = req
74		.uri()
75		.host()
76		.or_else(|| req.headers().get(axum::http::header::HOST).and_then(|h| h.to_str().ok()))
77		.unwrap_or_default();
78	let cert_data = app.auth_adapter.read_cert_by_domain(host).await?;
79
80	Ok((StatusCode::OK, Json(IdTagRes { id_tag: cert_data.id_tag.to_string() })))
81}
82
83pub async fn return_login(
84	app: &App,
85	auth: auth_adapter::AuthLogin,
86) -> ClResult<(StatusCode, Json<Login>)> {
87	// Fetch tenant data for name and profile_pic
88	// Use read_tenant since the user is logging into their own tenant
89	let tenant = app.meta_adapter.read_tenant(auth.tn_id).await.ok();
90
91	let (name, profile_pic) = match tenant {
92		Some(t) => (t.name.to_string(), t.profile_pic.map(|p| p.to_string())),
93		None => (auth.id_tag.to_string(), None),
94	};
95
96	// Get or create SW encryption key for this tenant
97	let sw_encryption_key = match app.auth_adapter.read_var(auth.tn_id, SW_ENCRYPTION_KEY_VAR).await
98	{
99		Ok(key) => Some(key.to_string()),
100		Err(Error::NotFound) => {
101			// Generate new key
102			let key = generate_sw_encryption_key();
103			if let Err(e) =
104				app.auth_adapter.update_var(auth.tn_id, SW_ENCRYPTION_KEY_VAR, &key).await
105			{
106				warn!("Failed to store SW encryption key: {}", e);
107				None
108			} else {
109				info!("Generated new SW encryption key for tenant {}", auth.tn_id.0);
110				Some(key)
111			}
112		}
113		Err(e) => {
114			warn!("Failed to read SW encryption key: {}", e);
115			None
116		}
117	};
118
119	let login = Login {
120		tn_id: auth.tn_id,
121		id_tag: auth.id_tag.to_string(),
122		roles: auth.roles.map(|roles| roles.iter().map(ToString::to_string).collect()),
123		token: auth.token.to_string(),
124		name,
125		profile_pic: profile_pic.unwrap_or_default(),
126		settings: vec![],
127		sw_encryption_key,
128	};
129
130	Ok((StatusCode::OK, Json(login)))
131}
132
133/// # POST /api/auth/login
134#[derive(Deserialize)]
135pub struct LoginReq {
136	#[serde(rename = "idTag")]
137	id_tag: String,
138	password: String,
139}
140
141pub async fn post_login(
142	State(app): State<App>,
143	ConnectInfo(addr): ConnectInfo<SocketAddr>,
144	OptionalRequestId(req_id): OptionalRequestId,
145	Json(login): Json<LoginReq>,
146) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
147	let auth = app.auth_adapter.check_tenant_password(&login.id_tag, &login.password).await;
148
149	if let Ok(auth) = auth {
150		let (_status, Json(login_data)) = return_login(&app, auth).await?;
151		let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
152		Ok((StatusCode::OK, Json(response)))
153	} else {
154		// Penalize rate limit for failed login attempt
155		if let Err(e) = app.rate_limiter.penalize(&addr.ip(), PenaltyReason::AuthFailure, 1) {
156			warn!("Failed to record auth penalty for {}: {}", addr.ip(), e);
157		}
158		tokio::time::sleep(std::time::Duration::from_secs(1)).await;
159		Err(Error::PermissionDenied)
160	}
161}
162
163/// # GET /api/auth/login-token
164pub async fn get_login_token(
165	State(app): State<App>,
166	OptionalAuth(auth): OptionalAuth,
167	OptionalRequestId(req_id): OptionalRequestId,
168) -> ClResult<(StatusCode, Json<ApiResponse<Option<Login>>>)> {
169	if let Some(auth) = auth {
170		info!("login-token for {}", &auth.id_tag);
171		let auth = app.auth_adapter.create_tenant_login(&auth.id_tag).await;
172		if let Ok(auth) = auth {
173			info!("token: {}", &auth.token);
174			let (_status, Json(login_data)) = return_login(&app, auth).await?;
175			let response =
176				ApiResponse::new(Some(login_data)).with_req_id(req_id.unwrap_or_default());
177			Ok((StatusCode::OK, Json(response)))
178		} else {
179			tokio::time::sleep(std::time::Duration::from_secs(1)).await;
180			Err(Error::PermissionDenied)
181		}
182	} else {
183		// No authentication - return empty result
184		info!("login-token called without authentication");
185		let response = ApiResponse::new(None).with_req_id(req_id.unwrap_or_default());
186		Ok((StatusCode::OK, Json(response)))
187	}
188}
189
190/// Request body for logout endpoint
191#[derive(Deserialize, Default)]
192pub struct LogoutReq {
193	/// Optional API key to delete on logout (for "stay logged in" cleanup)
194	#[serde(rename = "apiKey")]
195	api_key: Option<String>,
196}
197
198/// POST /auth/logout - Invalidate current access token
199pub async fn post_logout(
200	State(app): State<App>,
201	Auth(auth): Auth,
202	OptionalRequestId(req_id): OptionalRequestId,
203	Json(req): Json<LogoutReq>,
204) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
205	// Note: Token invalidation could be implemented with a token blacklist table
206	// For now, tokens remain valid until expiration (short-lived access tokens)
207
208	// If API key provided, validate it belongs to this user and delete it
209	if let Some(ref api_key) = req.api_key {
210		match app.auth_adapter.validate_api_key(api_key).await {
211			Ok(validation) if validation.tn_id == auth.tn_id => {
212				if let Err(e) = app.auth_adapter.delete_api_key(auth.tn_id, validation.key_id).await
213				{
214					warn!("Failed to delete API key {} on logout: {:?}", validation.key_id, e);
215				} else {
216					info!(
217						"Deleted API key {} for user {} on logout",
218						validation.key_id, auth.id_tag
219					);
220				}
221			}
222			Ok(_) => {
223				warn!("API key provided at logout does not belong to user {}", auth.id_tag);
224			}
225			Err(e) => {
226				// Invalid/expired key, ignore silently (might already be deleted)
227				debug!("API key validation failed on logout: {:?}", e);
228			}
229		}
230	}
231
232	info!("User {} logged out", auth.id_tag);
233
234	let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
235
236	Ok((StatusCode::OK, Json(response)))
237}
238
239/// # POST /api/auth/password
240#[derive(Deserialize)]
241pub struct PasswordReq {
242	#[serde(rename = "currentPassword")]
243	current_password: String,
244	#[serde(rename = "newPassword")]
245	new_password: String,
246}
247
248pub async fn post_password(
249	State(app): State<App>,
250	ConnectInfo(addr): ConnectInfo<SocketAddr>,
251	Auth(auth): Auth,
252	OptionalRequestId(req_id): OptionalRequestId,
253	Json(req): Json<PasswordReq>,
254) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
255	// Validate new password strength
256	if req.new_password.trim().is_empty() {
257		return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
258	}
259
260	if req.new_password.len() < 8 {
261		return Err(Error::ValidationError("Password must be at least 8 characters".into()));
262	}
263
264	if req.new_password == req.current_password {
265		return Err(Error::ValidationError(
266			"New password must be different from current password".into(),
267		));
268	}
269
270	// Verify current password using authenticated user's id_tag
271	let verification = app
272		.auth_adapter
273		.check_tenant_password(&auth.id_tag, &req.current_password)
274		.await;
275
276	if verification.is_err() {
277		// Penalize rate limit for failed password verification
278		if let Err(e) = app.rate_limiter.penalize(&addr.ip(), PenaltyReason::AuthFailure, 1) {
279			warn!("Failed to record auth penalty for {}: {}", addr.ip(), e);
280		}
281		// Delay to prevent timing attacks
282		tokio::time::sleep(std::time::Duration::from_secs(1)).await;
283		warn!("Failed password verification for user {}", auth.id_tag);
284		return Err(Error::PermissionDenied);
285	}
286
287	// Update to new password
288	app.auth_adapter.update_tenant_password(&auth.id_tag, &req.new_password).await?;
289
290	info!("User {} successfully changed their password", auth.id_tag);
291
292	let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
293
294	Ok((StatusCode::OK, Json(response)))
295}
296
297/// # GET /api/auth/access-token
298/// Gets an access token for a subject.
299/// Can be called with either:
300/// 1. A token query parameter (action token to exchange)
301/// 2. A refId query parameter (share link to exchange for scoped token)
302/// 3. An apiKey query parameter (API key to exchange for access token)
303/// 4. A via parameter (cross-document link: get token for target file via source file)
304/// 5. Just subject parameter (uses authenticated session)
305#[derive(Deserialize)]
306pub struct GetAccessTokenQuery {
307	#[serde(default)]
308	token: Option<String>,
309	scope: Option<String>,
310	/// Share link ref_id to exchange for a scoped access token
311	#[serde(rename = "refId")]
312	ref_id: Option<String>,
313	/// API key to exchange for an access token
314	#[serde(rename = "apiKey")]
315	api_key: Option<String>,
316	/// If true with refId, use validate_ref instead of use_ref (for token refresh)
317	#[serde(default)]
318	refresh: Option<bool>,
319	/// Source file_id for cross-document link access (requires scope param with target file)
320	via: Option<String>,
321}
322
323pub async fn get_access_token(
324	State(app): State<App>,
325	tn_id: TnId,
326	id_tag: IdTag,
327	ConnectInfo(addr): ConnectInfo<SocketAddr>,
328	OptionalAuth(maybe_auth): OptionalAuth,
329	Query(query): Query<GetAccessTokenQuery>,
330	OptionalRequestId(req_id): OptionalRequestId,
331) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
332	use tracing::warn;
333
334	info!("Got access token request for id_tag={} with scope={:?}", id_tag.0, query.scope);
335
336	// Cross-document link: get scoped token for target file via source file
337	if let Some(ref via_file_id) = query.via {
338		use cloudillo_types::types::{AccessLevel, TokenScope};
339
340		// Requires scope param: "file:{target_file_id}:{R|W}"
341		let scope_str = query
342			.scope
343			.as_deref()
344			.ok_or_else(|| Error::ValidationError("scope parameter required with via".into()))?;
345
346		let token_scope = TokenScope::parse(scope_str)
347			.ok_or_else(|| Error::ValidationError("Invalid scope format".into()))?;
348
349		let TokenScope::File { file_id: ref target_file_id, access: requested_access } =
350			token_scope
351		else {
352			return Err(Error::ValidationError("scope must be a file scope".into()));
353		};
354
355		info!(
356			"Via token request: via={}, target={}, access={:?}",
357			via_file_id, target_file_id, requested_access
358		);
359
360		// Caller must be authenticated (either session or scoped token)
361		let auth = maybe_auth.as_ref().ok_or(Error::Unauthorized)?;
362
363		// Parse via reference: could be "id_tag:file_id" or just "file_id"
364		let via_bare_file_id =
365			via_file_id.split_once(':').map_or(via_file_id.as_str(), |(_, fid)| fid);
366
367		// Check caller has access to the via (source) file
368		let caller_has_via_access = if let Some(ref caller_scope) = auth.scope {
369			// Scoped token: must be scoped to the via file (bare id)
370			if let Some(TokenScope::File { file_id: ref scope_fid, access: scope_access }) =
371				TokenScope::parse(caller_scope)
372			{
373				scope_fid == via_bare_file_id && scope_access != AccessLevel::None
374			} else {
375				false
376			}
377		} else {
378			// Session-authenticated user: verify actual file access using bare file_id
379			use cloudillo_core::file_access::{self, FileAccessCtx};
380			let ctx = FileAccessCtx {
381				user_id_tag: &auth.id_tag,
382				tenant_id_tag: &id_tag.0,
383				user_roles: &auth.roles,
384			};
385			file_access::check_file_access_with_scope(
386				&app,
387				tn_id,
388				via_bare_file_id,
389				&ctx,
390				None,
391				None,
392			)
393			.await
394			.is_ok()
395		};
396
397		if !caller_has_via_access {
398			warn!("Via token denied: caller has no access to source file {}", via_file_id);
399			return Err(Error::PermissionDenied);
400		}
401
402		// Look up share entry: resource=target/embedded, subject=via/container
403		let link_perm = app
404			.meta_adapter
405			.check_share_access(tn_id, 'F', target_file_id, 'F', via_bare_file_id)
406			.await?
407			.ok_or_else(|| {
408				warn!(
409					"Via token denied: no file link from {} to {}",
410					via_bare_file_id, target_file_id
411				);
412				Error::PermissionDenied
413			})?;
414
415		// Determine effective access: min(requested, link_permission)
416		let effective_access = requested_access.min(AccessLevel::from_perm_char(link_perm));
417
418		// Create scoped token for the target file
419		let access_char = if effective_access == AccessLevel::Write { 'W' } else { 'R' };
420		let target_scope = format!("file:{}:{}", target_file_id, access_char);
421
422		let token_result = app
423			.auth_adapter
424			.create_access_token(
425				tn_id,
426				&auth_adapter::AccessToken {
427					iss: &id_tag.0,
428					sub: auth.scope.as_ref().map_or(Some(&*auth.id_tag), |_| None),
429					r: None,
430					scope: Some(&target_scope),
431					exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
432				},
433			)
434			.await?;
435
436		info!("Created via token for {} with scope {}", target_file_id, target_scope);
437		let response = ApiResponse::new(json!({
438			"token": token_result,
439			"scope": target_scope,
440			"resourceId": target_file_id,
441			"accessLevel": effective_access.as_str(),
442		}))
443		.with_req_id(req_id.unwrap_or_default());
444		return Ok((StatusCode::OK, Json(response)));
445	}
446
447	// If token is provided in query, verify it; otherwise use authenticated session
448	if let Some(token_param) = query.token {
449		info!("Verifying action token from query parameter");
450		let verify_fn = app.ext::<ActionVerifyFn>()?;
451		let auth_action = verify_fn(&app, tn_id, &token_param, Some(&addr.ip())).await?;
452		if *auth_action.aud.as_ref().ok_or(Error::PermissionDenied)?.as_ref() != *id_tag.0 {
453			warn!("Auth action issuer {} doesn't match id_tag {}", auth_action.iss, id_tag.0);
454			return Err(Error::PermissionDenied);
455		}
456		info!("Got auth action: {:?}", &auth_action);
457
458		info!(
459			"Creating access token with t={}, u={}, scope={:?}",
460			id_tag.0,
461			auth_action.iss,
462			query.scope.as_deref()
463		);
464
465		// Fetch profile roles from meta adapter and expand them
466		let profile_roles = match app.meta_adapter.read_profile_roles(tn_id, &auth_action.iss).await
467		{
468			Ok(roles) => {
469				info!(
470					"Found profile roles for {} in tn_id {:?}: {:?}",
471					auth_action.iss, tn_id, roles
472				);
473				roles
474			}
475			Err(e) => {
476				warn!(
477					"Failed to read profile roles for {} in tn_id {:?}: {}",
478					auth_action.iss, tn_id, e
479				);
480				None
481			}
482		};
483
484		let expanded_roles = profile_roles
485			.as_ref()
486			.map(|roles| expand_roles(roles))
487			.filter(|s| !s.is_empty());
488
489		info!("Expanded roles for access token: {:?}", expanded_roles);
490
491		let token_result = app
492			.auth_adapter
493			.create_access_token(
494				tn_id,
495				&auth_adapter::AccessToken {
496					iss: &id_tag.0,
497					sub: Some(&auth_action.iss),
498					r: expanded_roles.as_deref(),
499					scope: query.scope.as_deref(),
500					exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
501				},
502			)
503			.await?;
504		info!("Got access token: {}", &token_result);
505		let response = ApiResponse::new(json!({ "token": token_result }))
506			.with_req_id(req_id.unwrap_or_default());
507		Ok((StatusCode::OK, Json(response)))
508	} else if let Some(ref_id) = query.ref_id {
509		// Exchange share link ref for scoped access token (no auth required)
510		let is_refresh = query.refresh.unwrap_or(false);
511		info!("Exchanging ref_id {} for scoped access token (refresh={})", ref_id, is_refresh);
512
513		// For refresh: validate without decrementing counter
514		// For initial access: validate and decrement counter
515		let (ref_tn_id, _ref_id_tag, ref_data) = if is_refresh {
516			app.meta_adapter.validate_ref(&ref_id, &["share.file"]).await
517		} else {
518			app.meta_adapter.use_ref(&ref_id, &["share.file"]).await
519		}
520		.map_err(|e| {
521			warn!(
522				"Failed to {} ref {}: {}",
523				if is_refresh { "validate" } else { "use" },
524				ref_id,
525				e
526			);
527			match e {
528				Error::NotFound => Error::ValidationError("Invalid or expired share link".into()),
529				Error::ValidationError(_) => e,
530				_ => Error::ValidationError("Invalid share link".into()),
531			}
532		})?;
533
534		// Validate ref belongs to this tenant
535		if ref_tn_id != tn_id {
536			warn!(
537				"Ref tenant mismatch: ref belongs to {:?} but request is for {:?}",
538				ref_tn_id, tn_id
539			);
540			return Err(Error::PermissionDenied);
541		}
542
543		// Extract resource_id (file_id) and access_level
544		let file_id = ref_data
545			.resource_id
546			.ok_or_else(|| Error::ValidationError("Share link missing resource_id".into()))?;
547		let access_level = ref_data.access_level.unwrap_or('R');
548
549		// Create scoped access token
550		// scope format: "file:{file_id}:{R|W}"
551		let scope = format!("file:{}:{}", file_id, access_level);
552		info!("Creating scoped access token with scope={}", scope);
553
554		let token_result = app
555			.auth_adapter
556			.create_access_token(
557				tn_id,
558				&auth_adapter::AccessToken {
559					iss: &id_tag.0,
560					sub: None, // Anonymous/guest access
561					r: None,   // No roles for share link access
562					scope: Some(&scope),
563					exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
564				},
565			)
566			.await?;
567
568		info!("Got scoped access token for share link");
569		let response = ApiResponse::new(json!({
570			"token": token_result,
571			"scope": scope,
572			"resourceId": file_id.to_string(),
573			"accessLevel": if access_level == 'W' { "write" } else { "read" }
574		}))
575		.with_req_id(req_id.unwrap_or_default());
576		Ok((StatusCode::OK, Json(response)))
577	} else if let Some(api_key) = query.api_key {
578		// Exchange API key for access token (no auth required)
579		info!("Exchanging API key for access token");
580
581		// Validate the API key
582		let validation = app.auth_adapter.validate_api_key(&api_key).await.map_err(|e| {
583			warn!("API key validation failed: {:?}", e);
584			Error::PermissionDenied
585		})?;
586
587		// Verify API key belongs to this tenant
588		if validation.tn_id != tn_id {
589			warn!(
590				"API key tenant mismatch: key belongs to {:?} but request is for {:?}",
591				validation.tn_id, tn_id
592			);
593			return Err(Error::PermissionDenied);
594		}
595
596		info!(
597			"Creating access token from API key for id_tag={}, scopes={:?}",
598			validation.id_tag, validation.scopes
599		);
600
601		// Create access token with API key's scopes
602		let token_result = app
603			.auth_adapter
604			.create_access_token(
605				tn_id,
606				&auth_adapter::AccessToken {
607					iss: &id_tag.0,
608					sub: Some(&validation.id_tag),
609					r: validation.roles.as_deref(),
610					scope: validation.scopes.as_deref(),
611					exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
612				},
613			)
614			.await?;
615
616		info!("Got access token from API key: {}", &token_result);
617
618		// Create AuthLogin and use return_login for consistent response
619		let auth_login = auth_adapter::AuthLogin {
620			tn_id,
621			id_tag: validation.id_tag,
622			roles: validation.roles.map(|r| r.split(',').map(Into::into).collect()),
623			token: token_result,
624		};
625		let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
626		let response = ApiResponse::new(serde_json::to_value(login_data)?)
627			.with_req_id(req_id.unwrap_or_default());
628		Ok((StatusCode::OK, Json(response)))
629	} else {
630		// Use authenticated session token - requires auth
631		let auth = maybe_auth.ok_or(Error::Unauthorized)?;
632
633		info!(
634			"Using authenticated session for id_tag={}, scope={:?}",
635			auth.id_tag,
636			query.scope.as_deref()
637		);
638
639		// Fetch profile roles from meta adapter and expand them
640		let profile_roles =
641			app.meta_adapter.read_profile_roles(tn_id, &auth.id_tag).await.ok().flatten();
642
643		// If user is the tenant owner, they implicitly have leader role
644		let profile_roles: Option<Box<[Box<str>]>> = if auth.id_tag == id_tag.0 {
645			Some(vec!["leader".into()].into_boxed_slice())
646		} else {
647			profile_roles
648		};
649
650		let expanded_roles = profile_roles
651			.as_ref()
652			.map(|roles| expand_roles(roles))
653			.filter(|s: &String| !s.is_empty());
654
655		let token_result = app
656			.auth_adapter
657			.create_access_token(
658				tn_id,
659				&auth_adapter::AccessToken {
660					iss: &id_tag.0,
661					sub: Some(&auth.id_tag),
662					r: expanded_roles.as_deref(),
663					scope: query.scope.as_deref(),
664					exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
665				},
666			)
667			.await?;
668		info!("Got access token from session: {}", &token_result);
669		let response = ApiResponse::new(json!({ "token": token_result }))
670			.with_req_id(req_id.unwrap_or_default());
671		Ok((StatusCode::OK, Json(response)))
672	}
673}
674
675/// # GET /api/auth/proxy-token
676/// Generate a proxy token for federation (allows this user to authenticate on behalf of the server)
677/// If `idTag` query parameter is provided and different from the current server, this will
678/// perform a federated token exchange with the target server.
679#[skip_serializing_none]
680#[derive(Serialize)]
681pub struct ProxyTokenRes {
682	token: String,
683	/// User's roles in this context (extracted from JWT for federated tokens)
684	roles: Option<Vec<String>>,
685}
686
687#[derive(Deserialize)]
688pub struct ProxyTokenQuery {
689	#[serde(rename = "idTag")]
690	id_tag: Option<String>,
691}
692
693pub async fn get_proxy_token(
694	State(app): State<App>,
695	IdTag(own_id_tag): IdTag,
696	Auth(auth): Auth,
697	Query(query): Query<ProxyTokenQuery>,
698	OptionalRequestId(req_id): OptionalRequestId,
699) -> ClResult<(StatusCode, Json<ApiResponse<ProxyTokenRes>>)> {
700	// If target idTag is specified and different from own server, use federation
701	if let Some(ref target_id_tag) = query.id_tag {
702		if target_id_tag != own_id_tag.as_ref() {
703			#[derive(Deserialize)]
704			struct AccessTokenClaims {
705				r: Option<String>,
706			}
707
708			info!("Getting federated proxy token for {} -> {}", &auth.id_tag, target_id_tag);
709
710			// Use federation flow: create action token and exchange at target
711			let token = app.request.create_proxy_token(auth.tn_id, target_id_tag, None).await?;
712
713			let roles: Option<Vec<String>> = match decode_jwt_no_verify::<AccessTokenClaims>(&token)
714			{
715				Ok(claims) => {
716					info!("Decoded federated token, roles claim: {:?}", claims.r);
717					claims.r.map(|r| r.split(',').map(String::from).collect())
718				}
719				Err(e) => {
720					warn!("Failed to decode federated token for roles: {:?}", e);
721					None
722				}
723			};
724
725			let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles })
726				.with_req_id(req_id.unwrap_or_default());
727			return Ok((StatusCode::OK, Json(response)));
728		}
729	}
730
731	// Default: create local access token (valid on own server)
732	info!("Generating local access token for {}", &auth.id_tag);
733	let roles_str: String = auth.roles.iter().map(AsRef::as_ref).collect::<Vec<&str>>().join(",");
734	let token = app
735		.auth_adapter
736		.create_access_token(
737			auth.tn_id,
738			&auth_adapter::AccessToken {
739				iss: &own_id_tag,
740				sub: Some(&auth.id_tag),
741				r: if roles_str.is_empty() { None } else { Some(&roles_str) },
742				scope: None,
743				exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
744			},
745		)
746		.await?;
747
748	// Return roles alongside token for local context
749	let roles: Vec<String> = auth.roles.iter().map(ToString::to_string).collect();
750	let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles: Some(roles) })
751		.with_req_id(req_id.unwrap_or_default());
752
753	Ok((StatusCode::OK, Json(response)))
754}
755
756/// # POST /auth/set-password
757/// Set password using a reference (welcome or password reset)
758/// This endpoint is used during registration (welcome ref) and password reset flows
759#[derive(Deserialize)]
760pub struct SetPasswordReq {
761	#[serde(rename = "refId")]
762	ref_id: String,
763	#[serde(rename = "newPassword")]
764	new_password: String,
765}
766
767pub async fn post_set_password(
768	State(app): State<App>,
769	OptionalRequestId(req_id): OptionalRequestId,
770	Json(req): Json<SetPasswordReq>,
771) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
772	// Validate new password strength
773	if req.new_password.trim().is_empty() {
774		return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
775	}
776
777	if req.new_password.len() < 8 {
778		return Err(Error::ValidationError("Password must be at least 8 characters".into()));
779	}
780
781	// Use the ref - this validates type, expiration, counter, and decrements it
782	// Returns the tenant ID, id_tag, and ref data that owns this ref
783	let (tn_id, id_tag, _ref_data) = app
784		.meta_adapter
785		.use_ref(&req.ref_id, &["welcome", "password"])
786		.await
787		.map_err(|e| {
788			warn!("Failed to use ref {}: {}", req.ref_id, e);
789			match e {
790				Error::NotFound => Error::ValidationError("Invalid or expired reference".into()),
791				Error::ValidationError(_) => e,
792				_ => Error::ValidationError("Invalid reference".into()),
793			}
794		})?;
795
796	info!(
797		tn_id = ?tn_id,
798		id_tag = %id_tag,
799		ref_id = %req.ref_id,
800		"Setting password via reference"
801	);
802
803	// Update the password
804	app.auth_adapter.update_tenant_password(&id_tag, &req.new_password).await?;
805
806	info!(
807		tn_id = ?tn_id,
808		id_tag = %id_tag,
809		"Password set successfully, generating login token"
810	);
811
812	// Create a login token for the user
813	let auth = app.auth_adapter.create_tenant_login(&id_tag).await?;
814
815	// Return login info using the existing return_login helper
816	let (_status, Json(login_data)) = return_login(&app, auth).await?;
817	let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
818
819	Ok((StatusCode::OK, Json(response)))
820}
821
822/// # POST /api/auth/forgot-password
823/// Request a password reset email (user-initiated)
824/// Always returns success to prevent email enumeration
825#[derive(Deserialize)]
826pub struct ForgotPasswordReq {
827	email: String,
828}
829
830#[derive(Serialize)]
831pub struct ForgotPasswordRes {
832	message: String,
833}
834
835pub async fn post_forgot_password(
836	State(app): State<App>,
837	ConnectInfo(addr): ConnectInfo<SocketAddr>,
838	OptionalRequestId(req_id): OptionalRequestId,
839	Json(req): Json<ForgotPasswordReq>,
840) -> ClResult<(StatusCode, Json<ApiResponse<ForgotPasswordRes>>)> {
841	let email = req.email.trim().to_lowercase();
842
843	info!(email = %email, ip = %addr.ip(), "Password reset requested");
844
845	// Success response (always returned for security)
846	let success_response = || {
847		ApiResponse::new(ForgotPasswordRes {
848			message: "If an account with this email exists, a password reset link has been sent."
849				.to_string(),
850		})
851		.with_req_id(req_id.clone().unwrap_or_default())
852	};
853
854	// Basic email validation
855	if !email.contains('@') || email.len() < 5 {
856		return Ok((StatusCode::OK, Json(success_response())));
857	}
858
859	// Look up tenant by email
860	let auth_opts =
861		ListTenantsOptions { status: None, q: Some(&email), limit: Some(10), offset: None };
862	let tenants = match app.auth_adapter.list_tenants(&auth_opts).await {
863		Ok(t) => t,
864		Err(e) => {
865			warn!(email = %email, error = ?e, "Failed to look up tenant by email");
866			return Ok((StatusCode::OK, Json(success_response())));
867		}
868	};
869
870	// Find exact email match
871	let tenant = tenants.into_iter().find(|t| t.email.as_deref() == Some(email.as_str()));
872
873	let Some(tenant) = tenant else {
874		info!(email = %email, "No tenant found for email (not revealing)");
875		return Ok((StatusCode::OK, Json(success_response())));
876	};
877
878	let tn_id = tenant.tn_id;
879	let id_tag = tenant.id_tag.to_string();
880
881	// Rate limiting: check recent password reset refs for this tenant
882	// Allow max 1 per hour, 3 per day
883	let opts = ListRefsOptions {
884		typ: Some("password".to_string()),
885		filter: Some("all".to_string()),
886		resource_id: None,
887	};
888	let recent_refs = app.meta_adapter.list_refs(tn_id, &opts).await.unwrap_or_default();
889
890	let now = Timestamp::now().0;
891	let one_hour_ago = now - 3600;
892	let one_day_ago = now - 86400;
893
894	let hourly_count = recent_refs.iter().filter(|r| r.created_at.0 > one_hour_ago).count();
895	let daily_count = recent_refs.iter().filter(|r| r.created_at.0 > one_day_ago).count();
896
897	if hourly_count >= 1 {
898		info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (hourly)");
899		return Ok((StatusCode::OK, Json(success_response())));
900	}
901
902	if daily_count >= 3 {
903		info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (daily)");
904		return Ok((StatusCode::OK, Json(success_response())));
905	}
906
907	// Get tenant meta data for the name
908	let user_name = app
909		.meta_adapter
910		.read_tenant(tn_id)
911		.await
912		.map_or_else(|_| id_tag.clone(), |t| t.name.to_string());
913
914	// Create password reset ref
915	let expires_at = Some(Timestamp(now + 86400)); // 24 hours
916	let (ref_id, reset_url) = match create_ref_internal(
917		&app,
918		tn_id,
919		CreateRefInternalParams {
920			id_tag: &id_tag,
921			typ: "password",
922			description: Some("User-initiated password reset"),
923			expires_at,
924			path_prefix: "/reset-password",
925			resource_id: None,
926			count: None,
927		},
928	)
929	.await
930	{
931		Ok(result) => result,
932		Err(e) => {
933			warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to create password reset ref");
934			return Ok((StatusCode::OK, Json(success_response())));
935		}
936	};
937
938	// Get tenant's preferred language
939	let lang = get_tenant_lang(&app.settings, tn_id).await;
940
941	// Get base_id_tag for sender name
942	let base_id_tag = app.opts.base_id_tag.as_ref().map_or("cloudillo", AsRef::as_ref);
943
944	// Schedule email
945	let email_params = EmailTaskParams {
946		to: email.clone(),
947		subject: None,
948		template_name: "password_reset".to_string(),
949		template_vars: serde_json::json!({
950			"identity_tag": user_name,
951			"base_id_tag": base_id_tag,
952			"instance_name": "Cloudillo",
953			"reset_link": reset_url,
954			"expire_hours": 24,
955		}),
956		lang,
957		custom_key: Some(format!("pw-reset:{}:{}", tn_id.0, now)),
958		from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
959	};
960
961	if let Err(e) =
962		EmailModule::schedule_email_task(&app.scheduler, &app.settings, tn_id, email_params).await
963	{
964		warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to schedule password reset email");
965		// Still return success to not reveal anything
966	} else {
967		info!(
968			tn_id = ?tn_id,
969			id_tag = %id_tag,
970			ref_id = %ref_id,
971			"Password reset email scheduled"
972		);
973	}
974
975	Ok((StatusCode::OK, Json(success_response())))
976}
977
978// ============================================================================
979// POST /api/auth/login-init — Combined login initialization endpoint
980// ============================================================================
981
982#[derive(Serialize)]
983#[serde(tag = "status")]
984pub enum LoginInitResponse {
985	#[serde(rename = "authenticated")]
986	Authenticated { login: Login },
987	#[serde(rename = "unauthenticated")]
988	Unauthenticated {
989		#[serde(rename = "qrLogin")]
990		qr_login: crate::qr_login::InitResponse,
991		#[serde(rename = "webAuthn")]
992		web_authn: Option<crate::webauthn::LoginChallengeRes>,
993	},
994}
995
996pub async fn post_login_init(
997	State(app): State<App>,
998	OptionalAuth(auth): OptionalAuth,
999	tn_id: TnId,
1000	id_tag: IdTag,
1001	ConnectInfo(addr): ConnectInfo<SocketAddr>,
1002	OptionalRequestId(req_id): OptionalRequestId,
1003	headers: HeaderMap,
1004) -> ClResult<(StatusCode, Json<ApiResponse<LoginInitResponse>>)> {
1005	if let Some(auth) = auth {
1006		// Authenticated path: create fresh login token (replaces login-token)
1007		info!("login-init for authenticated user {}", &auth.id_tag);
1008		let auth_login = app.auth_adapter.create_tenant_login(&auth.id_tag).await?;
1009		let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
1010		let response = ApiResponse::new(LoginInitResponse::Authenticated { login: login_data })
1011			.with_req_id(req_id.unwrap_or_default());
1012		Ok((StatusCode::OK, Json(response)))
1013	} else {
1014		// Unauthenticated path: return QR + WebAuthn init data
1015		debug!("login-init for unauthenticated user");
1016		let qr_result = crate::qr_login::create_session(&app, tn_id, &addr, &headers)?;
1017		let wa_result = crate::webauthn::try_login_challenge(&app, &id_tag, tn_id).await;
1018		let response = ApiResponse::new(LoginInitResponse::Unauthenticated {
1019			qr_login: qr_result,
1020			web_authn: wa_result,
1021		})
1022		.with_req_id(req_id.unwrap_or_default());
1023		Ok((StatusCode::OK, Json(response)))
1024	}
1025}
1026
1027// vim: ts=4