13 Commits
ci ... main

Author SHA1 Message Date
Geoff Seemueller
0493f32f3c Merge pull request #7 from seemueller-io/geoffsee-patch-1
Update README.md
2025-08-15 09:12:30 -04:00
Geoff Seemueller
e4ac91afe9 Update README.md 2025-08-15 09:11:23 -04:00
Geoff Seemueller
1f52e2fd06 Merge pull request #5 from seemueller-io/add-tests
add tests
2025-06-17 12:29:49 -04:00
geoffsee
d04634a99c add tests 2025-06-17 10:38:30 -04:00
geoffsee
90f6a0ab7e Update README.md 2025-06-06 09:39:08 -04:00
Geoff Seemueller
e1d6d007a5 Update README.md 2025-05-22 20:43:18 -04:00
Geoff Seemueller
562be94a57 Update README.md 2025-05-22 20:40:46 -04:00
Geoff Seemueller
c39ddec19a Update README.md 2025-05-22 20:39:34 -04:00
Geoff Seemueller
c49a3d72a4 Update README.md 2025-05-22 20:37:23 -04:00
Geoff Seemueller
342ce05d89 Update README.md 2025-05-22 20:26:57 -04:00
Geoff Seemueller
d9f85d1b80 Update README.md 2025-05-22 12:49:20 -04:00
Geoff Seemueller
bb8c27cd53 Update README.md 2025-05-12 18:12:25 -04:00
Geoff Seemueller
a1b5a473eb Merge pull request #2 from seemueller-io/ci
add test workflow
2025-05-12 17:35:58 -04:00
6 changed files with 549 additions and 175 deletions

224
README.md
View File

@@ -1,200 +1,76 @@
# zitadel-session-worker
# axum-tower-sessions-edge
[![Rust](https://github.com/seemueller-io/axum-tower-sessions-edge/actions/workflows/test.yaml/badge.svg)](https://github.com/seemueller-io/axum-tower-sessions-edge/actions/workflows/test.yaml)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
> ⚠️ **WARNING**: This project is currently in development and **NOT** production-ready. Use at your own risk. It may
> contain bugs, security vulnerabilities, or incomplete features. This should
> serve as a starting point for anyone building similar technology. All feedback is welcome.
> Deprecated: This project is being rewritten to support newer dependency versions.
A Rust Cloudflare Worker that provides authentication and session management for web applications using ZITADEL as the identity provider. It adopts the implementation for oauth2 token introspection from [smartive/zitadel-rs](https://github.com/smartive/zitadel-rust).
Warning: This API may be unstable.
## Overview
Validates incoming requests for defined routes and forwards traffic to the service defined as `PROXY_TARGET`.
This project is a Rust-based Cloudflare Worker that acts as an authentication proxy for web applications. It handles:
> Targets `wasm32-unknown-unknown`
- oauth2/oidc w/PKCE via Zitadel
- Session management using Cloudflare KV storage
- Token introspection and validation
- Proxying authenticated requests to backend services
## Features
- [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
- [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
- [OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
When deployed, the worker sits between your users and your application services. It:
1. Intercepts incoming requests
2. Verifies if the user has a valid session
3. If not, redirects to ZITADEL for authentication
4. After successful authentication, creates a session and proxies the request to your service
5. For subsequent requests, validates the session and proxies authenticated requests
> **Note**: Caches are used by the introspection and session modules. They prevent excessive r/w.
## Prerequisites
- [Rust](https://www.rust-lang.org/tools/install) (latest stable version)
- LLVM and clang
- [Bun](https://bun.sh/) JavaScript runtime
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) for Cloudflare Workers development
- ZITADEL Administrator Access
## Installation
1. Clone the repository:
## Quickstart
```bash
git clone <repository-url>
cd zitadel-session-worker
```
git clone https://github.com/seemueller-io/axum-tower-sessions-edge.git
cd axum-tower-sessions-edge
bun install
# Create a `.dev.vars` file in the project root with the following variables:
#CLIENT_ID="your-client-id"
#CLIENT_SECRET="your-client-secret"
#AUTH_SERVER_URL="https://your-zitadel-instance-url"
#ZITADEL_ORG_ID="your-organization-id"
#ZITADEL_PROJECT_ID="your-project-id"
#APP_URL="http://localhost:3000"
2. Install dependencies:
```bash
# Install JavaScript dependencies
bun install
```
# Update the wrangler.jsonc and replace the value of PROXY_TARGET with a worker script name.
## Configuration
> **Note**: There is a docker compose file with Zitadel in this repository that can be used for testing.
### Environment Variables
Create a `.dev.vars` file in the project root with the following variables:
```
CLIENT_ID="your-client-id"
CLIENT_SECRET="your-client-secret"
AUTH_SERVER_URL="your-zitadel-instance-url"
ZITADEL_ORG_ID="your-organization-id"
ZITADEL_PROJECT_ID="your-project-id"
APP_URL="http://localhost:3000"
DEV_MODE="true"
npx wrangler dev
# Open `http://localhost:3000` in your browser. If everything is configured correctly, you should be taken to a Zitadel login page.
```
### Wrangler Configuration
### Extras
- `wrangler.jsonc` - Base configuration
Run your own Zitadel: `docker compose up -d`
> You will need to configure:
> - Organization
> - Project
> - Application - _Choose PKCE (with code)_
## Development
### Running Locally
```bash
# Start the development server
bun run dev
```
This will start the worker on `localhost:3000`.
### Building
Sometimes the error messages are challenging to surface. Here are some alternative build commands that might help.
```bash
# Build the project
# Default build
npx wrangler build
# Build command as defined in wrangler.jsonc
cargo clean && cargo install -q worker-build && worker-build --release
# Hacky but effective (targets the common runtime)
cargo build --release --target wasm32-unknown-unknown
```
## Deployment
### Deploying to Cloudflare
```bash
# Deploy to development environment
bun run deploy:dev
# Deploy with updated secrets
bun run deploy:dev:secrets
```
### Viewing Logs
```bash
# View logs from the deployed worker
bun run tail:dev
```
## Integration with Your Application
To integrate this worker with your existing application:
1. **Configure Cloudflare**:
- Set up a Cloudflare Worker route that points to your application domain
- Deploy this worker to that route
2. **Configure ZITADEL**:
- Create an application in ZITADEL
- Configure the redirect URI to `https://your-worker-domain/login/callback`
- Get the client ID and client secret
3. **Configure this Worker**:
- Update the environment variables with your ZITADEL credentials
- Set the `APP_URL` to your application's URL
- Set an http route in `wrangler.jsonc`
4. **Access Control**:
- The worker will automatically handle authentication
- Your application will receive authenticated requests with user information
- You can access user information via the `/api/whoami` endpoint
## Testing
The project uses Rust's built-in testing framework with tokio for async tests.
```bash
# Run all tests
cargo test
```
### Adding New Tests
1. For unit tests, add them to the `tests` module in the relevant source file
2. For async tests, use the `#[tokio::test]` attribute
3. Follow the existing pattern of testing both success and error cases
4. Mock external dependencies when necessary
## Debugging
1. For local development, use `console_log!` macros to output debug information
2. View logs in the wrangler development console
3. For deployed workers, use `bun run tail:dev` to stream logs
4. Check the `/api/whoami` endpoint to verify user authentication and session data
## Project Structure
- `src/` - Rust source code
- `api/` - API endpoints and routing
- `axum_introspector/` - Axum framework integration for token introspection
- `credentials/` - Credential management
- `oidc/` - OpenID Connect implementation
- `session_storage/` - Session storage implementations
- `utilities.rs` - Utility functions
- `lib.rs` - Main entry point and worker setup
## Contributing
Contributions to this project are welcome! Here are some guidelines:
1. **Fork the repository** and create your branch from `main`
2. **Install dependencies** and ensure you can build the project
3. **Make your changes** and add or update tests as necessary
4. **Ensure tests pass** by running `cargo test`
5. **Format your code** with `cargo fmt`
6. **Submit a pull request** with a clear description of your changes
### Code Style
- Follow Rust's standard code style and idioms
- Use `cargo fmt` to format code
- Use `cargo clippy` for linting
## Acknowledgements
This project is made possible thanks to:
- **ZITADEL**: For providing the robust identity management platform that powers this authentication proxy
- **Smartive**: For [zitadel-rs](https://github.com/smartive/zitadel-rust)
- **Cloudflare**: For their Workers platform and KV storage solution
- **Open Source Community**: For the various dependencies and tools that make this project possible:
- The Rust ecosystem and its crates
- The Axum web framework
- The Tower middleware ecosystem
- Various other open-source projects listed in our dependencies
I appreciate the hard work and dedication of all the developers and organizations that contribute to the open-source
ecosystem.
- **Open Source Community**: For the various dependencies and tools that make this project possible.
- [The Rust ecosystem](https://www.rust-lang.org/ecosystem) and its crates
- [ZITADEL](https://zitadel.com/): For providing the robust identity management platform that powers this authentication
proxy
- [Smartive](https://github.com/smartive): For [zitadel-rs](https://github.com/smartive/zitadel-rust)
- [Cloudflare](https://github.com/cloudflare): For their [Workers](https://workers.cloudflare.com/) platform and KV storage
solution
- [Fermyon/Spin](https://www.fermyon.com/spin): [http-auth-middleware](https://github.com/fermyon/http-auth-middleware) (Reference implementation)
- [The Axum web framework](https://github.com/tokio-rs/axum)
- [The Tower middleware ecosystem](https://github.com/tower-rs)
- Various other open-source projects listed in [Cargo.toml](./Cargo.toml)
## License

View File

@@ -1,2 +1,3 @@
pub mod public;
pub mod authenticated;
pub mod authenticated;
pub mod router;

188
src/api/router.rs Normal file
View File

@@ -0,0 +1,188 @@
use crate::api::authenticated::AuthenticatedApi;
use crate::api::public::PublicApi;
use crate::axum_introspector::introspection::{IntrospectionState, IntrospectionStateBuilder};
use crate::oidc::introspection::cache::in_memory::InMemoryIntrospectionCache;
use crate::session_storage::in_memory::MemoryStore;
use axum::response::{IntoResponse, Redirect};
use axum::routing::{any, get};
use axum::{Router, ServiceExt};
use http::HeaderName;
use std::iter::once;
use std::sync::Arc;
use tower_cookies::CookieManagerLayer;
use tower_http::cors::CorsLayer;
use tower_http::propagate_header::PropagateHeaderLayer;
use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
use tower_sessions::cookie::{Key, SameSite};
use tower_sessions::SessionManagerLayer;
use tower_sessions_core::Expiry;
// Test configuration struct
#[derive(Clone)]
pub struct TestConfig {
pub auth_server_url: String,
pub client_id: String,
pub client_secret: String,
pub app_url: String,
pub dev_mode: bool,
}
impl Default for TestConfig {
fn default() -> Self {
Self {
auth_server_url: "https://test-auth-server.example.com".to_string(),
client_id: "test-client-id".to_string(),
client_secret: "test-client-secret".to_string(),
app_url: "http://localhost:3000".to_string(),
dev_mode: true,
}
}
}
// App state for testing
#[derive(Clone)]
pub struct TestAppState {
pub introspection_state: IntrospectionState,
pub session_store: MemoryStore,
}
impl From<TestAppState> for IntrospectionState {
fn from(state: TestAppState) -> Self {
state.introspection_state
}
}
// Create a router for testing
pub async fn create_router(config: TestConfig) -> Router<TestAppState> {
// Create a memory-based introspection cache for testing
let cache = InMemoryIntrospectionCache::new();
// Create introspection state
let introspection_state = IntrospectionStateBuilder::new(&config.auth_server_url)
.with_basic_auth(&config.client_id, &config.client_secret)
.with_introspection_cache(cache)
.build()
.await
.unwrap();
// Create a memory-based session store for testing
let session_store = MemoryStore::default();
// Create app state
let state = TestAppState {
introspection_state,
session_store: session_store.clone(),
};
// Generate keys for session encryption and signing
let signing_key = Key::generate();
let encryption_key = Key::generate();
// Parse the app URL to get the host for cookies
let cookie_host_uri = config.app_url.parse::<http::Uri>().unwrap();
let mut cookie_host = cookie_host_uri.authority().unwrap().to_string();
if cookie_host.starts_with("localhost:") {
cookie_host = "localhost".to_string();
}
// Create session layer
let session_layer = SessionManagerLayer::new(session_store)
.with_name("session")
.with_expiry(Expiry::OnSessionEnd)
.with_domain(cookie_host)
.with_same_site(SameSite::Lax)
.with_signed(signing_key)
.with_private(encryption_key)
.with_path("/")
.with_secure(!config.dev_mode)
.with_always_save(false);
// Error handling middleware
async fn handle_introspection_errors(
mut response: axum_core::response::Response,
) -> axum_core::response::Response {
let x_error_header_value = response
.headers()
.get("x-introspection-error")
.and_then(|header_value| header_value.to_str().ok());
match response.status() {
http::StatusCode::UNAUTHORIZED => {
if let Some(x_error) = x_error_header_value {
if x_error == "unauthorized" {
return Redirect::to("/login").into_response();
}
}
response
}
http::StatusCode::BAD_REQUEST => {
if let Some(x_error) = x_error_header_value {
if x_error == "invalid schema"
|| x_error == "invalid header"
|| x_error == "introspection error"
{
return Redirect::to("/login").into_response();
}
}
response
}
http::StatusCode::FORBIDDEN => {
if let Some(x_error) = x_error_header_value {
if x_error == "user is inactive" {
return Redirect::to("/login").into_response();
}
}
response
}
http::StatusCode::NOT_FOUND => {
if let Some(x_error) = x_error_header_value {
if x_error == "user was not found" {
return Redirect::to("/login").into_response();
}
}
response
}
http::StatusCode::INTERNAL_SERVER_ERROR => {
if let Some(x_error) = x_error_header_value {
if x_error == "missing config" {
return Redirect::to("/login").into_response();
}
}
response
}
_ => response,
}
}
// Create the router with test-specific routes
Router::new()
.route("/api/whoami", get(whoami))
.route("/public", get(public_test_route))
.route("/protected", get(protected_test_route))
.layer(PropagateHeaderLayer::new(HeaderName::from_static(
"x-request-id",
)))
.layer(axum::middleware::map_response(handle_introspection_errors))
.with_state(state)
.layer(session_layer)
.layer(CookieManagerLayer::new())
.layer(CorsLayer::very_permissive())
.layer(SetSensitiveRequestHeadersLayer::new(once(
http::header::AUTHORIZATION,
)))
}
// Test routes
async fn whoami() -> impl IntoResponse {
"test user"
}
async fn public_test_route() -> impl IntoResponse {
"public route"
}
async fn protected_test_route() -> impl IntoResponse {
"protected route"
}

View File

@@ -0,0 +1,96 @@
use super::*;
use axum::http::Method;
#[tokio::test]
async fn test_auth_middleware_rejects_invalid_token() {
let app = test_app().await;
let (status, _) = make_request(
app,
Method::GET,
"/protected",
None,
Some(vec![("Authorization".to_string(), "Bearer invalid-token".to_string())]),
).await;
// Should redirect to login or return unauthorized
assert!(status == StatusCode::UNAUTHORIZED || status == StatusCode::FOUND);
}
#[tokio::test]
async fn test_auth_middleware_accepts_valid_token() {
let app = test_app().await;
// Create a valid token for testing
let token = create_test_token();
let (status, _) = make_request(
app,
Method::GET,
"/protected",
None,
Some(vec![("Authorization".to_string(), format!("Bearer {}", token))]),
).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_session_middleware_creates_session() {
let app = test_app().await;
let (status, headers) = make_request_with_response_headers(
app,
Method::GET,
"/login",
None,
None,
).await;
assert_eq!(status, StatusCode::OK);
// Check that a session cookie was set
let has_session_cookie = headers.iter()
.any(|(name, value)| name.to_lowercase() == "set-cookie" && value.contains("session="));
assert!(has_session_cookie);
}
#[tokio::test]
async fn test_error_handling_middleware_redirects_to_login() {
let app = test_app().await;
// Make a request that will trigger an unauthorized error with the specific header
let (status, _) = make_request(
app,
Method::GET,
"/protected",
None,
Some(vec![
("Authorization".to_string(), "Bearer invalid-token".to_string()),
("X-Introspection-Error".to_string(), "unauthorized".to_string()),
]),
).await;
// Should redirect to login
assert_eq!(status, StatusCode::FOUND);
}
#[tokio::test]
async fn test_cors_middleware() {
let app = test_app().await;
let (_, headers) = make_request_with_response_headers(
app,
Method::GET,
"/public",
None,
Some(vec![("Origin".to_string(), "http://example.com".to_string())]),
).await;
// Check that CORS headers were set
let has_cors_headers = headers.iter()
.any(|(name, _)| name.to_lowercase() == "access-control-allow-origin");
assert!(has_cors_headers);
}

126
src/api/tests/mod.rs Normal file
View File

@@ -0,0 +1,126 @@
use axum::{
body::Body,
http::{Request, StatusCode},
Router,
};
use tower::ServiceExt;
// Import your API router
use crate::api::router;
// Helper function to create a test app
async fn test_app() -> Router {
// Create a test configuration
let config = TestConfig::default();
// Create the router with test configuration
router::create_router(config).await
}
// Helper function to make a test request
async fn make_request(
app: Router,
method: http::Method,
uri: &str,
body: Option<String>,
headers: Option<Vec<(String, String)>>,
) -> (StatusCode, String) {
let mut req_builder = Request::builder()
.method(method)
.uri(uri);
// Add headers if provided
if let Some(headers) = headers {
for (name, value) in headers {
req_builder = req_builder.header(name, value);
}
}
// Add body if provided
let body = match body {
Some(b) => Body::from(b),
None => Body::empty(),
};
let req = req_builder.body(Body::from(body)).unwrap();
// Process the request
let response = app.oneshot(req).await.unwrap();
// Extract status code
let status = response.status();
// Extract body
let body = hyper::body::to_bytes(response.into_body())
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
(status, body)
}
// Helper function to make a request and return headers
async fn make_request_with_response_headers(
app: Router,
method: http::Method,
uri: &str,
body: Option<String>,
headers: Option<Vec<(String, String)>>,
) -> (StatusCode, Vec<(String, String)>) {
let mut req_builder = Request::builder()
.method(method)
.uri(uri);
// Add headers if provided
if let Some(headers) = headers {
for (name, value) in headers {
req_builder = req_builder.header(name, value);
}
}
// Add body if provided
let body = match body {
Some(b) => Body::from(b),
None => Body::empty(),
};
let req = req_builder.body(Body::from(body)).unwrap();
// Process the request
let response = app.oneshot(req).await.unwrap();
// Extract status code
let status = response.status();
// Extract headers
let headers = response.headers().iter()
.map(|(name, value)| (name.to_string(), value.to_str().unwrap_or("").to_string()))
.collect();
(status, headers)
}
// Helper function to create a test token
fn create_test_token() -> String {
// In a real implementation, this would create a valid JWT token
// For testing purposes, we can use a placeholder
"test-token".to_string()
}
// Helper struct for test configuration
#[derive(Clone)]
struct TestConfig {
// Add fields as needed for your tests
}
impl Default for TestConfig {
fn default() -> Self {
Self {
// Initialize with default values
}
}
}
// Export the test modules
pub mod routes;
pub mod middleware;

87
src/api/tests/routes.rs Normal file
View File

@@ -0,0 +1,87 @@
use super::*;
use axum::http::Method;
#[tokio::test]
async fn test_public_route_accessible() {
let app = test_app().await;
let (status, body) = make_request(
app,
Method::GET,
"/public",
None,
None,
).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "public route");
}
#[tokio::test]
async fn test_protected_route_requires_auth() {
let app = test_app().await;
let (status, _) = make_request(
app,
Method::GET,
"/protected",
None,
None,
).await;
// Should redirect to login or return unauthorized
assert!(status == StatusCode::UNAUTHORIZED || status == StatusCode::FOUND);
}
#[tokio::test]
async fn test_protected_route_with_valid_token() {
let app = test_app().await;
// Create a valid token for testing
let token = create_test_token();
let (status, body) = make_request(
app,
Method::GET,
"/protected",
None,
Some(vec![("Authorization".to_string(), format!("Bearer {}", token))]),
).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "protected route");
}
#[tokio::test]
async fn test_login_page_accessible() {
let app = test_app().await;
let (status, _) = make_request(
app,
Method::GET,
"/login",
None,
None,
).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_whoami_endpoint() {
let app = test_app().await;
// Create a valid token for testing
let token = create_test_token();
let (status, body) = make_request(
app,
Method::GET,
"/api/whoami",
None,
Some(vec![("Authorization".to_string(), format!("Bearer {}", token))]),
).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "test user");
}