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, Method, 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 method = req.method().clone();
219			let allowed = match token_scope {
220				cloudillo_types::types::TokenScope::File { .. } => {
221					path.starts_with("/api/files/")
222						|| path == "/api/files"
223						|| path.starts_with("/ws/rtdb/")
224						|| path.starts_with("/ws/crdt/")
225						|| path == "/api/auth/access-token"
226				}
227				cloudillo_types::types::TokenScope::ApkgPublish => {
228					path.starts_with("/api/files/apkg/")
229						|| (path == "/api/actions" && method == Method::POST)
230						|| path.starts_with("/api/apps")
231				}
232			};
233			if !allowed {
234				warn!(scope = %scope, path = %path, "Scoped token denied access to non-matching endpoint");
235				return Err(Error::PermissionDenied);
236			}
237		}
238	}
239
240	req.extensions_mut().insert(Auth(claims));
241
242	Ok(next.run(req).await)
243}
244
245pub async fn optional_auth(
246	State(state): State<App>,
247	mut req: Request<Body>,
248	next: Next,
249) -> ClResult<Response<Body>> {
250	// Try to extract IdTag (optional for this middleware)
251	let id_tag = req.extensions().get::<IdTag>().cloned();
252
253	// Try to get token from Authorization header first
254	let token = if let Some(auth_header) =
255		req.headers().get(header::AUTHORIZATION).and_then(|h| h.to_str().ok())
256	{
257		auth_header.strip_prefix("Bearer ").map(|token| token.trim().to_string())
258	} else if req.uri().path().starts_with("/ws/") || req.uri().path().starts_with("/api/files/") {
259		// Fallback: try to get token from query parameter (for WebSocket and file endpoints)
260		let query = req.uri().query().unwrap_or("");
261		extract_token_from_query(query)
262	} else {
263		None
264	};
265
266	// Only validate if both id_tag and token are present
267	if let (Some(id_tag), Some(ref token)) = (id_tag, token) {
268		// Try to get tn_id
269		match state.auth_adapter.read_tn_id(&id_tag.0).await {
270			Ok(tn_id) => {
271				// Try to validate token based on type
272				let claims_result: Result<Result<AuthCtx, Error>, Error> =
273					match get_api_key_type(token) {
274						Some(ApiKeyType::Tenant) => {
275							// Validate tenant API key (cl_ prefix)
276							state.auth_adapter.validate_api_key(token).await.map(|validation| {
277								// Verify API key belongs to requested tenant
278								if validation.tn_id != tn_id {
279									return Err(Error::PermissionDenied);
280								}
281								Ok(AuthCtx {
282									tn_id: validation.tn_id,
283									id_tag: validation.id_tag,
284									roles: validation
285										.roles
286										.map(|r| r.split(',').map(Box::from).collect())
287										.unwrap_or_default(),
288									scope: validation.scopes,
289								})
290							})
291						}
292						Some(ApiKeyType::Idp) => {
293							// Validate IDP API key (idp_ prefix)
294							if let Some(idp_adapter) = state.idp_adapter.as_ref() {
295								match idp_adapter.verify_api_key(token).await {
296									Ok(Some(auth_id_tag)) => Ok(Ok(AuthCtx {
297										tn_id,
298										id_tag: auth_id_tag.into(),
299										roles: Box::new([]),
300										scope: None,
301									})),
302									Ok(None) => {
303										warn!("IDP API key validation failed: key not found or expired");
304										Err(Error::PermissionDenied)
305									}
306									Err(e) => {
307										warn!("IDP API key validation error: {:?}", e);
308										Err(Error::PermissionDenied)
309									}
310								}
311							} else {
312								warn!("IDP API key used but Identity Provider not available");
313								Err(Error::ServiceUnavailable(
314									"Identity Provider not available".to_string(),
315								))
316							}
317						}
318						None => {
319							// Validate JWT token
320							state.auth_adapter.validate_access_token(tn_id, token).await.map(Ok)
321						}
322					};
323
324				match claims_result {
325					Ok(Ok(claims)) => {
326						// Enforce scope restrictions: scoped tokens can only access matching endpoints
327						let scope_allowed = if let Some(ref scope) = claims.scope {
328							if let Some(token_scope) =
329								cloudillo_types::types::TokenScope::parse(scope)
330							{
331								let path = req.uri().path();
332								let method = req.method().clone();
333								match token_scope {
334									cloudillo_types::types::TokenScope::File { .. } => {
335										path.starts_with("/api/files/")
336											|| path == "/api/files" || path.starts_with("/ws/rtdb/")
337											|| path.starts_with("/ws/crdt/") || path
338											== "/api/auth/access-token"
339									}
340									// ApkgPublish scope: intentionally restrictive allowlist.
341									// Only permits the exact endpoints needed for app publishing
342									// to limit blast radius of a compromised scoped token.
343									cloudillo_types::types::TokenScope::ApkgPublish => {
344										path.starts_with("/api/files/apkg/")
345											|| (path == "/api/actions" && method == Method::POST)
346											|| path.starts_with("/api/apps")
347									}
348								}
349							} else {
350								false
351							}
352						} else {
353							true
354						};
355						if scope_allowed {
356							req.extensions_mut().insert(Auth(claims));
357						} else {
358							warn!("Scoped token denied access in optional_auth, treating as unauthenticated");
359						}
360					}
361					Ok(Err(e)) => {
362						warn!("Token validation failed (tenant mismatch): {:?}", e);
363					}
364					Err(e) => {
365						warn!("Token validation failed: {:?}", e);
366					}
367				}
368			}
369			Err(e) => {
370				warn!("Failed to resolve tenant ID: {:?}", e);
371			}
372		}
373	}
374
375	Ok(next.run(req).await)
376}
377
378/// Add or generate request ID and store in extensions
379pub async fn request_id_middleware(mut req: Request<Body>, next: Next) -> Response<Body> {
380	// Extract X-Request-ID header if present, otherwise generate new one
381	let request_id = req
382		.headers()
383		.get("X-Request-ID")
384		.and_then(|h| h.to_str().ok())
385		.map_or_else(|| format!("req_{}", random_id().unwrap_or_default()), ToString::to_string);
386
387	// Store in extensions for handlers to access
388	req.extensions_mut().insert(RequestId(request_id.clone()));
389
390	// Run the request
391	let mut response = next.run(req).await;
392
393	// Add request ID to response headers
394	if let Ok(header_value) = request_id.parse() {
395		response.headers_mut().insert("X-Request-ID", header_value);
396	}
397
398	response
399}
400
401// vim: ts=4