From 690425e1443a480eee5a2ac18fe37882bab5d389 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 5 Mar 2026 01:49:37 +0100 Subject: [PATCH] feat: update configuration and README for improved authentication and database support --- .env.example | 62 +++++++------------- Cargo.lock | 128 +---------------------------------------- README.md | 148 +++++++++++++++++++++++++++++------------------- api/src/main.rs | 18 +++++- 4 files changed, 131 insertions(+), 225 deletions(-) diff --git a/.env.example b/.env.example index c7e1668..a01c603 100644 --- a/.env.example +++ b/.env.example @@ -2,82 +2,64 @@ # K-Template Configuration # ============================================================================ # Copy this file to .env and adjust values for your environment. -# All values shown are defaults or examples. # ============================================================================ -# Server Configuration +# Server # ============================================================================ HOST=127.0.0.1 PORT=3000 # ============================================================================ -# Database Configuration +# Database # ============================================================================ # SQLite (default) DATABASE_URL=sqlite:data.db?mode=rwc -# PostgreSQL (alternative - requires postgres feature) +# PostgreSQL (requires postgres feature flag) # DATABASE_URL=postgres://user:password@localhost:5432/mydb -# Connection pool settings DB_MAX_CONNECTIONS=5 DB_MIN_CONNECTIONS=1 # ============================================================================ -# Authentication Mode +# Cookie Secret # ============================================================================ -# Options: session, jwt, both -# - session: Cookie-based sessions (requires auth-axum-login feature) -# - jwt: Bearer token authentication (requires auth-jwt feature) -# - both: Support both methods (try JWT first, fall back to session) -AUTH_MODE=jwt +# Used to encrypt the OIDC state cookie (CSRF token, PKCE verifier, nonce). +# Must be at least 64 characters in production. +COOKIE_SECRET=your-cookie-secret-key-must-be-at-least-64-characters-long-for-security!! -# ============================================================================ -# Session Configuration (for session/both modes) -# ============================================================================ -# Must be at least 64 characters in production -SESSION_SECRET=your-super-secret-key-must-be-at-least-64-characters-long-for-security - -# Set to true in production for HTTPS-only cookies +# Set to true when serving over HTTPS SECURE_COOKIE=false # ============================================================================ -# JWT Configuration (for jwt/both modes) +# JWT # ============================================================================ -# Must be at least 32 characters in production +# Must be at least 32 characters in production. JWT_SECRET=your-jwt-secret-key-at-least-32-chars -# Optional: JWT issuer and audience for token validation -JWT_ISSUER=your-app-name -JWT_AUDIENCE=your-app-audience +# Optional: embed issuer/audience claims in tokens +# JWT_ISSUER=your-app-name +# JWT_AUDIENCE=your-app-audience -# Token expiry in hours (default: 24) +# Token lifetime in hours (default: 24) JWT_EXPIRY_HOURS=24 # ============================================================================ -# OIDC Configuration (optional - requires auth-oidc feature) +# OIDC (optional — requires auth-oidc feature flag) # ============================================================================ -# Your OIDC provider's issuer URL (e.g., Keycloak, Auth0, Zitadel) -OIDC_ISSUER=https://your-oidc-provider.com - -# Client credentials from your OIDC provider -OIDC_CLIENT_ID=your-client-id -OIDC_CLIENT_SECRET=your-client-secret - -# Callback URL (must match what's configured in your OIDC provider) -OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback - -# Optional: Resource ID for audience verification -# OIDC_RESOURCE_ID=your-resource-id +# OIDC_ISSUER=https://your-oidc-provider.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback +# OIDC_RESOURCE_ID=your-resource-id # optional audience claim to verify # ============================================================================ -# CORS Configuration +# CORS # ============================================================================ -# Comma-separated list of allowed origins CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 # ============================================================================ # Production Mode # ============================================================================ -# Set to true/production/1 to enable production checks (secret length, etc.) +# Set to true/production/1 to enforce minimum secret lengths and other checks. PRODUCTION=false diff --git a/Cargo.lock b/Cargo.lock index 5c86870..28f0b4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,20 +803,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -1395,8 +1381,8 @@ dependencies = [ [[package]] name = "k-core" -version = "0.1.10" -source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#7a72f5f54ad45ba82f451e90c44c0581d13194d9" +version = "0.1.11" +source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#0ea9aa7870d73b5f665241a4183ffd899e628b9c" dependencies = [ "anyhow", "async-nats", @@ -1408,12 +1394,9 @@ dependencies = [ "serde", "sqlx", "thiserror 2.0.17", - "time", "tokio", "tower", "tower-http", - "tower-sessions", - "tower-sessions-sqlx-store", "tracing", "tracing-subscriber", "uuid", @@ -1475,7 +1458,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", - "serde", ] [[package]] @@ -1639,7 +1621,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.16", "http", @@ -2189,25 +2171,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - [[package]] name = "rsa" version = "0.9.9" @@ -2707,7 +2670,6 @@ dependencies = [ "sha2", "smallvec", "thiserror 2.0.17", - "time", "tokio", "tokio-stream", "tracing", @@ -2792,7 +2754,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.17", - "time", "tracing", "uuid", "whoami", @@ -2832,7 +2793,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.17", - "time", "tracing", "uuid", "whoami", @@ -2859,7 +2819,6 @@ dependencies = [ "serde_urlencoded", "sqlx-core", "thiserror 2.0.17", - "time", "tracing", "url", "uuid", @@ -3129,22 +3088,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" -dependencies = [ - "axum-core", - "cookie", - "futures-util", - "http", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.6.8" @@ -3176,71 +3119,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-sessions" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" -dependencies = [ - "async-trait", - "http", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core", - "tower-sessions-memory-store", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" -dependencies = [ - "async-trait", - "axum-core", - "base64 0.22.1", - "futures", - "http", - "parking_lot", - "rand 0.8.5", - "serde", - "serde_json", - "thiserror 2.0.17", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-memory-store" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" -dependencies = [ - "async-trait", - "time", - "tokio", - "tower-sessions-core", -] - -[[package]] -name = "tower-sessions-sqlx-store" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3" -dependencies = [ - "async-trait", - "rmp-serde", - "sqlx", - "thiserror 1.0.69", - "time", - "tower-sessions-core", -] - [[package]] name = "tracing" version = "0.1.44" diff --git a/README.md b/README.md index c0afebb..fd6487f 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ A production-ready, modular Rust API template for K-Suite applications, followin ## Features - **Hexagonal Architecture**: Clear separation between Domain, Infrastructure, and API layers -- **Multiple Auth Modes**: Session-based, JWT, or both - fully configurable -- **OIDC Integration**: Connect to any OpenID Connect provider (Keycloak, Auth0, Zitadel, etc.) +- **JWT-Only Authentication**: Stateless Bearer token auth — no server-side sessions +- **OIDC Integration**: Connect to any OpenID Connect provider (Keycloak, Auth0, Zitadel, etc.) with stateless cookie-based flow state - **Database Flexibility**: SQLite (default) or PostgreSQL via feature flags -- **Type-Safe Configuration**: Newtypes with built-in validation for all security-sensitive values +- **Type-Safe Domain**: Newtypes with built-in validation for emails, passwords, secrets, and OIDC values - **Cargo Generate Ready**: Pre-configured for scaffolding new services ## Quick Start @@ -22,7 +22,6 @@ cargo generate --git https://github.com/GKaszewski/k-template.git You'll be prompted to choose: - **Project name**: Your new service name - **Database**: `sqlite` or `postgres` -- **Session auth**: Enable cookie-based sessions - **JWT auth**: Enable Bearer token authentication - **OIDC**: Enable OpenID Connect integration @@ -33,15 +32,6 @@ git clone https://github.com/GKaszewski/k-template.git my-api cd my-api cp .env.example .env # Edit .env with your configuration -``` - -### Run - -```bash -# Development (with hot reload via cargo-watch) -cargo watch -x run - -# Or simply cargo run ``` @@ -51,51 +41,51 @@ The API will be available at `http://localhost:3000/api/v1/`. All configuration is done via environment variables. See [.env.example](.env.example) for all options. -### Authentication Modes +### Key Variables -Set `AUTH_MODE` to one of: - -| Mode | Description | Required Features | -|------|-------------|-------------------| -| `session` | Cookie-based sessions | `auth-axum-login` | -| `jwt` | Bearer token authentication | `auth-jwt` | -| `both` | Try JWT first, fall back to session | `auth-axum-login`, `auth-jwt` | +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `sqlite:data.db?mode=rwc` | Database connection string | +| `COOKIE_SECRET` | *(insecure dev default)* | Secret for encrypting OIDC state cookie (≥64 bytes in production) | +| `JWT_SECRET` | *(insecure dev default)* | Secret for signing JWT tokens (≥32 bytes in production) | +| `JWT_EXPIRY_HOURS` | `24` | Token lifetime in hours | +| `CORS_ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated allowed origins | +| `SECURE_COOKIE` | `false` | Set `true` when serving over HTTPS | +| `PRODUCTION` | `false` | Enforces minimum secret lengths | ### OIDC Integration -To enable OIDC login (e.g., "Login with Google"): +To enable "Login with Google/Keycloak/etc.": -1. Enable the `auth-oidc` feature (enabled by default) -2. Configure your OIDC provider in `.env`: +1. Enable the `auth-oidc` feature (on by default in cargo-generate) +2. Set these environment variables: ```env OIDC_ISSUER=https://your-provider.com OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-secret OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback ``` -3. Users can login via `GET /api/v1/auth/login/oidc` +3. Users start the flow at `GET /api/v1/auth/login/oidc` + +OIDC state (CSRF token, PKCE verifier, nonce) is stored in a short-lived encrypted cookie — no database session table required. ## Feature Flags -Features are configured in `api/Cargo.toml`: - ```toml [features] -default = ["sqlite", "auth-axum-login", "auth-oidc", "auth-jwt"] +default = ["sqlite", "auth-jwt"] ``` | Feature | Description | |---------|-------------| -| `sqlite` | SQLite database support (default) | -| `postgres` | PostgreSQL database support | -| `auth-axum-login` | Session-based authentication | +| `sqlite` | SQLite database (default) | +| `postgres` | PostgreSQL database | +| `auth-jwt` | JWT Bearer token authentication | | `auth-oidc` | OpenID Connect integration | -| `auth-jwt` | JWT token authentication | -| `auth-full` | All auth features combined | ### Common Configurations -**JWT-only API (stateless)**: +**JWT-only (minimal, default)**: ```toml default = ["sqlite", "auth-jwt"] ``` @@ -105,48 +95,74 @@ default = ["sqlite", "auth-jwt"] default = ["sqlite", "auth-oidc", "auth-jwt"] ``` -**Full-featured (all auth methods)**: +**PostgreSQL + OIDC + JWT**: ```toml -default = ["sqlite", "auth-full"] +default = ["postgres", "auth-oidc", "auth-jwt"] ``` ## API Endpoints ### Authentication -| Method | Endpoint | Description | -|--------|----------|-------------| -| `POST` | `/api/v1/auth/login` | Login with email/password | -| `POST` | `/api/v1/auth/register` | Register new user | -| `POST` | `/api/v1/auth/logout` | Logout (session mode) | -| `GET` | `/api/v1/auth/me` | Get current user | -| `POST` | `/api/v1/auth/token` | Get JWT for session user | -| `GET` | `/api/v1/auth/login/oidc` | Start OIDC login flow | -| `GET` | `/api/v1/auth/callback` | OIDC callback | +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| `POST` | `/api/v1/auth/register` | — | Register with email + password → JWT | +| `POST` | `/api/v1/auth/login` | — | Login with email + password → JWT | +| `POST` | `/api/v1/auth/logout` | — | Returns 200; client drops the token | +| `GET` | `/api/v1/auth/me` | Bearer | Current user info | +| `POST` | `/api/v1/auth/token` | Bearer | Issue a fresh JWT (`auth-jwt`) | +| `GET` | `/api/v1/auth/login/oidc` | — | Start OIDC flow, sets encrypted state cookie (`auth-oidc`) | +| `GET` | `/api/v1/auth/callback` | — | Complete OIDC flow → JWT, clears cookie (`auth-oidc`) | + +### Example: Register and use a token + +```bash +# Register +curl -X POST http://localhost:3000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "mypassword"}' +# → {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 86400} + +# Use the token +curl http://localhost:3000/api/v1/auth/me \ + -H "Authorization: Bearer eyJ..." +``` ## Project Structure ``` k-template/ -├── domain/ # Core business logic (no I/O dependencies) +├── domain/ # Pure business logic — zero I/O dependencies │ └── src/ │ ├── entities.rs # User entity -│ ├── value_objects.rs # Email, Password, OIDC newtypes +│ ├── value_objects.rs # Email, Password, JwtSecret, OIDC newtypes │ ├── repositories.rs # Repository interfaces (ports) -│ └── services.rs # Domain services +│ ├── services.rs # Domain services +│ └── errors.rs # DomainError (Unauthenticated 401, Forbidden 403, …) │ ├── infra/ # Infrastructure adapters │ └── src/ -│ ├── auth/ # Auth backends (OIDC, JWT) -│ └── user_repository.rs +│ ├── auth/ +│ │ ├── jwt.rs # JwtValidator — create + verify tokens +│ │ └── oidc.rs # OidcService + OidcState (cookie-serializable) +│ ├── user_repository.rs # SQLite / PostgreSQL adapter +│ ├── db.rs # DatabasePool re-export +│ └── factory.rs # build_user_repository() │ -├── api/ # HTTP API layer +├── api/ # HTTP layer │ └── src/ -│ ├── routes/ # API endpoints -│ ├── config.rs # Configuration -│ └── state.rs # Application state +│ ├── routes/ +│ │ ├── auth.rs # Login, register, logout, me, OIDC flow +│ │ └── config.rs # /config endpoint +│ ├── config.rs # Config::from_env() +│ ├── state.rs # AppState (user_service, cookie_key, jwt_validator, …) +│ ├── extractors.rs # CurrentUser (JWT Bearer extractor) +│ ├── error.rs # ApiError → HTTP status mapping +│ └── dto.rs # LoginRequest, RegisterRequest, TokenResponse, … │ -├── .env.example # Configuration template +├── migrations_sqlite/ +├── migrations_postgres/ +├── .env.example └── compose.yml # Docker Compose for local dev ``` @@ -156,10 +172,13 @@ k-template/ ```bash # All tests -cargo test --all-features +cargo test -# Domain tests only +# Domain only cargo test -p domain + +# Infra only (SQLite integration tests) +cargo test -p infra ``` ### Database Migrations @@ -168,10 +187,23 @@ cargo test -p domain # SQLite sqlx migrate run --source migrations_sqlite -# PostgreSQL +# PostgreSQL sqlx migrate run --source migrations_postgres ``` +### Building with specific features + +```bash +# Minimal: SQLite + JWT only +cargo build -F sqlite,auth-jwt + +# Full: SQLite + JWT + OIDC +cargo build -F sqlite,auth-jwt,auth-oidc + +# PostgreSQL variant +cargo build --no-default-features -F postgres,auth-jwt,auth-oidc +``` + ## License MIT diff --git a/api/src/main.rs b/api/src/main.rs index 4a06b50..563dfeb 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -34,7 +34,23 @@ async fn main() -> anyhow::Result<()> { // Setup database tracing::info!("Connecting to database: {}", config.database_url); + + #[cfg(all(feature = "sqlite", not(feature = "postgres")))] + let db_type = k_core::db::DbType::Sqlite; + + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let db_type = k_core::db::DbType::Postgres; + + // Both features enabled: fall back to URL inspection at runtime + #[cfg(all(feature = "sqlite", feature = "postgres"))] + let db_type = if config.database_url.starts_with("postgres") { + k_core::db::DbType::Postgres + } else { + k_core::db::DbType::Sqlite + }; + let db_config = k_core::db::DatabaseConfig { + db_type, url: config.database_url.clone(), max_connections: config.db_max_connections, min_connections: config.db_min_connections, @@ -51,8 +67,6 @@ async fn main() -> anyhow::Result<()> { let server_config = ServerConfig { cors_origins: config.cors_allowed_origins.clone(), - // session_secret is unused (sessions removed); kept for k-core API compat - session_secret: None, }; let app = Router::new()