Skip to main content

cloudillo_core/
middleware.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Custom middlewares
5
6use crate::extract::RequestId;
7use crate::extract::{Auth, IdTag};
8use crate::prelude::*;
9use axum::{
10	body::Body,
11	extract::State,
12	http::{Method, Request, header, response::Response},
13	middleware::Next,
14};
15use cloudillo_types::auth_adapter::AuthCtx;
16use std::pin::Pin;
17
18/// Tenant API key prefix (validated by auth adapter)
19const TENANT_API_KEY_PREFIX: &str = "cl_";
20
21/// IDP API key prefix (validated by identity provider adapter)
22const IDP_API_KEY_PREFIX: &str = "idp_";
23
24/// API key type for routing to correct validation adapter
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum ApiKeyType {
27	/// Tenant API key (cl_ prefix) - validated by auth adapter
28	Tenant,
29	/// IDP API key (idp_ prefix) - validated by identity provider adapter
30	Idp,
31}
32
33/// Check if a token is an API key and return its type
34fn get_api_key_type(token: &str) -> Option<ApiKeyType> {
35	if token.starts_with(TENANT_API_KEY_PREFIX) {
36		Some(ApiKeyType::Tenant)
37	} else if token.starts_with(IDP_API_KEY_PREFIX) {
38		Some(ApiKeyType::Idp)
39	} else {
40		None
41	}
42}
43
44// Type aliases for permission check middleware components
45pub type PermissionCheckInput =
46	(State<App>, Auth, axum::extract::Path<String>, Request<Body>, Next);
47pub type PermissionCheckOutput =
48	Pin<Box<dyn Future<Output = Result<axum::response::Response, Error>> + Send>>;
49
50/// Wrapper struct for permission check middleware factories
51///
52/// This struct wraps a closure that implements the permission check middleware pattern.
53/// It takes a static permission action string and returns a middleware factory function.
54#[derive(Clone)]
55pub struct PermissionCheckFactory<F>
56where
57	F: Fn(
58			State<App>,
59			Auth,
60			axum::extract::Path<String>,
61			Request<Body>,
62			Next,
63		) -> PermissionCheckOutput
64		+ Clone
65		+ Send
66		+ Sync,
67{
68	handler: F,
69}
70
71impl<F> PermissionCheckFactory<F>
72where
73	F: Fn(
74			State<App>,
75			Auth,
76			axum::extract::Path<String>,
77			Request<Body>,
78			Next,
79		) -> PermissionCheckOutput
80		+ Clone
81		+ Send
82		+ Sync,
83{
84	pub fn new(handler: F) -> Self {
85		Self { handler }
86	}
87
88	pub fn call(
89		&self,
90		state: State<App>,
91		auth: Auth,
92		path: axum::extract::Path<String>,
93		req: Request<Body>,
94		next: Next,
95	) -> PermissionCheckOutput {
96		(self.handler)(state, auth, path, req, next)
97	}
98}
99
100/// Extract token from query parameters
101fn extract_token_from_query(query: &str) -> Option<String> {
102	for param in query.split('&') {
103		if param.starts_with("token=") {
104			let token = param.strip_prefix("token=")?;
105			if !token.is_empty() {
106				// For JWT tokens, just use as-is (they don't contain special chars that need decoding)
107				// URL decoding is typically only needed for form-encoded data
108				return Some(token.to_string());
109			}
110		}
111	}
112	None
113}
114
115pub async fn require_auth(
116	State(state): State<App>,
117	mut req: Request<Body>,
118	next: Next,
119) -> ClResult<Response<Body>> {
120	// Extract IdTag from request extensions (inserted by webserver)
121	let id_tag = req
122		.extensions()
123		.get::<IdTag>()
124		.ok_or_else(|| {
125			warn!("IdTag not found in request extensions");
126			Error::PermissionDenied
127		})?
128		.clone();
129
130	// Convert IdTag to TnId via database lookup
131	let tn_id = state.auth_adapter.read_tn_id(&id_tag.0).await.map_err(|_| {
132		warn!("Failed to resolve tenant ID for id_tag: {}", id_tag.0);
133		Error::PermissionDenied
134	})?;
135
136	// Try to get token from Authorization header first
137	let token = if let Some(auth_header) =
138		req.headers().get("Authorization").and_then(|h| h.to_str().ok())
139	{
140		if let Some(token) = auth_header.strip_prefix("Bearer ") {
141			token.trim().to_string()
142		} else {
143			warn!("Authorization header present but doesn't start with 'Bearer ': {}", auth_header);
144			return Err(Error::PermissionDenied);
145		}
146	} else {
147		// Fallback: try to get token from query parameter (for WebSocket)
148		let query_token = extract_token_from_query(req.uri().query().unwrap_or(""));
149		if query_token.is_none() {
150			warn!("No Authorization header and no token query parameter found");
151		}
152		query_token.ok_or(Error::PermissionDenied)?
153	};
154
155	// Validate token based on type
156	let claims = match get_api_key_type(&token) {
157		Some(ApiKeyType::Tenant) => {
158			// Validate tenant API key (cl_ prefix)
159			let validation = state.auth_adapter.validate_api_key(&token).await.map_err(|e| {
160				warn!("Tenant API key validation failed: {:?}", e);
161				Error::PermissionDenied
162			})?;
163
164			// Verify API key belongs to requested tenant
165			if validation.tn_id != tn_id {
166				warn!(
167					"API key tenant mismatch: key belongs to {:?} but request is for {:?}",
168					validation.tn_id, tn_id
169				);
170				return Err(Error::PermissionDenied);
171			}
172
173			AuthCtx {
174				tn_id: validation.tn_id,
175				id_tag: validation.id_tag,
176				roles: validation
177					.roles
178					.map(|r| r.split(',').map(Box::from).collect())
179					.unwrap_or_default(),
180				scope: validation.scopes,
181			}
182		}
183		Some(ApiKeyType::Idp) => {
184			// Validate IDP API key (idp_ prefix)
185			let idp_adapter = state.idp_adapter.as_ref().ok_or_else(|| {
186				warn!("IDP API key used but Identity Provider not available");
187				Error::ServiceUnavailable("Identity Provider not available".to_string())
188			})?;
189
190			let auth_id_tag = idp_adapter
191				.verify_api_key(&token)
192				.await
193				.map_err(|e| {
194					warn!("IDP API key validation error: {:?}", e);
195					Error::PermissionDenied
196				})?
197				.ok_or_else(|| {
198					warn!("IDP API key validation failed: key not found or expired");
199					Error::PermissionDenied
200				})?;
201
202			AuthCtx {
203				tn_id, // From request host lookup
204				id_tag: auth_id_tag.into(),
205				roles: Box::new([]), // IDP keys don't have roles
206				scope: None,
207			}
208		}
209		None => {
210			// Validate JWT token (existing flow)
211			state.auth_adapter.validate_access_token(tn_id, &id_tag.0, &token).await?
212		}
213	};
214
215	// Enforce scope restrictions: scoped tokens can only access matching endpoints
216	if let Some(ref scope) = claims.scope
217		&& let Some(token_scope) = cloudillo_types::types::TokenScope::parse(scope)
218	{
219		let path = req.uri().path();
220		let method = req.method().clone();
221		let allowed = match token_scope {
222			cloudillo_types::types::TokenScope::File { .. } => {
223				path.starts_with("/api/files/")
224					|| path == "/api/files"
225					|| path.starts_with("/ws/rtdb/")
226					|| path.starts_with("/ws/crdt/")
227					|| path == "/api/auth/access-token"
228			}
229			cloudillo_types::types::TokenScope::ApkgPublish => {
230				path.starts_with("/api/files/apkg/")
231					|| (path == "/api/actions" && method == Method::POST)
232					|| path.starts_with("/api/apps")
233			}
234		};
235		if !allowed {
236			warn!(scope = %scope, path = %path, "Scoped token denied access to non-matching endpoint");
237			return Err(Error::PermissionDenied);
238		}
239	}
240
241	req.extensions_mut().insert(Auth(claims));
242
243	Ok(next.run(req).await)
244}
245
246pub async fn optional_auth(
247	State(state): State<App>,
248	mut req: Request<Body>,
249	next: Next,
250) -> ClResult<Response<Body>> {
251	// Try to extract IdTag (optional for this middleware)
252	let id_tag = req.extensions().get::<IdTag>().cloned();
253
254	// Try to get token from Authorization header first
255	let token = if let Some(auth_header) =
256		req.headers().get(header::AUTHORIZATION).and_then(|h| h.to_str().ok())
257	{
258		auth_header.strip_prefix("Bearer ").map(|token| token.trim().to_string())
259	} else if req.uri().path().starts_with("/ws/") || req.uri().path().starts_with("/api/files/") {
260		// Fallback: try to get token from query parameter (for WebSocket and file endpoints)
261		let query = req.uri().query().unwrap_or("");
262		extract_token_from_query(query)
263	} else {
264		None
265	};
266
267	// Only validate if both id_tag and token are present
268	if let (Some(id_tag), Some(ref token)) = (id_tag, token) {
269		// Try to get tn_id
270		match state.auth_adapter.read_tn_id(&id_tag.0).await {
271			Ok(tn_id) => {
272				// Try to validate token based on type
273				let claims_result: Result<Result<AuthCtx, Error>, Error> =
274					match get_api_key_type(token) {
275						Some(ApiKeyType::Tenant) => {
276							// Validate tenant API key (cl_ prefix)
277							state.auth_adapter.validate_api_key(token).await.map(|validation| {
278								// Verify API key belongs to requested tenant
279								if validation.tn_id != tn_id {
280									return Err(Error::PermissionDenied);
281								}
282								Ok(AuthCtx {
283									tn_id: validation.tn_id,
284									id_tag: validation.id_tag,
285									roles: validation
286										.roles
287										.map(|r| r.split(',').map(Box::from).collect())
288										.unwrap_or_default(),
289									scope: validation.scopes,
290								})
291							})
292						}
293						Some(ApiKeyType::Idp) => {
294							// Validate IDP API key (idp_ prefix)
295							if let Some(idp_adapter) = state.idp_adapter.as_ref() {
296								match idp_adapter.verify_api_key(token).await {
297									Ok(Some(auth_id_tag)) => Ok(Ok(AuthCtx {
298										tn_id,
299										id_tag: auth_id_tag.into(),
300										roles: Box::new([]),
301										scope: None,
302									})),
303									Ok(None) => {
304										warn!(
305											"IDP API key validation failed: key not found or expired"
306										);
307										Err(Error::PermissionDenied)
308									}
309									Err(e) => {
310										warn!("IDP API key validation error: {:?}", e);
311										Err(Error::PermissionDenied)
312									}
313								}
314							} else {
315								warn!("IDP API key used but Identity Provider not available");
316								Err(Error::ServiceUnavailable(
317									"Identity Provider not available".to_string(),
318								))
319							}
320						}
321						None => {
322							// Validate JWT token
323							state
324								.auth_adapter
325								.validate_access_token(tn_id, &id_tag.0, token)
326								.await
327								.map(Ok)
328						}
329					};
330
331				match claims_result {
332					Ok(Ok(claims)) => {
333						// Enforce scope restrictions: scoped tokens can only access matching endpoints
334						let scope_allowed = if let Some(ref scope) = claims.scope {
335							if let Some(token_scope) =
336								cloudillo_types::types::TokenScope::parse(scope)
337							{
338								let path = req.uri().path();
339								let method = req.method().clone();
340								match token_scope {
341									cloudillo_types::types::TokenScope::File { .. } => {
342										path.starts_with("/api/files/")
343											|| path == "/api/files" || path.starts_with("/ws/rtdb/")
344											|| path.starts_with("/ws/crdt/") || path
345											== "/api/auth/access-token"
346									}
347									// ApkgPublish scope: intentionally restrictive allowlist.
348									// Only permits the exact endpoints needed for app publishing
349									// to limit blast radius of a compromised scoped token.
350									cloudillo_types::types::TokenScope::ApkgPublish => {
351										path.starts_with("/api/files/apkg/")
352											|| (path == "/api/actions" && method == Method::POST)
353											|| path.starts_with("/api/apps")
354									}
355								}
356							} else {
357								false
358							}
359						} else {
360							true
361						};
362						if scope_allowed {
363							req.extensions_mut().insert(Auth(claims));
364						} else {
365							warn!(
366								"Scoped token denied access in optional_auth, treating as unauthenticated"
367							);
368						}
369					}
370					Ok(Err(e)) => {
371						warn!("Token validation failed (tenant mismatch): {:?}", e);
372					}
373					Err(e) => {
374						warn!("Token validation failed: {:?}", e);
375					}
376				}
377			}
378			Err(e) => {
379				warn!("Failed to resolve tenant ID: {:?}", e);
380			}
381		}
382	}
383
384	Ok(next.run(req).await)
385}
386
387/// Add or generate request ID, attach a `request` span carrying its short
388/// form, and store the full id in extensions. The custom log formatter
389/// (`crate::log::CloudilloFormat`) uses the `request` span's `id` field to
390/// prefix every event line with `REQ:<short>`.
391///
392/// If the outer transport layer (see `cloudillo::webserver::create_https_server`)
393/// has already inserted a `RequestId` extension and entered the `request` span,
394/// `RequestId::install` returns a span that just re-uses the existing id.
395pub async fn request_id_middleware(mut req: Request<Body>, next: Next) -> Response<Body> {
396	let span = RequestId::install(&mut req);
397	let request_id = req.extensions().get::<RequestId>().map(|r| r.0.clone()).unwrap_or_default();
398
399	let mut response = {
400		use tracing::Instrument;
401		next.run(req).instrument(span).await
402	};
403
404	if let Ok(header_value) = request_id.parse() {
405		response.headers_mut().insert("X-Request-ID", header_value);
406	}
407	response
408}
409
410// vim: ts=4