Skip to main content

cloudillo_core/
middleware.rs

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