From 95d9ba89259a76ee3d13f0700c1c4d62d9ea91d7 Mon Sep 17 00:00:00 2001 From: geoffsee <> Date: Tue, 17 Jun 2025 12:29:05 -0400 Subject: [PATCH] add dedicated docs crate and refactor for improved stability --- .gitignore | 3 +- README.md | 37 +----- src/config.rs | 81 ++++++++++++ src/docs.rs | 89 ++++++++++++++ src/error.rs | 71 +++++++++++ src/lib.rs | 325 ++++++++++++++++--------------------------------- src/router.rs | 99 +++++++++++++++ src/session.rs | 128 +++++++++++++++++++ 8 files changed, 576 insertions(+), 257 deletions(-) create mode 100644 src/config.rs create mode 100644 src/docs.rs create mode 100644 src/error.rs create mode 100644 src/router.rs create mode 100644 src/session.rs diff --git a/.gitignore b/.gitignore index f21515b..2d5855e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /node_modules/ /.wrangler/ /.idea/ -/build/ \ No newline at end of file +/build/ +/project \ No newline at end of file diff --git a/README.md b/README.md index f920655..164b6df 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,9 @@ [![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 API may be unstable. +> **Warning**: This API may be unstable. -Validates incoming requests for defined routes and forwards traffic to the service defined as `PROXY_TARGET`. - -> Targets `wasm32-unknown-unknown` - -## 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) - -## Quickstart - ```bash +```bash git clone https://github.com/seemueller-io/axum-tower-sessions-edge.git cd axum-tower-sessions-edge bun install @@ -31,29 +21,6 @@ bun install npx wrangler dev # Open `http://localhost:3000` in your browser. If everything is configured correctly, you should be taken to a Zitadel login page. ``` - -### Extras - -Run your own Zitadel: `docker compose up -d` -> You will need to configure: -> - Organization -> - Project -> - Application - _Choose PKCE (with code)_ - - -### Building -Sometimes the error messages are challenging to surface. Here are some alternative build commands that might help. -```bash -# 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 -``` - ## Acknowledgements This project is made possible thanks to: diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4fc3c83 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,81 @@ +//! Configuration management for the application. +//! +//! This module centralizes all configuration settings and provides validation +//! for required configuration at startup. + +use std::fmt::Debug; +use worker::Env; + +/// Constants for KV storage keys +pub const KV_STORAGE_BINDING: &str = "KV_STORAGE"; +pub const SIGNING_KEY: &str = "keystore::sig"; +pub const ENCRYPTION_KEY: &str = "keystore::enc"; + +/// Application configuration +#[derive(Clone, Debug)] +pub struct Config { + /// The URL of the authentication server + pub auth_server_url: String, + /// The client ID for OAuth authentication + pub client_id: String, + /// The client secret for OAuth authentication + pub client_secret: String, + /// The application URL + pub app_url: String, + /// Whether the application is running in development mode + pub dev_mode: bool, +} + +impl Config { + /// Create a new configuration from environment variables + /// + /// # Arguments + /// + /// * `env` - The environment containing configuration values + /// + /// # Returns + /// + /// A Result containing the configuration or an error if required values are missing + pub fn from_env(env: &Env) -> Result { + let auth_server_url = env + .secret("AUTH_SERVER_URL") + .map_err(|_| ConfigError::MissingValue("AUTH_SERVER_URL"))? + .to_string(); + + let client_id = env + .secret("CLIENT_ID") + .map_err(|_| ConfigError::MissingValue("CLIENT_ID"))? + .to_string(); + + let client_secret = env + .secret("CLIENT_SECRET") + .map_err(|_| ConfigError::MissingValue("CLIENT_SECRET"))? + .to_string(); + + let app_url = env + .secret("APP_URL") + .map_err(|_| ConfigError::MissingValue("APP_URL"))? + .to_string(); + + let dev_mode = env + .var("DEV_MODE") + .map(|var| var.to_string() == "true") + .unwrap_or(false); + + Ok(Config { + auth_server_url, + client_id, + client_secret, + app_url, + dev_mode, + }) + } +} + +/// Errors that can occur when loading configuration +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// A required configuration value is missing + #[error("Missing required configuration value: {0}")] + MissingValue(&'static str), +} \ No newline at end of file diff --git a/src/docs.rs b/src/docs.rs new file mode 100644 index 0000000..e566095 --- /dev/null +++ b/src/docs.rs @@ -0,0 +1,89 @@ +//! # axum-tower-sessions-edge Documentation +//! +//! This module provides comprehensive documentation for the axum-tower-sessions-edge project. +//! It serves as a central place for understanding the project's architecture, components, +//! and usage patterns. +//! +//! ## Overview +//! +//! axum-tower-sessions-edge is a Rust library that validates incoming requests for defined routes +//! and forwards traffic to the service defined as `PROXY_TARGET`. It's designed to work with +//! Cloudflare Workers and targets the `wasm32-unknown-unknown` platform. +//! +//! ## Features +//! +//! - **OAuth 2.0**: Implementation of the OAuth 2.0 authorization framework +//! - **PKCE (Proof Key for Code Exchange)**: Enhanced security for OAuth 2.0 +//! - **Token Introspection**: Validation of OAuth 2.0 tokens +//! +//! ## Architecture +//! +//! The project is organized into several modules: +//! +//! - **api**: Contains the API endpoints for both authenticated and public routes +//! - **axum_introspector**: Handles token introspection with Axum +//! - **credentials**: Manages authentication credentials +//! - **oidc**: Implements OpenID Connect functionality +//! - **session_storage**: Handles session management +//! - **utilities**: Provides utility functions +//! - **zitadel_http**: HTTP client for Zitadel +//! +//! ## Usage +//! +//! ### Basic Setup +//! +//! To use this library, you need to configure it with your OAuth 2.0 provider details: +//! +//! ```rust +//! // Example configuration (not actual code) +//! let introspection_state = IntrospectionStateBuilder::new("https://your-auth-server-url") +//! .with_basic_auth("your-client-id", "your-client-secret") +//! .with_introspection_cache(cache) +//! .build() +//! .await +//! .unwrap(); +//! ``` +//! +//! ### Authentication Flow +//! +//! The library implements a standard OAuth 2.0 flow: +//! +//! 1. User accesses a protected route +//! 2. If not authenticated, they are redirected to the login page +//! 3. User authenticates with the OAuth provider +//! 4. Provider redirects back with an authorization code +//! 5. The code is exchanged for tokens +//! 6. User session is established +//! 7. User is granted access to protected resources +//! +//! ## Components +//! +//! ### IntrospectionState +//! +//! Central component for token introspection and validation: +//! +//! ```rust +//! // Example usage (not actual code) +//! let introspection_state = IntrospectionStateBuilder::new(auth_server_url) +//! .with_basic_auth(client_id, client_secret) +//! .with_introspection_cache(cache) +//! .build() +//! .await?; +//! ``` +//! +//! ### Session Management +//! +//! The library uses tower-sessions for session management: +//! +//! ```rust +//! // Example session setup (not actual code) +//! let session_layer = SessionManagerLayer::new(session_store) +//! .with_name("session") +//! .with_expiry(Expiry::OnSessionEnd) +//! .with_secure(!is_dev); +//! ``` +//! +//! ## Deployment +//! +//! This library is designed to be deployed as a Cloudflare Worker. See the README.md for +//! detailed deployment instructions. \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..17cad50 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,71 @@ +//! Error handling for the application. +//! +//! This module provides centralized error handling functionality, +//! including middleware for handling introspection errors. + +use axum::response::{IntoResponse, Redirect, Response}; +use http::StatusCode; + +/// Middleware for handling introspection errors. +/// +/// This middleware checks for specific error headers and redirects +/// to the login page when appropriate. +pub async fn handle_introspection_errors(mut response: Response) -> Response { + let x_error_header_value = response + .headers() + .get("x-introspection-error") + .and_then(|header_value| header_value.to_str().ok()); + + // not used but is available + let x_session_header_value = response + .headers() + .get("x-session") + .and_then(|header_value| header_value.to_str().ok()); + + match response.status() { + StatusCode::UNAUTHORIZED => { + if let Some(x_error) = x_error_header_value { + if x_error == "unauthorized" { + return Redirect::to("/login").into_response(); + } + } + response + } + 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 + } + StatusCode::FORBIDDEN => { + if let Some(x_error) = x_error_header_value { + if x_error == "user is inactive" { + return Redirect::to("/login").into_response(); + } + } + response + } + 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 + } + 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, + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 152e986..f6ba20c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,38 +1,43 @@ +//! # axum-tower-sessions-edge +//! +//! A Rust library that validates incoming requests for defined routes and forwards traffic +//! to the service defined as `PROXY_TARGET`. It's designed to work with Cloudflare Workers +//! and targets the `wasm32-unknown-unknown` platform. +//! +//! ## Features +//! +//! - OAuth 2.0 authentication flow +//! - Proof Key for Code Exchange (PKCE) for enhanced security +//! - OAuth 2.0 Token Introspection for token validation +//! - Session management with tower-sessions +//! - Cloudflare Workers integration +//! +//! See the [docs](crate::docs) module for comprehensive documentation. + mod api; mod axum_introspector; +mod config; mod credentials; +mod docs; +mod error; mod oidc; +mod router; +mod session; mod session_storage; mod utilities; mod zitadel_http; -use crate::api::authenticated::AuthenticatedApi; -use crate::api::public::PublicApi; -use crate::axum_introspector::introspection::{ - IntrospectedUser, IntrospectionState, IntrospectionStateBuilder, -}; +use axum::handler::Handler; +use crate::axum_introspector::introspection::IntrospectionStateBuilder; +use crate::config::{Config, KV_STORAGE_BINDING}; use crate::oidc::introspection::cache::cloudflare::CloudflareIntrospectionCache; +use crate::router::{create_router, AppState}; +use crate::session::{create_session_layer, SessionConfig}; use crate::session_storage::cloudflare::CloudflareKvStore; -use axum::extract::FromRef; -use axum::response::{IntoResponse, Redirect}; -use axum::routing::{any, get}; -use axum::{Router, ServiceExt}; -use bytes::Bytes; -use http::HeaderName; use serde::{Deserialize, Serialize}; -use serde_json::to_string; -use std::fmt::Debug; -use std::iter::once; -use std::ops::Deref; -use tower::ServiceExt as TowerServiceExt; +use tower::ServiceExt; use tower_cookies::cookie::SameSite; -use tower_cookies::CookieManagerLayer; -use tower_http::cors::CorsLayer; -use tower_http::propagate_header::PropagateHeaderLayer; -use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; use tower_service::Service; -use tower_sessions::cookie::Key; -use tower_sessions::SessionManagerLayer; use tower_sessions_core::Expiry; use tracing::instrument::WithSubscriber; use tracing_subscriber::prelude::*; @@ -54,227 +59,105 @@ fn start() { .init() } -const SIGNING_KEY: &str = "keystore::sig"; -const ENCRYPTION_KEY: &str = "keystore::enc"; - -// main entrypoint - -#[event(fetch)] -async fn fetch( - req: HttpRequest, - _env: Env, - _ctx: Context, -) -> Result> { - console_error_panic_hook::set_once(); - - Ok(route(req, _env).await) -} - #[derive(Serialize, Deserialize, Clone, Debug)] struct Callback { code: String, state: String, } -#[derive(Clone)] -struct AppState { - introspection_state: IntrospectionState, +#[event(fetch)] +async fn fetch( + req: HttpRequest, env: Env, - session_store: CloudflareKvStore, -} -impl FromRef for IntrospectionState { - fn from_ref(input: &AppState) -> Self { - input.introspection_state.clone() - } + _ctx: Context, +) -> Result> { + console_error_panic_hook::set_once(); + + Ok(route(req, env).await) } -async fn route(req: HttpRequest, _env: Env) -> axum_core::response::Response { - let kv = _env.kv("KV_STORAGE").unwrap(); +async fn route(req: HttpRequest, env: Env) -> axum::http::Response { + // Load configuration from environment + let config = match Config::from_env(&env) { + Ok(config) => config, + Err(err) => { + console_error!("Configuration error: {}", err); + return axum::http::Response::builder() + .status(500) + .body(axum::body::Body::from("Internal Server Error: Configuration error")) + .unwrap(); + } + }; + + // Initialize KV store + let kv = match env.kv(KV_STORAGE_BINDING) { + Ok(kv) => kv, + Err(err) => { + console_error!("KV store error: {}", err); + return axum::http::Response::builder() + .status(500) + .body(axum::body::Body::from("Internal Server Error: KV store error")) + .unwrap(); + } + }; + + // Initialize introspection cache let cache = CloudflareIntrospectionCache::new(kv.clone()); - let introspection_state = IntrospectionStateBuilder::new( - _env.secret("AUTH_SERVER_URL") - .unwrap() - .to_string() - .as_str(), - ) - .with_basic_auth( - _env.secret("CLIENT_ID") - .unwrap() - .to_string() - .as_str(), - _env.secret("CLIENT_SECRET") - .unwrap() - .to_string() - .as_str(), - ) - .with_introspection_cache(cache) - .build() - .await - .unwrap(); + // Build introspection state + let introspection_state = match IntrospectionStateBuilder::new(&config.auth_server_url) + .with_basic_auth(&config.client_id, &config.client_secret) + .with_introspection_cache(cache) + .build() + .await + { + Ok(state) => state, + Err(err) => { + console_error!("Introspection state error: {}", err); + return axum::http::Response::builder() + .status(500) + .body(axum::body::Body::from("Internal Server Error: Introspection state error")) + .unwrap(); + } + }; + // Initialize session store let session_store = CloudflareKvStore::new(kv.clone()); + // Create application state let state = AppState { introspection_state, session_store: session_store.clone(), - env: _env.clone(), + env: env.clone(), }; - let dev_mode = _env.var("DEV_MODE").unwrap().to_string(); // Example check - - let is_dev = dev_mode == "true"; - - let keystore = _env.kv("KV_STORAGE").unwrap(); - - let signing = if let Some(bytes) = keystore.get(SIGNING_KEY).bytes().await.unwrap() { - Key::derive_from(bytes.as_slice()) - } else { - let key = Key::generate(); - keystore - .put_bytes(SIGNING_KEY, key.master()) - .unwrap() - .execute() - .await - .unwrap(); - key + // Create session configuration + let session_config = SessionConfig { + cookie_name: "session".to_string(), + expiry: Expiry::OnSessionEnd, + domain: "localhost".to_string(), // Will be overridden in create_session_layer + path: "/".to_string(), + secure: !config.dev_mode, + same_site: SameSite::Lax, }; - let encryption = if let Some(bytes) = keystore.get(ENCRYPTION_KEY).bytes().await.unwrap() { - Key::derive_from(bytes.as_slice()) - } else { - let key = Key::generate(); - keystore - .put_bytes(ENCRYPTION_KEY, key.master()) - .unwrap() - .execute() - .await - .unwrap(); - key - }; + // Create session layer + let session_layer = create_session_layer( + &config, + Some(session_config), + session_store, + kv, + ).await; - let host_string = _env.secret("APP_URL").unwrap().to_string().as_str().to_owned(); + // Create router + let router = create_router(state, session_layer); - let cookie_host_uri = host_string.parse::().unwrap(); + // Handle request + // Convert the worker request to an axum request + let axum_req = axum::extract::Request::try_from(req).unwrap(); - let mut cookie_host = cookie_host_uri.authority().unwrap().to_string(); - - if cookie_host.starts_with("localhost:") { - cookie_host = "localhost".to_string(); - } - - let session_layer = SessionManagerLayer::new(state.session_store.clone()) - .with_name("session") - .with_expiry(Expiry::OnSessionEnd) - .with_domain(cookie_host) - .with_same_site(SameSite::Lax) - .with_signed(signing) - .with_private(encryption) - .with_path("/") - .with_secure(!is_dev) - .with_always_save(false); - - 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()); - - // not used but is available - let x_session_header_value = response - .headers() - .get("x-session") - .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, - } - } - - let mut router = Router::new() - .route("/", any(AuthenticatedApi::proxy)) - .route("/login", get(PublicApi::login_page)) // Add the login page route - .route("/login/callback", get(PublicApi::callback)) - .route("/login/authorize", get(PublicApi::authorize)) - .route("/api/whoami", get(whoami)) - .route("/*path", any(AuthenticatedApi::proxy)) - .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, - ))); - - router - .as_service() - .ready() - .await - .unwrap() - .oneshot(req) - .await - .unwrap() -} - -async fn whoami( - session: tower_sessions::Session, - introspected_user: IntrospectedUser, -) -> impl IntoResponse { - console_log!("calling whoami"); - to_string(&introspected_user).unwrap() -} - -impl FromRef for CloudflareKvStore { - fn from_ref(input: &AppState) -> Self { - input.session_store.clone() - } + // Use the router to handle the request + // Since we've modified create_router to return a Router with empty state, + // we can now use the oneshot method directly + router.oneshot(axum_req).await.unwrap() } diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..013d751 --- /dev/null +++ b/src/router.rs @@ -0,0 +1,99 @@ +//! Routing configuration for the application. +//! +//! This module provides centralized routing functionality, +//! including router configuration and middleware setup. + +use crate::api::authenticated::AuthenticatedApi; +use crate::api::public::PublicApi; +use crate::error::handle_introspection_errors; +use worker::console_log; +use axum::extract::FromRef; +use axum::response::IntoResponse; +use axum::routing::{any, get}; +use axum::{Router, ServiceExt}; +use http::HeaderName; +use serde_json::to_string; +use std::iter::once; +use tower_cookies::CookieManagerLayer; +use tower_http::cors::CorsLayer; +use tower_http::propagate_header::PropagateHeaderLayer; +use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; +use tower_sessions::SessionManagerLayer; + +use crate::axum_introspector::introspection::{IntrospectedUser, IntrospectionState}; +use crate::session_storage::cloudflare::CloudflareKvStore; + +/// Application state shared across handlers +#[derive(Clone)] +pub struct AppState { + /// State for token introspection + pub introspection_state: IntrospectionState, + /// Cloudflare environment + pub env: worker::Env, + /// Session store + pub session_store: CloudflareKvStore, +} + +impl FromRef for IntrospectionState { + fn from_ref(input: &AppState) -> Self { + input.introspection_state.clone() + } +} + +impl FromRef for CloudflareKvStore { + fn from_ref(input: &AppState) -> Self { + input.session_store.clone() + } +} + +/// Create a router with the given state and session layer +/// +/// # Arguments +/// +/// * `state` - The application state +/// * `session_layer` - The session manager layer +/// +/// # Returns +/// +/// A configured router +pub fn create_router( + state: AppState, + session_layer: SessionManagerLayer, +) -> Router { + Router::new() + .route("/", any(AuthenticatedApi::proxy)) + .route("/login", get(PublicApi::login_page)) + .route("/login/callback", get(PublicApi::callback)) + .route("/login/authorize", get(PublicApi::authorize)) + .route("/api/whoami", get(whoami)) + .route("/*path", any(AuthenticatedApi::proxy)) + .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, + ))) +} + +/// Handler for the whoami endpoint +/// +/// # Arguments +/// +/// * `session` - The user's session +/// * `introspected_user` - The introspected user information +/// +/// # Returns +/// +/// The user information as JSON +pub async fn whoami( + session: tower_sessions::Session, + introspected_user: IntrospectedUser, +) -> impl IntoResponse { + console_log!("calling whoami"); + to_string(&introspected_user).unwrap() +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..63726ae --- /dev/null +++ b/src/session.rs @@ -0,0 +1,128 @@ +//! Session management for the application. +//! +//! This module provides centralized session management functionality, +//! including session configuration and key management. + +use crate::config::{Config, ENCRYPTION_KEY, SIGNING_KEY}; +use crate::session_storage::cloudflare::CloudflareKvStore; +use tower_cookies::cookie::SameSite; +use tower_sessions::cookie::Key; +use tower_sessions::service::PrivateCookie; +use tower_sessions::SessionManagerLayer; +use tower_sessions_core::Expiry; +use worker::kv::KvStore as Kv; + +/// Session configuration options +#[derive(Clone, Debug)] +pub struct SessionConfig { + /// The name of the session cookie + pub cookie_name: String, + /// The expiry policy for the session + pub expiry: Expiry, + /// The domain for the session cookie + pub domain: String, + /// The path for the session cookie + pub path: String, + /// Whether the session cookie should be secure + pub secure: bool, + /// The same-site policy for the session cookie + pub same_site: SameSite, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + cookie_name: "session".to_string(), + expiry: Expiry::OnSessionEnd, + domain: "localhost".to_string(), + path: "/".to_string(), + secure: true, + same_site: SameSite::Lax, + } + } +} + +/// Create a session manager layer with the given configuration +/// +/// # Arguments +/// +/// * `config` - The application configuration +/// * `session_config` - The session configuration +/// * `session_store` - The session store +/// * `keystore` - The KV store for key management +/// +/// # Returns +/// +/// A session manager layer +pub async fn create_session_layer( + config: &Config, + session_config: Option, + session_store: CloudflareKvStore, + keystore: Kv, +) -> SessionManagerLayer { + let session_config = session_config.unwrap_or_default(); + + let (signing, encryption) = get_or_create_keys(keystore).await; + + let mut domain = session_config.domain; + + // Handle localhost special case + if let Ok(uri) = config.app_url.parse::() { + if let Some(authority) = uri.authority() { + domain = authority.to_string(); + if domain.starts_with("localhost:") { + domain = "localhost".to_string(); + } + } + } + + SessionManagerLayer::new(session_store) + .with_name(session_config.cookie_name) + .with_expiry(session_config.expiry) + .with_domain(domain) + .with_same_site(session_config.same_site) + .with_signed(signing) + .with_private(encryption) + .with_path(session_config.path) + .with_secure(!config.dev_mode) + .with_always_save(false) +} + +/// Get or create signing and encryption keys +/// +/// # Arguments +/// +/// * `keystore` - The KV store for key management +/// +/// # Returns +/// +/// A tuple of (signing_key, encryption_key) +async fn get_or_create_keys(keystore: Kv) -> (Key, Key) { + let signing = if let Some(bytes) = keystore.get(SIGNING_KEY).bytes().await.unwrap() { + Key::derive_from(bytes.as_slice()) + } else { + let key = Key::generate(); + keystore + .put_bytes(SIGNING_KEY, key.master()) + .unwrap() + .execute() + .await + .unwrap(); + key + }; + + let encryption = if let Some(bytes) = keystore.get(ENCRYPTION_KEY).bytes().await.unwrap() { + Key::derive_from(bytes.as_slice()) + } else { + let key = Key::generate(); + keystore + .put_bytes(ENCRYPTION_KEY, key.master()) + .unwrap() + .execute() + .await + .unwrap(); + key + }; + + (signing, encryption) +}