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