Skip to content

Commit 740c152

Browse files
committed
feat: add skip_paths for JwtLayer and docs_with_auth for protected Swagger UI
- JwtLayer now supports skip_paths() to exclude paths from JWT validation - Added docs_with_auth() for Basic Auth protected documentation - Updated auth-api example to demonstrate protected docs - Bumped version to 0.1.2
1 parent 4ca8341 commit 740c152

7 files changed

Lines changed: 206 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.1.2] - 2024-12-31
11+
1012
### Added
11-
- CONTRIBUTING.md with contribution guidelines
12-
- CHANGELOG.md following Keep a Changelog format
13-
- GitHub Actions CI/CD workflows
14-
- Dual MIT/Apache-2.0 license files
13+
- `skip_paths` method for JwtLayer to exclude paths from JWT validation
14+
- `docs_with_auth` method for Basic Auth protected Swagger UI
15+
- `docs_with_auth_and_info` method for customized protected docs
16+
17+
### Changed
18+
- auth-api example now demonstrates protected docs with Basic Auth
19+
- JWT middleware can now skip validation for public endpoints
1520

1621
## [0.1.1] - 2024-12-31
1722

@@ -78,6 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7883
- `extras` meta-feature for common optional features
7984
- `full` feature for all optional features
8085

81-
[Unreleased]: https://github.com/Tuntii/RustAPI/compare/v0.1.1...HEAD
86+
[Unreleased]: https://github.com/Tuntii/RustAPI/compare/v0.1.2...HEAD
87+
[0.1.2]: https://github.com/Tuntii/RustAPI/compare/v0.1.1...v0.1.2
8288
[0.1.1]: https://github.com/Tuntii/RustAPI/compare/v0.1.0...v0.1.1
8389
[0.1.0]: https://github.com/Tuntii/RustAPI/releases/tag/v0.1.0

Cargo.lock

Lines changed: 7 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ members = [
1414
]
1515

1616
[workspace.package]
17-
version = "0.1.1"
17+
version = "0.1.2"
1818
edition = "2021"
1919
authors = ["RustAPI Contributors"]
2020
license = "MIT OR Apache-2.0"
@@ -72,8 +72,8 @@ prometheus = "0.13"
7272
utoipa = { version = "4.2" }
7373

7474
# Internal crates
75-
rustapi-core = { path = "crates/rustapi-core", version = "0.1.1" }
76-
rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.1" }
77-
rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.1" }
78-
rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.1" }
79-
rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.1" }
75+
rustapi-core = { path = "crates/rustapi-core", version = "0.1.2" }
76+
rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.2" }
77+
rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.2" }
78+
rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.2" }
79+
rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.2" }

crates/rustapi-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ tracing = { workspace = true }
4141
tracing-subscriber = { workspace = true }
4242
inventory = { workspace = true }
4343
uuid = { workspace = true }
44+
base64 = "0.22"
4445

4546
# Cookies (optional)
4647
cookie = { version = "0.18", optional = true }

crates/rustapi-core/src/app.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,129 @@ impl RustApi {
370370
.route(path, get(docs_handler))
371371
}
372372

373+
/// Enable Swagger UI documentation with Basic Auth protection
374+
///
375+
/// When username and password are provided, the docs endpoint will require
376+
/// Basic Authentication. This is useful for protecting API documentation
377+
/// in production environments.
378+
///
379+
/// # Example
380+
///
381+
/// ```rust,ignore
382+
/// RustApi::new()
383+
/// .route("/users", get(list_users))
384+
/// .docs_with_auth("/docs", "admin", "secret123")
385+
/// .run("127.0.0.1:8080")
386+
/// .await
387+
/// ```
388+
#[cfg(feature = "swagger-ui")]
389+
pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
390+
let title = self.openapi_spec.info.title.clone();
391+
let version = self.openapi_spec.info.version.clone();
392+
let description = self.openapi_spec.info.description.clone();
393+
394+
self.docs_with_auth_and_info(
395+
path,
396+
username,
397+
password,
398+
&title,
399+
&version,
400+
description.as_deref(),
401+
)
402+
}
403+
404+
/// Enable Swagger UI documentation with Basic Auth and custom API info
405+
///
406+
/// # Example
407+
///
408+
/// ```rust,ignore
409+
/// RustApi::new()
410+
/// .docs_with_auth_and_info(
411+
/// "/docs",
412+
/// "admin",
413+
/// "secret",
414+
/// "My API",
415+
/// "2.0.0",
416+
/// Some("Protected API documentation")
417+
/// )
418+
/// ```
419+
#[cfg(feature = "swagger-ui")]
420+
pub fn docs_with_auth_and_info(
421+
mut self,
422+
path: &str,
423+
username: &str,
424+
password: &str,
425+
title: &str,
426+
version: &str,
427+
description: Option<&str>,
428+
) -> Self {
429+
use crate::router::MethodRouter;
430+
use base64::{engine::general_purpose::STANDARD, Engine};
431+
use std::collections::HashMap;
432+
433+
// Update spec info
434+
self.openapi_spec.info.title = title.to_string();
435+
self.openapi_spec.info.version = version.to_string();
436+
if let Some(desc) = description {
437+
self.openapi_spec.info.description = Some(desc.to_string());
438+
}
439+
440+
let path = path.trim_end_matches('/');
441+
let openapi_path = format!("{}/openapi.json", path);
442+
443+
// Create expected auth header value
444+
let credentials = format!("{}:{}", username, password);
445+
let encoded = STANDARD.encode(credentials.as_bytes());
446+
let expected_auth = format!("Basic {}", encoded);
447+
448+
// Clone values for closures
449+
let spec_json =
450+
serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
451+
let openapi_url = openapi_path.clone();
452+
let expected_auth_spec = expected_auth.clone();
453+
let expected_auth_docs = expected_auth;
454+
455+
// Create spec handler with auth check
456+
let spec_handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |req: crate::Request| {
457+
let json = spec_json.clone();
458+
let expected = expected_auth_spec.clone();
459+
Box::pin(async move {
460+
if !check_basic_auth(&req, &expected) {
461+
return unauthorized_response();
462+
}
463+
http::Response::builder()
464+
.status(http::StatusCode::OK)
465+
.header(http::header::CONTENT_TYPE, "application/json")
466+
.body(http_body_util::Full::new(bytes::Bytes::from(json)))
467+
.unwrap()
468+
}) as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
469+
});
470+
471+
// Create docs handler with auth check
472+
let docs_handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |req: crate::Request| {
473+
let url = openapi_url.clone();
474+
let expected = expected_auth_docs.clone();
475+
Box::pin(async move {
476+
if !check_basic_auth(&req, &expected) {
477+
return unauthorized_response();
478+
}
479+
rustapi_openapi::swagger_ui_html(&url)
480+
}) as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
481+
});
482+
483+
// Create method routers with boxed handlers
484+
let mut spec_handlers = HashMap::new();
485+
spec_handlers.insert(http::Method::GET, spec_handler);
486+
let spec_router = MethodRouter::from_boxed(spec_handlers);
487+
488+
let mut docs_handlers = HashMap::new();
489+
docs_handlers.insert(http::Method::GET, docs_handler);
490+
let docs_router = MethodRouter::from_boxed(docs_handlers);
491+
492+
self.route(&openapi_path, spec_router)
493+
.route(path, docs_router)
494+
}
495+
373496
/// Run the server
374497
///
375498
/// # Example
@@ -407,3 +530,24 @@ impl Default for RustApi {
407530
Self::new()
408531
}
409532
}
533+
534+
/// Check Basic Auth header against expected credentials
535+
#[cfg(feature = "swagger-ui")]
536+
fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
537+
req.headers()
538+
.get(http::header::AUTHORIZATION)
539+
.and_then(|v| v.to_str().ok())
540+
.map(|auth| auth == expected)
541+
.unwrap_or(false)
542+
}
543+
544+
/// Create 401 Unauthorized response with WWW-Authenticate header
545+
#[cfg(feature = "swagger-ui")]
546+
fn unauthorized_response() -> crate::Response {
547+
http::Response::builder()
548+
.status(http::StatusCode::UNAUTHORIZED)
549+
.header(http::header::WWW_AUTHENTICATE, "Basic realm=\"API Documentation\"")
550+
.header(http::header::CONTENT_TYPE, "text/plain")
551+
.body(http_body_util::Full::new(bytes::Bytes::from("Unauthorized")))
552+
.unwrap()
553+
}

crates/rustapi-extras/src/jwt/mod.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,15 @@ impl JwtValidation {
8686
/// }
8787
///
8888
/// let app = RustApi::new()
89-
/// .layer(JwtLayer::<Claims>::new("my-secret-key"))
89+
/// .layer(JwtLayer::<Claims>::new("my-secret-key")
90+
/// .skip_paths(vec!["/health", "/docs", "/auth/login"]))
9091
/// .route("/protected", get(protected_handler));
9192
/// ```
9293
#[derive(Clone)]
9394
pub struct JwtLayer<T> {
9495
secret: Arc<String>,
9596
validation: JwtValidation,
97+
skip_paths: Arc<Vec<String>>,
9698
_claims: PhantomData<T>,
9799
}
98100

@@ -102,6 +104,7 @@ impl<T: DeserializeOwned + Clone + Send + Sync + 'static> JwtLayer<T> {
102104
Self {
103105
secret: Arc::new(secret.into()),
104106
validation: JwtValidation::default(),
107+
skip_paths: Arc::new(Vec::new()),
105108
_claims: PhantomData,
106109
}
107110
}
@@ -112,6 +115,22 @@ impl<T: DeserializeOwned + Clone + Send + Sync + 'static> JwtLayer<T> {
112115
self
113116
}
114117

118+
/// Skip JWT validation for specific paths.
119+
///
120+
/// Paths that start with any of the provided prefixes will bypass JWT validation.
121+
/// This is useful for public endpoints like health checks, documentation, and login.
122+
///
123+
/// # Example
124+
///
125+
/// ```ignore
126+
/// let layer = JwtLayer::<Claims>::new("secret")
127+
/// .skip_paths(vec!["/health", "/docs", "/auth/login"]);
128+
/// ```
129+
pub fn skip_paths(mut self, paths: Vec<&str>) -> Self {
130+
self.skip_paths = Arc::new(paths.into_iter().map(String::from).collect());
131+
self
132+
}
133+
115134
/// Get the configured secret.
116135
pub fn secret(&self) -> &str {
117136
&self.secret
@@ -142,8 +161,15 @@ impl<T: DeserializeOwned + Clone + Send + Sync + 'static> MiddlewareLayer for Jw
142161
) -> Pin<Box<dyn Future<Output = Response> + Send + 'static>> {
143162
let secret = self.secret.clone();
144163
let validation = self.validation.clone();
164+
let skip_paths = self.skip_paths.clone();
145165

146166
Box::pin(async move {
167+
// Check if this path should skip JWT validation
168+
let path = req.uri().path();
169+
if skip_paths.iter().any(|skip| path.starts_with(skip)) {
170+
return next(req).await;
171+
}
172+
147173
// Extract the Authorization header
148174
let auth_header = req.headers().get(http::header::AUTHORIZATION);
149175

examples/auth-api/src/main.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,33 +195,36 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
195195
println!(" GET /protected/data - Protected data");
196196
println!();
197197
println!("Documentation:");
198-
println!(" GET /docs - Swagger UI");
198+
println!(" GET /docs - Swagger UI (Basic Auth: docs / docs123)");
199199
println!();
200200
println!("Server running at http://127.0.0.1:8080");
201201

202202
// Create the app with JWT middleware for protected routes
203+
// Public routes (/health, /auth/login, /) are excluded from JWT validation
204+
// Docs has its own Basic Auth protection
203205
let app = RustApi::new()
204206
.body_limit(1024 * 1024) // 1MB limit
205207
.layer(RequestIdLayer::new())
206208
.layer(TracingLayer::new())
207209
// Rate limiting: 100 requests per minute
208210
.layer(RateLimitLayer::new(100, Duration::from_secs(60)))
209-
// JWT middleware - validates tokens on all routes
210-
// Public routes will fail without token, so we need to handle that
211-
.layer(JwtLayer::<Claims>::new(JWT_SECRET))
211+
// JWT middleware - skip public paths (docs has its own auth)
212+
.layer(JwtLayer::<Claims>::new(JWT_SECRET)
213+
.skip_paths(vec!["/health", "/docs", "/auth/login", "/"]))
212214
.register_schema::<LoginRequest>()
213215
.register_schema::<LoginResponse>()
214216
.register_schema::<UserProfile>()
215217
.register_schema::<Message>()
216-
// Public routes (these will require token due to global JWT layer)
218+
// Public routes
217219
.mount_route(welcome_route())
218220
.mount_route(health_route())
219221
.mount_route(login_route())
220-
// Protected routes using standard routing
222+
// Protected routes
221223
.route("/protected/profile", get(get_profile))
222224
.route("/protected/admin", get(admin_only))
223225
.route("/protected/data", get(get_protected_data))
224-
.docs("/docs");
226+
// Docs with Basic Auth protection
227+
.docs_with_auth("/docs", "docs", "docs123");
225228

226229
app.run("127.0.0.1:8080").await
227230
}

0 commit comments

Comments
 (0)