Skip to main content

cloudillo_auth/
handler.rs

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