From d7bdd51164d740fb22dd5c9f570a43e44b853cd1 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Sun, 7 Dec 2025 13:18:22 +0100 Subject: [PATCH] WIP Passkey implementation. Needs fixing storage.rs and more tests Signed-off-by: Till Wegmueller --- .claude/settings.local.json | 11 +- CLAUDE.md | 316 ++++- Cargo.lock | 449 +++++++ Cargo.toml | 22 + client-wasm/Cargo.toml | 32 + client-wasm/src/lib.rs | 287 +++++ config.toml | 2 + migration/src/lib.rs | 8 +- .../src/m20250107_000001_add_passkeys.rs | 139 +++ .../m20250107_000002_extend_sessions_users.rs | 137 +++ src/admin_mutations.rs | 109 +- src/entities/mod.rs | 4 + src/entities/passkey.rs | 24 + src/entities/session.rs | 3 + src/entities/user.rs | 2 + src/entities/webauthn_challenge.rs | 20 + src/errors.rs | 14 + src/jobs.rs | 242 +++- src/jwks.rs | 216 ++++ src/lib.rs | 17 + src/main.rs | 30 +- src/settings.rs | 164 +++ src/storage.rs | 702 +++++++++++ src/web.rs | 1067 ++++++++++++++++- src/webauthn_manager.rs | 82 ++ tests/helpers/builders.rs | 263 ++++ tests/helpers/db.rs | 67 ++ tests/helpers/mock_webauthn.rs | 87 ++ tests/helpers/mod.rs | 7 + tests/integration_test.rs | 3 +- 30 files changed, 4480 insertions(+), 46 deletions(-) create mode 100644 client-wasm/Cargo.toml create mode 100644 client-wasm/src/lib.rs create mode 100644 migration/src/m20250107_000001_add_passkeys.rs create mode 100644 migration/src/m20250107_000002_extend_sessions_users.rs create mode 100644 src/entities/passkey.rs create mode 100644 src/entities/webauthn_challenge.rs create mode 100644 src/lib.rs create mode 100644 src/webauthn_manager.rs create mode 100644 tests/helpers/builders.rs create mode 100644 tests/helpers/db.rs create mode 100644 tests/helpers/mock_webauthn.rs create mode 100644 tests/helpers/mod.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ce61f6a..558a8c3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,16 @@ "Bash(gh run view:*)", "Bash(cargo fmt:*)", "Bash(cargo clippy:*)", - "Bash(rm:*)" + "Bash(rm:*)", + "WebSearch", + "Bash(cargo check:*)", + "Bash(cat:*)", + "Bash(cargo doc:*)", + "Bash(grep:*)", + "Bash(cargo run:*)", + "Bash(wasm-pack build:*)", + "Bash(find:*)", + "Bash(wc:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index 9eac3e4..ec2aee9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,15 +134,35 @@ Implements OpenID Connect and OAuth 2.0 endpoints: **OAuth/OIDC Flow:** - `GET /authorize` - Authorization endpoint (issues authorization code with PKCE) - - Currently uses fixed subject "demo-user" (pending login flow implementation per docs/next-iteration-plan.md) - Validates client_id, redirect_uri, scope (must include "openid"), PKCE S256 + - Checks 2FA requirements (admin-enforced, high-value scopes, max_age) + - Redirects to /login or /login/2fa if authentication needed - Returns redirect with code and state - `POST /token` - Token endpoint (exchanges code for tokens) - Supports `client_secret_basic` (Authorization header) and `client_secret_post` (form body) - Validates PKCE S256 code_verifier - - Returns access_token, id_token (JWT), token_type, expires_in + - Returns access_token, id_token (JWT with AMR/ACR claims), token_type, expires_in - `GET /userinfo` - UserInfo endpoint (returns claims for Bearer token) +**Authentication:** +- `GET /login` - Login page with passkey autofill and password fallback +- `POST /login` - Password authentication, checks 2FA requirements +- `GET /login/2fa` - Two-factor authentication page +- `POST /logout` - End user session + +**Passkey/WebAuthn Endpoints:** +- `POST /webauthn/register/start` - Start passkey registration (requires session) +- `POST /webauthn/register/finish` - Complete passkey registration +- `POST /webauthn/authenticate/start` - Start passkey authentication (public) +- `POST /webauthn/authenticate/finish` - Complete passkey authentication +- `POST /webauthn/2fa/start` - Start 2FA passkey verification (requires partial session) +- `POST /webauthn/2fa/finish` - Complete 2FA passkey verification + +**Passkey Management:** +- `GET /account/passkeys` - List user's registered passkeys +- `DELETE /account/passkeys/:credential_id` - Delete a passkey +- `PATCH /account/passkeys/:credential_id` - Update passkey name + **Non-Standard:** - `GET /properties/:owner/:key` - Get property value - `PUT /properties/:owner/:key` - Set property value @@ -168,12 +188,60 @@ Generated ID tokens include: - Standard claims: iss, sub, aud, exp, iat - Optional: nonce (if provided in authorize request) - at_hash: hash of access token per OIDC spec (left 128 bits of SHA-256, base64url) +- auth_time: timestamp of authentication (from session) +- amr: Authentication Method References array (e.g., ["pwd"], ["hwk"], ["pwd", "hwk"]) +- acr: Authentication Context Reference ("aal1" for single-factor, "aal2" for two-factor) - Signed with RS256, includes kid header matching JWKS ### State Management - Authorization codes: 5 minute TTL, single-use (marked consumed) - Access tokens: 1 hour TTL, checked for expiration and revoked flag -- Both stored in SQLite with timestamps +- Sessions: Track authentication methods (AMR), context (ACR), and MFA status +- WebAuthn challenges: 5 minute TTL, cleaned up every 5 minutes by background job +- All stored in database with timestamps + +### WebAuthn/Passkey Authentication + +Barycenter supports passwordless authentication using WebAuthn/FIDO2 passkeys with the following features: + +**Authentication Modes:** +- **Single-factor passkey login**: Passkeys as primary authentication method +- **Two-factor authentication**: Passkeys as second factor after password login +- **Password fallback**: Traditional password authentication remains available + +**Client Implementation:** +- Rust WASM module (`client-wasm/`) compiled with wasm-pack +- Browser-side WebAuthn API calls via wasm-bindgen +- Conditional UI support for autofill in Chrome 108+, Safari 16+ +- Progressive enhancement: falls back to explicit button if autofill unavailable + +**Passkey Storage:** +- Full `Passkey` object stored as JSON in database +- Tracks signature counter for clone detection +- Records backup state (cloud-synced vs hardware-bound) +- Supports friendly names for user management + +**AMR (Authentication Method References) Values:** +- `"pwd"`: Password authentication +- `"hwk"`: Hardware-bound passkey (YubiKey, security key) +- `"swk"`: Software/cloud-synced passkey (iCloud Keychain, password manager) +- Multiple values indicate multi-factor auth (e.g., `["pwd", "hwk"]`) + +**2FA Enforcement Modes:** + +1. **User-Optional 2FA**: Users can enable 2FA in account settings (future UI) +2. **Admin-Enforced 2FA**: Set `users.requires_2fa = 1` via GraphQL mutation +3. **Context-Based 2FA**: Triggered by: + - High-value scopes: "admin", "payment", "transfer", "delete" + - Fresh authentication required: `max_age < 300` seconds + - Can be configured per-scope or per-request + +**2FA Flow:** +1. User logs in with password → creates partial session (`mfa_verified=0`) +2. If 2FA required, redirect to `/login/2fa` +3. User verifies with passkey +4. Session upgraded: `mfa_verified=1`, `acr="aal2"`, `amr=["pwd", "hwk"]` +5. Authorization proceeds, ID token includes full authentication context ## Current Implementation Status @@ -183,33 +251,245 @@ See `docs/oidc-conformance.md` for detailed OIDC compliance requirements. - Authorization Code flow with PKCE (S256) - Dynamic client registration - Token endpoint with client_secret_basic and client_secret_post -- ID Token signing (RS256) with at_hash and nonce +- ID Token signing (RS256) with at_hash, nonce, auth_time, AMR, and ACR claims - UserInfo endpoint with Bearer token authentication - Discovery and JWKS publication - Property storage API +- User authentication with sessions +- Password authentication with argon2 hashing +- WebAuthn/passkey authentication (single-factor and two-factor) +- WASM client for browser-side WebAuthn operations +- Conditional UI/autofill for passkey login +- Three 2FA modes: user-optional, admin-enforced, context-based +- Background jobs for cleanup (sessions, tokens, challenges) +- Admin GraphQL API for user management and job triggering +- Refresh token grant with rotation +- Session-based AMR/ACR tracking -**Pending (see docs/next-iteration-plan.md):** -- User authentication and session management (currently uses fixed "demo-user" subject) -- auth_time claim in ID Token (requires session tracking) +**Pending:** - Cache-Control headers on token endpoint - Consent flow (currently auto-consents) -- Refresh tokens -- Token revocation and introspection +- Token revocation and introspection endpoints - OpenID Federation trust chain validation +- User account management UI + +## Admin GraphQL API + +The admin API is served on a separate port (default: 9091) and provides GraphQL queries and mutations for management: + +**Mutations:** +```graphql +mutation { + # Trigger background jobs manually + triggerJob(jobName: "cleanup_expired_sessions") { + success + message + } + + # Enable 2FA requirement for a user + setUser2faRequired(username: "alice", required: true) { + success + message + requires2fa + } +} +``` + +**Queries:** +```graphql +query { + # Get job execution history + jobLogs(limit: 10, onlyFailures: false) { + id + jobName + startedAt + completedAt + success + recordsProcessed + } + + # Get user 2FA status + user2faStatus(username: "alice") { + username + requires2fa + passkeyEnrolled + passkeyCount + passkeyEnrolledAt + } + + # List available jobs + availableJobs { + name + description + schedule + } +} +``` + +Available job names: +- `cleanup_expired_sessions` (hourly at :00) +- `cleanup_expired_refresh_tokens` (hourly at :30) +- `cleanup_expired_challenges` (every 5 minutes) + +## Building the WASM Client + +The passkey authentication client is written in Rust and compiled to WebAssembly: + +```bash +# Install wasm-pack if not already installed +cargo install wasm-pack + +# Build the WASM module +cd client-wasm +wasm-pack build --target web --out-dir ../static/wasm + +# The built files will be in static/wasm/: +# - barycenter_webauthn_client_bg.wasm +# - barycenter_webauthn_client.js +# - TypeScript definitions (.d.ts files) +``` + +The WASM module is automatically loaded by the login page and provides: +- `supports_webauthn()`: Check if WebAuthn is available +- `supports_conditional_ui()`: Check for autofill support +- `register_passkey(options)`: Create a new passkey +- `authenticate_passkey(options, mediation)`: Authenticate with passkey ## Testing and Validation -No automated tests currently exist. Manual testing can be done with curl commands following the OAuth 2.0 Authorization Code + PKCE flow: +### Manual Testing Flow -1. Register a client via `POST /connect/register` -2. Generate PKCE verifier and challenge -3. Navigate to `/authorize` with required parameters -4. Exchange authorization code at `/token` with code_verifier -5. Access `/userinfo` with Bearer access_token - -Example PKCE generation (bash): +**1. Test Password Login:** ```bash +# Navigate to http://localhost:9090/login +# Enter username: admin, password: password123 +# Should create session and redirect +``` + +**2. Test Passkey Registration:** +```bash +# After logging in with password +# Navigate to http://localhost:9090/account/passkeys +# (Future UI - currently use browser console) + +# Call via JavaScript console: +fetch('/webauthn/register/start', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + // Use browser's navigator.credentials.create() with returned options + }); +``` + +**3. Test Passkey Authentication:** +- Navigate to `/login` +- Click on username field +- Browser should show passkey autofill (Chrome 108+, Safari 16+) +- Select a passkey to authenticate + +**4. Test Admin-Enforced 2FA:** +```graphql +# Via admin API (port 9091) +mutation { + setUser2faRequired(username: "admin", required: true) { + success + } +} +``` + +Then: +1. Log out +2. Log in with password +3. Should redirect to `/login/2fa` +4. Complete passkey verification +5. Should complete authorization with ACR="aal2" + +**5. Test Context-Based 2FA:** +```bash +# Request authorization with max_age < 300 +curl "http://localhost:9090/authorize?...&max_age=60" +# Should trigger 2FA even if not admin-enforced +``` + +### OIDC Flow Testing + +```bash +# 1. Register a client +curl -X POST http://localhost:9090/connect/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:8080/callback"], + "client_name": "Test Client" + }' + +# 2. Generate PKCE verifier=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_') challenge=$(echo -n "$verifier" | openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '+/' '-_') + +# 3. Navigate to authorize endpoint (in browser) +http://localhost:9090/authorize?client_id=CLIENT_ID&redirect_uri=http://localhost:8080/callback&response_type=code&scope=openid&code_challenge=$challenge&code_challenge_method=S256&state=random + +# 4. After redirect, exchange code for tokens +curl -X POST http://localhost:9090/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code&code=CODE&redirect_uri=http://localhost:8080/callback&client_id=CLIENT_ID&client_secret=SECRET&code_verifier=$verifier" + +# 5. Decode ID token to verify AMR/ACR claims +# Use jwt.io or similar to inspect the token ``` -- I have github team plan \ No newline at end of file + +### Expected ID Token Claims + +After passkey authentication: +```json +{ + "iss": "http://localhost:9090", + "sub": "user_subject_uuid", + "aud": "client_id", + "exp": 1234567890, + "iat": 1234564290, + "auth_time": 1234564290, + "amr": ["hwk"], // or ["swk"] for cloud-synced, ["pwd", "hwk"] for 2FA + "acr": "aal1", // or "aal2" for 2FA + "nonce": "optional_nonce" +} +``` + +## Migration Guide for Existing Deployments + +If you have an existing Barycenter deployment, the database will be automatically migrated when you update: + +1. **Backup your database** before upgrading +2. Run the application - migrations run automatically on startup +3. New tables will be created: + - `passkeys`: Stores registered passkeys + - `webauthn_challenges`: Temporary challenge storage +4. Existing tables will be extended: + - `sessions`: Added `amr`, `acr`, `mfa_verified` columns + - `users`: Added `requires_2fa`, `passkey_enrolled_at` columns + +**Post-Migration Steps:** + +1. Build the WASM client: + ```bash + cd client-wasm + wasm-pack build --target web --out-dir ../static/wasm + ``` + +2. Restart the application to serve static files + +3. Users can now register passkeys via `/account/passkeys` (future UI) + +4. Enable 2FA for specific users via admin API: + ```graphql + mutation { + setUser2faRequired(username: "admin", required: true) { + success + } + } + ``` + +**No Breaking Changes:** +- Password authentication continues to work +- Existing sessions remain valid +- ID tokens now include AMR/ACR claims (additive change) +- OIDC clients receiving new claims should handle gracefully \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 62d3714..a908ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,51 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-graphql" version = "7.0.17" @@ -455,10 +500,12 @@ version = "0.2.0-alpha.15" dependencies = [ "anyhow", "argon2", + "assert_matches", "async-graphql", "async-graphql-axum", "axum 0.8.7", "base64ct", + "bincode", "chrono", "clap", "config", @@ -467,6 +514,7 @@ dependencies = [ "migration", "oauth2", "openidconnect", + "pretty_assertions", "rand 0.8.5", "regex", "reqwest", @@ -474,20 +522,41 @@ dependencies = [ "sea-orm-migration", "seaography", "serde", + "serde_cbor", "serde_json", "serde_urlencoded", "serde_with", "sha2", + "tempfile", + "test-log", "thiserror 1.0.69", "time", "tokio", "tokio-cron-scheduler", + "tokio-test", "tower", + "tower-http", "tower_governor", "tracing", "tracing-subscriber", "url", "urlencoding", + "uuid", + "webauthn-rs", +] + +[[package]] +name = "barycenter-webauthn-client" +version = "0.1.0" +dependencies = [ + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", ] [[package]] @@ -514,6 +583,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bigdecimal" version = "0.4.9" @@ -528,6 +608,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "2.10.0" @@ -1035,6 +1135,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -1066,6 +1180,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1196,6 +1316,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1550,6 +1691,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "handlebars" version = "5.1.2" @@ -1697,6 +1855,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2219,6 +2383,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2419,6 +2603,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2628,6 +2821,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2790,6 +2989,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -3294,6 +3503,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.2" @@ -3354,6 +3572,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3639,6 +3866,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half 1.8.3", + "serde", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half 2.7.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4243,6 +4501,28 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "test-log" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -4441,6 +4721,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -4562,14 +4855,24 @@ checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4697,6 +5000,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4760,6 +5069,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.7" @@ -4826,6 +5141,22 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4914,6 +5245,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc379bfb624eb59050b509c13e77b4eb53150c350db69628141abce842f2373" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085b2df989e1e6f9620c1311df6c996e83fe16f57792b272ce1e024ac16a90f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "web-sys" version = "0.3.82" @@ -4934,6 +5289,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.8.5", + "rand_chacha 0.3.1", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4978,6 +5401,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -5306,6 +5738,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yaml-rust2" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 2a9a0ff..9872822 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "migration", "client-wasm"] + [package] name = "barycenter" version = "0.2.0-alpha.15" @@ -10,6 +13,10 @@ documentation = "https://github.com/CloudNebulaProject/barycenter/blob/main/READ keywords = ["openid", "oauth2", "identity", "authentication", "oidc"] categories = ["authentication", "web-programming"] +[lib] +name = "barycenter" +path = "src/lib.rs" + [dependencies] axum = { version = "0.8", features = ["json", "form"] } tokio = { version = "1", features = ["full"] } @@ -31,6 +38,10 @@ migration = { path = "migration" } # JOSE / JWKS & JWT josekit = "0.10" +# WebAuthn / Passkeys +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] } +uuid = { version = "1", features = ["v4", "serde"] } + chrono = { version = "0.4", features = ["serde", "clock"] } time = "0.3" rand = "0.8" @@ -45,6 +56,7 @@ argon2 = "0.5" # Rate limiting tower = "0.5" tower_governor = "0.4" +tower-http = { version = "0.6", features = ["fs"] } # Validation regex = "1" @@ -57,13 +69,23 @@ async-graphql-axum = "7" # Background job scheduler tokio-cron-scheduler = "0.13" +bincode = "2.0.1" [dev-dependencies] +# Existing OIDC/OAuth testing openidconnect = { version = "4", features = ["reqwest-blocking"] } oauth2 = "5" reqwest = { version = "0.12", features = ["blocking", "json", "cookies"] } urlencoding = "2" +# New test utilities +tempfile = "3" # Temp SQLite databases for test isolation +tokio-test = "0.4" # Async test utilities +assert_matches = "1" # Pattern matching assertions +pretty_assertions = "1" # Better assertion output with color diffs +test-log = "0.2" # Capture tracing logs in tests +serde_cbor = "0.11" # CBOR encoding for WebAuthn mocks + [profile.release] debug = 1 diff --git a/client-wasm/Cargo.toml b/client-wasm/Cargo.toml new file mode 100644 index 0000000..46a50fb --- /dev/null +++ b/client-wasm/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "barycenter-webauthn-client" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" +serde_json = "1" +web-sys = { version = "0.3", features = [ + "Window", + "Navigator", + "CredentialsContainer", + "PublicKeyCredential", + "PublicKeyCredentialCreationOptions", + "PublicKeyCredentialRequestOptions", + "AuthenticatorAttestationResponse", + "AuthenticatorAssertionResponse", + "AuthenticatorResponse", + "Credential", + "CredentialCreationOptions", + "CredentialRequestOptions", +] } +js-sys = "0.3" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/client-wasm/src/lib.rs b/client-wasm/src/lib.rs new file mode 100644 index 0000000..97653a5 --- /dev/null +++ b/client-wasm/src/lib.rs @@ -0,0 +1,287 @@ +use serde::Serialize; +use wasm_bindgen::prelude::*; + +/// Check if WebAuthn is supported in the current browser +#[wasm_bindgen] +pub fn supports_webauthn() -> bool { + let window = match web_sys::window() { + Some(w) => w, + None => return false, + }; + + // Check if PublicKeyCredential is available + js_sys::Reflect::has(&window, &JsValue::from_str("PublicKeyCredential")).unwrap_or(false) +} + +/// Check if conditional UI (autofill) is supported +#[wasm_bindgen] +pub async fn supports_conditional_ui() -> bool { + let window = match web_sys::window() { + Some(w) => w, + None => return false, + }; + + // Check if PublicKeyCredential.isConditionalMediationAvailable exists + let public_key_credential = + match js_sys::Reflect::get(&window, &JsValue::from_str("PublicKeyCredential")) { + Ok(pkc) => pkc, + Err(_) => return false, + }; + + let is_conditional_available = match js_sys::Reflect::get( + &public_key_credential, + &JsValue::from_str("isConditionalMediationAvailable"), + ) { + Ok(func) => func, + Err(_) => return false, + }; + + // Call the function if it exists + if is_conditional_available.is_function() { + let func = js_sys::Function::from(is_conditional_available); + match func.call0(&public_key_credential) { + Ok(promise_val) => { + let promise = js_sys::Promise::from(promise_val); + match wasm_bindgen_futures::JsFuture::from(promise).await { + Ok(result) => result.as_bool().unwrap_or(false), + Err(_) => false, + } + } + Err(_) => false, + } + } else { + false + } +} + +/// Register a new passkey +/// +/// Takes a JSON string containing PublicKeyCredentialCreationOptions +/// Returns a JSON string containing the credential response +#[wasm_bindgen] +pub async fn register_passkey(options_json: &str) -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window object"))?; + + let navigator = window.navigator(); + let credentials = navigator.credentials(); + + // Parse the options from JSON string to serde_json::Value first + let options: serde_json::Value = serde_json::from_str(options_json) + .map_err(|e| JsValue::from_str(&format!("Failed to parse options JSON: {}", e)))?; + + // Convert to JsValue + let options_value: JsValue = serde_wasm_bindgen::to_value(&options) + .map_err(|e| JsValue::from_str(&format!("Failed to convert options to JsValue: {}", e)))?; + + // Create CredentialCreationOptions + let credential_creation_options = js_sys::Object::new(); + js_sys::Reflect::set( + &credential_creation_options, + &JsValue::from_str("publicKey"), + &options_value, + )?; + + // Call navigator.credentials.create() + let promise = credentials.create_with_options(&web_sys::CredentialCreationOptions::from( + JsValue::from(credential_creation_options), + ))?; + + let result = wasm_bindgen_futures::JsFuture::from(promise).await?; + + // Convert the credential to JSON + let credential_json = serialize_credential_response(&result)?; + + Ok(credential_json) +} + +/// Authenticate with a passkey +/// +/// Takes a JSON string containing PublicKeyCredentialRequestOptions +/// and an optional mediation mode ("conditional" for autofill) +/// Returns a JSON string containing the assertion response +#[wasm_bindgen] +pub async fn authenticate_passkey( + options_json: &str, + mediation: Option, +) -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window object"))?; + + let navigator = window.navigator(); + let credentials = navigator.credentials(); + + // Parse the options from JSON string to serde_json::Value first + let options: serde_json::Value = serde_json::from_str(options_json) + .map_err(|e| JsValue::from_str(&format!("Failed to parse options JSON: {}", e)))?; + + // Convert to JsValue + let options_value: JsValue = serde_wasm_bindgen::to_value(&options) + .map_err(|e| JsValue::from_str(&format!("Failed to convert options to JsValue: {}", e)))?; + + // Create CredentialRequestOptions + let credential_request_options = js_sys::Object::new(); + js_sys::Reflect::set( + &credential_request_options, + &JsValue::from_str("publicKey"), + &options_value, + )?; + + // Add mediation if specified (for conditional UI) + if let Some(med) = mediation { + js_sys::Reflect::set( + &credential_request_options, + &JsValue::from_str("mediation"), + &JsValue::from_str(&med), + )?; + } + + // Call navigator.credentials.get() + let promise = credentials.get_with_options(&web_sys::CredentialRequestOptions::from( + JsValue::from(credential_request_options), + ))?; + + let result = wasm_bindgen_futures::JsFuture::from(promise).await?; + + // Convert the assertion to JSON + let assertion_json = serialize_assertion_response(&result)?; + + Ok(assertion_json) +} + +/// Serialize credential response (for registration) +fn serialize_credential_response(credential: &JsValue) -> Result { + // The credential returned is a PublicKeyCredential + // We need to extract and serialize the response + + #[derive(Serialize)] + struct CredentialResponse { + id: String, + raw_id: Vec, + response: AttestationResponse, + #[serde(rename = "type")] + type_: String, + } + + #[derive(Serialize)] + struct AttestationResponse { + attestation_object: Vec, + client_data_json: Vec, + } + + // Extract fields using js-sys Reflect + let id = js_sys::Reflect::get(credential, &JsValue::from_str("id"))? + .as_string() + .ok_or_else(|| JsValue::from_str("Missing id"))?; + + let raw_id = js_sys::Reflect::get(credential, &JsValue::from_str("rawId"))?; + let raw_id_bytes = js_sys::Uint8Array::new(&raw_id).to_vec(); + + let response_obj = js_sys::Reflect::get(credential, &JsValue::from_str("response"))?; + + let attestation_object = + js_sys::Reflect::get(&response_obj, &JsValue::from_str("attestationObject"))?; + let attestation_bytes = js_sys::Uint8Array::new(&attestation_object).to_vec(); + + let client_data_json = + js_sys::Reflect::get(&response_obj, &JsValue::from_str("clientDataJSON"))?; + let client_data_bytes = js_sys::Uint8Array::new(&client_data_json).to_vec(); + + let type_ = js_sys::Reflect::get(credential, &JsValue::from_str("type"))? + .as_string() + .unwrap_or_else(|| "public-key".to_string()); + + let response = CredentialResponse { + id, + raw_id: raw_id_bytes, + response: AttestationResponse { + attestation_object: attestation_bytes, + client_data_json: client_data_bytes, + }, + type_, + }; + + serde_json::to_string(&response) + .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e))) +} + +/// Serialize assertion response (for authentication) +fn serialize_assertion_response(credential: &JsValue) -> Result { + #[derive(Serialize)] + struct AssertionResponse { + id: String, + raw_id: Vec, + response: AuthenticatorResponse, + #[serde(rename = "type")] + type_: String, + } + + #[derive(Serialize)] + struct AuthenticatorResponse { + authenticator_data: Vec, + client_data_json: Vec, + signature: Vec, + user_handle: Option>, + } + + // Extract fields + let id = js_sys::Reflect::get(credential, &JsValue::from_str("id"))? + .as_string() + .ok_or_else(|| JsValue::from_str("Missing id"))?; + + let raw_id = js_sys::Reflect::get(credential, &JsValue::from_str("rawId"))?; + let raw_id_bytes = js_sys::Uint8Array::new(&raw_id).to_vec(); + + let response_obj = js_sys::Reflect::get(credential, &JsValue::from_str("response"))?; + + let authenticator_data = + js_sys::Reflect::get(&response_obj, &JsValue::from_str("authenticatorData"))?; + let authenticator_data_bytes = js_sys::Uint8Array::new(&authenticator_data).to_vec(); + + let client_data_json = + js_sys::Reflect::get(&response_obj, &JsValue::from_str("clientDataJSON"))?; + let client_data_bytes = js_sys::Uint8Array::new(&client_data_json).to_vec(); + + let signature = js_sys::Reflect::get(&response_obj, &JsValue::from_str("signature"))?; + let signature_bytes = js_sys::Uint8Array::new(&signature).to_vec(); + + // userHandle is optional + let user_handle = js_sys::Reflect::get(&response_obj, &JsValue::from_str("userHandle")) + .ok() + .and_then(|uh| { + if uh.is_null() || uh.is_undefined() { + None + } else { + Some(js_sys::Uint8Array::new(&uh).to_vec()) + } + }); + + let type_ = js_sys::Reflect::get(credential, &JsValue::from_str("type"))? + .as_string() + .unwrap_or_else(|| "public-key".to_string()); + + let response = AssertionResponse { + id, + raw_id: raw_id_bytes, + response: AuthenticatorResponse { + authenticator_data: authenticator_data_bytes, + client_data_json: client_data_bytes, + signature: signature_bytes, + user_handle, + }, + type_, + }; + + serde_json::to_string(&response) + .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::*; + + #[wasm_bindgen_test] + fn test_supports_webauthn() { + // This will only work in a real browser environment + let _result = supports_webauthn(); + } +} diff --git a/config.toml b/config.toml index 52e4233..e785504 100644 --- a/config.toml +++ b/config.toml @@ -3,6 +3,8 @@ host = "0.0.0.0" port = 8080 # Uncomment for production with HTTPS: # public_base_url = "https://idp.example.com" +# For development/testing with WebAuthn, use: +# public_base_url = "http://localhost:8080" [database] url = "sqlite://barycenter.db?mode=rwc" diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 0491d8f..d400adf 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -1,12 +1,18 @@ pub use sea_orm_migration::prelude::*; mod m20250101_000001_initial_schema; +mod m20250107_000001_add_passkeys; +mod m20250107_000002_extend_sessions_users; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20250101_000001_initial_schema::Migration)] + vec![ + Box::new(m20250101_000001_initial_schema::Migration), + Box::new(m20250107_000001_add_passkeys::Migration), + Box::new(m20250107_000002_extend_sessions_users::Migration), + ] } } diff --git a/migration/src/m20250107_000001_add_passkeys.rs b/migration/src/m20250107_000001_add_passkeys.rs new file mode 100644 index 0000000..0be4b76 --- /dev/null +++ b/migration/src/m20250107_000001_add_passkeys.rs @@ -0,0 +1,139 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create passkeys table + manager + .create_table( + Table::create() + .table(Passkeys::Table) + .if_not_exists() + .col( + ColumnDef::new(Passkeys::CredentialId) + .string() + .not_null() + .primary_key(), + ) + .col(string(Passkeys::Subject)) + .col(string(Passkeys::PublicKeyCose)) + .col( + ColumnDef::new(Passkeys::Counter) + .big_integer() + .not_null() + .default(0), + ) + .col(string_null(Passkeys::Aaguid)) + .col(big_integer(Passkeys::BackupEligible)) + .col(big_integer(Passkeys::BackupState)) + .col(string_null(Passkeys::Transports)) + .col(string_null(Passkeys::Name)) + .col(big_integer(Passkeys::CreatedAt)) + .col(big_integer_null(Passkeys::LastUsedAt)) + .foreign_key( + ForeignKey::create() + .name("fk_passkeys_subject") + .from(Passkeys::Table, Passkeys::Subject) + .to(Users::Table, Users::Subject) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create index on passkeys.subject for lookup + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_passkeys_subject") + .table(Passkeys::Table) + .col(Passkeys::Subject) + .to_owned(), + ) + .await?; + + // Create webauthn_challenges table + manager + .create_table( + Table::create() + .table(WebauthnChallenges::Table) + .if_not_exists() + .col( + ColumnDef::new(WebauthnChallenges::Challenge) + .string() + .not_null() + .primary_key(), + ) + .col(string_null(WebauthnChallenges::Subject)) + .col(string_null(WebauthnChallenges::SessionId)) + .col(string(WebauthnChallenges::ChallengeType)) + .col(big_integer(WebauthnChallenges::CreatedAt)) + .col(big_integer(WebauthnChallenges::ExpiresAt)) + .col(string(WebauthnChallenges::OptionsJson)) + .to_owned(), + ) + .await?; + + // Create index on webauthn_challenges.expires_at for cleanup job + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_webauthn_challenges_expires") + .table(WebauthnChallenges::Table) + .col(WebauthnChallenges::ExpiresAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(WebauthnChallenges::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Passkeys::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Passkeys { + Table, + CredentialId, + Subject, + PublicKeyCose, + Counter, + Aaguid, + BackupEligible, + BackupState, + Transports, + Name, + CreatedAt, + LastUsedAt, +} + +#[derive(DeriveIden)] +enum WebauthnChallenges { + Table, + Challenge, + Subject, + SessionId, + ChallengeType, + CreatedAt, + ExpiresAt, + OptionsJson, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Subject, +} diff --git a/migration/src/m20250107_000002_extend_sessions_users.rs b/migration/src/m20250107_000002_extend_sessions_users.rs new file mode 100644 index 0000000..3e52c39 --- /dev/null +++ b/migration/src/m20250107_000002_extend_sessions_users.rs @@ -0,0 +1,137 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Extend Sessions table with authentication context fields + // SQLite requires separate ALTER TABLE statements for each column + manager + .alter_table( + Table::alter() + .table(Sessions::Table) + .add_column(string_null(Sessions::Amr)) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Sessions::Table) + .add_column(string_null(Sessions::Acr)) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Sessions::Table) + .add_column( + ColumnDef::new(Sessions::MfaVerified) + .big_integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + + // Extend Users table with 2FA and passkey enrollment fields + manager + .alter_table( + Table::alter() + .table(Users::Table) + .add_column( + ColumnDef::new(Users::Requires2fa) + .big_integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Users::Table) + .add_column(big_integer_null(Users::PasskeyEnrolledAt)) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Remove columns from Users table + // SQLite requires separate ALTER TABLE statements for each column + manager + .alter_table( + Table::alter() + .table(Users::Table) + .drop_column(Users::PasskeyEnrolledAt) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Users::Table) + .drop_column(Users::Requires2fa) + .to_owned(), + ) + .await?; + + // Remove columns from Sessions table + manager + .alter_table( + Table::alter() + .table(Sessions::Table) + .drop_column(Sessions::MfaVerified) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Sessions::Table) + .drop_column(Sessions::Acr) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Sessions::Table) + .drop_column(Sessions::Amr) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Sessions { + Table, + Amr, + Acr, + MfaVerified, +} + +#[derive(DeriveIden)] +enum Users { + Table, + #[sea_orm(iden = "requires_2fa")] + Requires2fa, + PasskeyEnrolledAt, +} diff --git a/src/admin_mutations.rs b/src/admin_mutations.rs index 4c45d64..3a6cbc3 100644 --- a/src/admin_mutations.rs +++ b/src/admin_mutations.rs @@ -1,8 +1,13 @@ use async_graphql::*; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, + QueryOrder, QuerySelect, Set, +}; use std::sync::Arc; +use crate::entities; use crate::jobs; +use crate::storage; /// Custom mutations for admin operations #[derive(Default)] @@ -29,6 +34,52 @@ impl AdminMutation { }), } } + + /// Set 2FA requirement for a user (admin-enforced 2FA) + async fn set_user_2fa_required( + &self, + ctx: &Context<'_>, + #[graphql(desc = "Username to modify")] username: String, + #[graphql(desc = "Whether 2FA should be required")] required: bool, + ) -> Result { + let db = ctx + .data::>() + .map_err(|_| Error::new("Database connection not available"))?; + + // Get user by username + let user = storage::get_user_by_username(db.as_ref(), &username) + .await + .map_err(|e| Error::new(format!("Database error: {}", e)))? + .ok_or_else(|| Error::new(format!("User '{}' not found", username)))?; + + // Update requires_2fa flag + use crate::entities::user::{Column, Entity}; + let user_entity = Entity::find() + .filter(Column::Subject.eq(&user.subject)) + .one(db.as_ref()) + .await + .map_err(|e| Error::new(format!("Database error: {}", e)))? + .ok_or_else(|| Error::new("User entity not found"))?; + + let mut active: entities::user::ActiveModel = user_entity.into_active_model(); + active.requires_2fa = Set(if required { 1 } else { 0 }); + + active + .update(db.as_ref()) + .await + .map_err(|e| Error::new(format!("Failed to update user: {}", e)))?; + + Ok(User2FAResult { + success: true, + message: format!( + "2FA requirement {} for user '{}'", + if required { "enabled" } else { "disabled" }, + username + ), + username, + requires_2fa: required, + }) + } } /// Result of triggering a job @@ -39,6 +90,15 @@ pub struct JobTriggerResult { pub job_name: String, } +/// Result of setting user 2FA requirement +#[derive(SimpleObject)] +pub struct User2FAResult { + pub success: bool, + pub message: String, + pub username: String, + pub requires_2fa: bool, +} + /// Custom queries for admin operations #[derive(Default)] pub struct AdminQuery; @@ -107,8 +167,44 @@ impl AdminQuery { description: "Clean up expired refresh tokens".to_string(), schedule: "Hourly at :30".to_string(), }, + JobInfo { + name: "cleanup_expired_challenges".to_string(), + description: "Clean up expired WebAuthn challenges".to_string(), + schedule: "Every 5 minutes".to_string(), + }, ]) } + + /// Get 2FA status for a user + async fn user_2fa_status( + &self, + ctx: &Context<'_>, + #[graphql(desc = "Username to query")] username: String, + ) -> Result { + let db = ctx + .data::>() + .map_err(|_| Error::new("Database connection not available"))?; + + // Get user by username + let user = storage::get_user_by_username(db.as_ref(), &username) + .await + .map_err(|e| Error::new(format!("Database error: {}", e)))? + .ok_or_else(|| Error::new(format!("User '{}' not found", username)))?; + + // Get user's passkeys count + let passkeys = storage::get_passkeys_by_subject(db.as_ref(), &user.subject) + .await + .map_err(|e| Error::new(format!("Failed to get passkeys: {}", e)))?; + + Ok(User2FAStatus { + username, + subject: user.subject, + requires_2fa: user.requires_2fa == 1, + passkey_enrolled: user.passkey_enrolled_at.is_some(), + passkey_count: passkeys.len() as i32, + passkey_enrolled_at: user.passkey_enrolled_at, + }) + } } /// Job log entry @@ -130,3 +226,14 @@ pub struct JobInfo { pub description: String, pub schedule: String, } + +/// User 2FA status information +#[derive(SimpleObject)] +pub struct User2FAStatus { + pub username: String, + pub subject: String, + pub requires_2fa: bool, + pub passkey_enrolled: bool, + pub passkey_count: i32, + pub passkey_enrolled_at: Option, +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 6f69466..61175d2 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -2,16 +2,20 @@ pub mod access_token; pub mod auth_code; pub mod client; pub mod job_execution; +pub mod passkey; pub mod property; pub mod refresh_token; pub mod session; pub mod user; +pub mod webauthn_challenge; pub use access_token::Entity as AccessToken; pub use auth_code::Entity as AuthCode; pub use client::Entity as Client; pub use job_execution::Entity as JobExecution; +pub use passkey::Entity as Passkey; pub use property::Entity as Property; pub use refresh_token::Entity as RefreshToken; pub use session::Entity as Session; pub use user::Entity as User; +pub use webauthn_challenge::Entity as WebauthnChallenge; diff --git a/src/entities/passkey.rs b/src/entities/passkey.rs new file mode 100644 index 0000000..e85d623 --- /dev/null +++ b/src/entities/passkey.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "passkeys")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub credential_id: String, + pub subject: String, + pub public_key_cose: String, + pub counter: i64, + pub aaguid: Option, + pub backup_eligible: i64, + pub backup_state: i64, + pub transports: Option, + pub name: Option, + pub created_at: i64, + pub last_used_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/session.rs b/src/entities/session.rs index 00f0fc1..c38d086 100644 --- a/src/entities/session.rs +++ b/src/entities/session.rs @@ -12,6 +12,9 @@ pub struct Model { pub expires_at: i64, pub user_agent: Option, pub ip_address: Option, + pub amr: Option, + pub acr: Option, + pub mfa_verified: i64, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entities/user.rs b/src/entities/user.rs index b92f446..f0d25ee 100644 --- a/src/entities/user.rs +++ b/src/entities/user.rs @@ -13,6 +13,8 @@ pub struct Model { pub email_verified: i64, pub created_at: i64, pub enabled: i64, + pub requires_2fa: i64, + pub passkey_enrolled_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entities/webauthn_challenge.rs b/src/entities/webauthn_challenge.rs new file mode 100644 index 0000000..02e50b2 --- /dev/null +++ b/src/entities/webauthn_challenge.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "webauthn_challenges")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub challenge: String, + pub subject: Option, + pub session_id: Option, + pub challenge_type: String, + pub created_at: i64, + pub expires_at: i64, + pub options_json: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/errors.rs b/src/errors.rs index 640f818..2cbf339 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -23,6 +23,20 @@ pub enum CrabError { #[diagnostic(code(barycenter::jose))] Jose(String), + #[error("WebAuthn error: {0}")] + #[diagnostic( + code(barycenter::webauthn), + help("Check passkey configuration and client response format") + )] + WebAuthnError(String), + + #[error("Configuration error: {0}")] + #[diagnostic( + code(barycenter::configuration), + help("Check your configuration settings") + )] + Configuration(String), + #[error("Bad request: {0}")] #[diagnostic(code(barycenter::bad_request))] BadRequest(String), diff --git a/src/jobs.rs b/src/jobs.rs index 80a3c4c..7d821d7 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -87,13 +87,49 @@ pub async fn init_scheduler(db: DatabaseConnection) -> Result { + info!("Cleaned up {} expired WebAuthn challenges", count); + if let Some(id) = execution_id { + let _ = + complete_job_execution(&db, id, true, None, Some(count as i64)).await; + } + } + Err(e) => { + error!("Failed to cleanup expired challenges: {}", e); + if let Some(id) = execution_id { + let _ = + complete_job_execution(&db, id, false, Some(e.to_string()), None).await; + } + } + } + }) + }) + .map_err(|e| CrabError::Other(format!("Failed to create cleanup challenges job: {}", e)))?; + + sched + .add(cleanup_challenges_job) + .await + .map_err(|e| CrabError::Other(format!("Failed to add cleanup challenges job: {}", e)))?; + // Start the scheduler sched .start() .await .map_err(|e| CrabError::Other(format!("Failed to start job scheduler: {}", e)))?; - info!("Job scheduler started with {} jobs", 2); + info!("Job scheduler started with {} jobs", 3); Ok(sched) } @@ -160,6 +196,7 @@ pub async fn trigger_job_manually( let result = match job_name { "cleanup_expired_sessions" => storage::cleanup_expired_sessions(db).await, "cleanup_expired_refresh_tokens" => storage::cleanup_expired_refresh_tokens(db).await, + "cleanup_expired_challenges" => storage::cleanup_expired_challenges(db).await, _ => { return Err(CrabError::Other(format!("Unknown job name: {}", job_name))); } @@ -181,3 +218,206 @@ pub async fn trigger_job_manually( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{Database, DatabaseConnection}; + use sea_orm_migration::MigratorTrait; + use tempfile::NamedTempFile; + + /// Helper to create an in-memory test database + async fn test_db() -> DatabaseConnection { + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + let db_path = temp_file.path().to_str().expect("Invalid temp file path"); + let db_url = format!("sqlite://{}?mode=rwc", db_path); + + let db = Database::connect(&db_url) + .await + .expect("Failed to connect to test database"); + + migration::Migrator::up(&db, None) + .await + .expect("Failed to run migrations"); + + db + } + + #[tokio::test] + async fn test_start_job_execution() { + let db = test_db().await; + + let execution_id = start_job_execution(&db, "test_job") + .await + .expect("Failed to start job execution"); + + assert!(execution_id > 0); + + // Verify record was created + use entities::job_execution::{Column, Entity}; + let execution = Entity::find() + .filter(Column::Id.eq(execution_id)) + .one(&db) + .await + .expect("Failed to query job execution") + .expect("Job execution not found"); + + assert_eq!(execution.job_name, "test_job"); + assert!(execution.started_at > 0); + assert!(execution.completed_at.is_none()); + assert!(execution.success.is_none()); + } + + #[tokio::test] + async fn test_complete_job_execution_success() { + let db = test_db().await; + + let execution_id = start_job_execution(&db, "test_job") + .await + .expect("Failed to start job execution"); + + complete_job_execution(&db, execution_id, true, None, Some(42)) + .await + .expect("Failed to complete job execution"); + + // Verify record was updated + use entities::job_execution::{Column, Entity}; + let execution = Entity::find() + .filter(Column::Id.eq(execution_id)) + .one(&db) + .await + .expect("Failed to query job execution") + .expect("Job execution not found"); + + assert!(execution.completed_at.is_some()); + assert_eq!(execution.success, Some(1)); + assert_eq!(execution.records_processed, Some(42)); + assert!(execution.error_message.is_none()); + } + + #[tokio::test] + async fn test_complete_job_execution_failure() { + let db = test_db().await; + + let execution_id = start_job_execution(&db, "test_job") + .await + .expect("Failed to start job execution"); + + complete_job_execution( + &db, + execution_id, + false, + Some("Test error message".to_string()), + None, + ) + .await + .expect("Failed to complete job execution"); + + // Verify record was updated with error + use entities::job_execution::{Column, Entity}; + let execution = Entity::find() + .filter(Column::Id.eq(execution_id)) + .one(&db) + .await + .expect("Failed to query job execution") + .expect("Job execution not found"); + + assert!(execution.completed_at.is_some()); + assert_eq!(execution.success, Some(0)); + assert_eq!(execution.error_message, Some("Test error message".to_string())); + assert!(execution.records_processed.is_none()); + } + + #[tokio::test] + async fn test_trigger_job_manually_cleanup_sessions() { + let db = test_db().await; + + // Create an expired session + let user = storage::create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let past_auth_time = Utc::now().timestamp() - 7200; // 2 hours ago + storage::create_session(&db, &user.subject, past_auth_time, 3600, None, None) // 1 hour TTL + .await + .expect("Failed to create session"); + + // Trigger cleanup job + trigger_job_manually(&db, "cleanup_expired_sessions") + .await + .expect("Failed to trigger job"); + + // Verify job execution was recorded + use entities::job_execution::{Column, Entity}; + let execution = Entity::find() + .filter(Column::JobName.eq("cleanup_expired_sessions")) + .one(&db) + .await + .expect("Failed to query job execution") + .expect("Job execution not found"); + + assert_eq!(execution.success, Some(1)); + assert_eq!(execution.records_processed, Some(1)); // Should have cleaned up 1 session + } + + #[tokio::test] + async fn test_trigger_job_manually_cleanup_tokens() { + let db = test_db().await; + + // Trigger cleanup_expired_refresh_tokens job + trigger_job_manually(&db, "cleanup_expired_refresh_tokens") + .await + .expect("Failed to trigger job"); + + // Verify job execution was recorded + use entities::job_execution::{Column, Entity}; + let execution = Entity::find() + .filter(Column::JobName.eq("cleanup_expired_refresh_tokens")) + .one(&db) + .await + .expect("Failed to query job execution") + .expect("Job execution not found"); + + assert_eq!(execution.success, Some(1)); + } + + #[tokio::test] + async fn test_trigger_job_manually_invalid_name() { + let db = test_db().await; + + let result = trigger_job_manually(&db, "invalid_job_name").await; + + assert!(result.is_err()); + match result { + Err(CrabError::Other(msg)) => { + assert!(msg.contains("Unknown job name")); + } + _ => panic!("Expected CrabError::Other"), + } + } + + #[tokio::test] + async fn test_job_execution_records_processed() { + let db = test_db().await; + + let execution_id = start_job_execution(&db, "test_job") + .await + .expect("Failed to start job execution"); + + // Complete with specific record count + complete_job_execution(&db, execution_id, true, None, Some(123)) + .await + .expect("Failed to complete job execution"); + + // Verify records_processed field + use entities::job_execution::{Column, Entity}; + let execution = Entity::find() + .filter(Column::Id.eq(execution_id)) + .one(&db) + .await + .expect("Failed to query job execution") + .expect("Job execution not found"); + + assert_eq!(execution.records_processed, Some(123)); + } +} diff --git a/src/jwks.rs b/src/jwks.rs index 5a2557f..a2b2f93 100644 --- a/src/jwks.rs +++ b/src/jwks.rs @@ -88,3 +88,219 @@ fn random_kid() -> String { rand::thread_rng().fill_bytes(&mut bytes); base64ct::Base64UrlUnpadded::encode_string(&bytes) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::path::PathBuf; + + /// Helper to create test Keys config + fn test_keys_config(temp_dir: &TempDir) -> Keys { + Keys { + jwks_path: temp_dir.path().join("jwks.json"), + private_key_path: temp_dir.path().join("private_key.json"), + alg: "RS256".to_string(), + key_id: Some("test-kid-123".to_string()), + } + } + + #[tokio::test] + async fn test_jwks_manager_generates_key() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cfg = test_keys_config(&temp_dir); + + let manager = JwksManager::new(cfg.clone()) + .await + .expect("Failed to create JwksManager"); + + let private_jwk = manager.private_jwk(); + + // Verify it's RSA key + assert_eq!(private_jwk.key_type(), "RSA"); + assert_eq!(private_jwk.algorithm(), Some("RS256")); + assert_eq!(private_jwk.key_use(), Some("sig")); + assert_eq!(private_jwk.key_id(), Some("test-kid-123")); + + // Verify 2048-bit key (check modulus size) + if let Some(n) = private_jwk.parameter("n") { + if let Some(n_str) = n.as_str() { + let decoded = base64ct::Base64UrlUnpadded::decode_vec(n_str) + .expect("Failed to decode modulus"); + // 2048-bit key = 256 bytes modulus + assert_eq!(decoded.len(), 256); + } + } + } + + #[tokio::test] + async fn test_jwks_manager_persists_private_key() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cfg = test_keys_config(&temp_dir); + + let _manager = JwksManager::new(cfg.clone()) + .await + .expect("Failed to create JwksManager"); + + // Verify private key file exists + assert!(cfg.private_key_path.exists()); + + // Verify it contains valid JSON JWK + let content = fs::read_to_string(&cfg.private_key_path) + .expect("Failed to read private key"); + let jwk: Jwk = serde_json::from_str(&content) + .expect("Failed to parse private key JSON"); + + assert_eq!(jwk.key_type(), "RSA"); + assert!(jwk.parameter("d").is_some()); // Private exponent exists + } + + #[tokio::test] + async fn test_jwks_manager_persists_jwks() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cfg = test_keys_config(&temp_dir); + + let _manager = JwksManager::new(cfg.clone()) + .await + .expect("Failed to create JwksManager"); + + // Verify JWKS file exists + assert!(cfg.jwks_path.exists()); + + // Verify it contains valid JWKS structure + let content = fs::read_to_string(&cfg.jwks_path) + .expect("Failed to read JWKS"); + let jwks: Value = serde_json::from_str(&content) + .expect("Failed to parse JWKS JSON"); + + assert!(jwks.get("keys").is_some()); + assert!(jwks["keys"].is_array()); + assert_eq!(jwks["keys"].as_array().unwrap().len(), 1); + + // Verify public key (should not have private parameters) + let public_key = &jwks["keys"][0]; + assert!(public_key.get("n").is_some()); // Modulus + assert!(public_key.get("e").is_some()); // Exponent + assert!(public_key.get("d").is_none()); // No private exponent + } + + #[tokio::test] + async fn test_jwks_manager_loads_existing() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cfg = test_keys_config(&temp_dir); + + // Create first manager + let manager1 = JwksManager::new(cfg.clone()) + .await + .expect("Failed to create first JwksManager"); + + let kid1 = manager1.private_jwk().key_id().unwrap().to_string(); + + // Create second manager - should reuse the same key + let manager2 = JwksManager::new(cfg.clone()) + .await + .expect("Failed to create second JwksManager"); + + let kid2 = manager2.private_jwk().key_id().unwrap().to_string(); + + // Verify same key was loaded + assert_eq!(kid1, kid2); + + // Verify modulus is identical (same key) + let jwk1 = manager1.private_jwk(); + let jwk2 = manager2.private_jwk(); + + assert_eq!( + jwk1.parameter("n"), + jwk2.parameter("n") + ); + } + + #[tokio::test] + async fn test_sign_jwt_rs256() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cfg = test_keys_config(&temp_dir); + + let manager = JwksManager::new(cfg.clone()) + .await + .expect("Failed to create JwksManager"); + + // Create a test payload + let mut payload = JwtPayload::new(); + payload.set_issuer("https://example.com"); + payload.set_subject("user123"); + payload.set_expires_at(&(chrono::Utc::now() + chrono::Duration::hours(1))); + + // Sign the JWT + let token = manager.sign_jwt_rs256(&payload) + .expect("Failed to sign JWT"); + + // Verify token is not empty and has 3 parts (header.payload.signature) + assert!(!token.is_empty()); + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3); + + // Decode and verify header contains kid + let header_json = base64ct::Base64UrlUnpadded::decode_vec(parts[0]) + .expect("Failed to decode header"); + let header: Value = serde_json::from_slice(&header_json) + .expect("Failed to parse header"); + + assert_eq!(header["alg"], "RS256"); + assert_eq!(header["kid"], "test-kid-123"); + } + + #[tokio::test] + async fn test_jwks_json_format() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cfg = test_keys_config(&temp_dir); + + let manager = JwksManager::new(cfg) + .await + .expect("Failed to create JwksManager"); + + let jwks = manager.jwks_json(); + + // Verify JWKS structure + assert!(jwks.is_object()); + assert!(jwks.get("keys").is_some()); + assert!(jwks["keys"].is_array()); + + let keys = jwks["keys"].as_array().unwrap(); + assert_eq!(keys.len(), 1); + + // Verify first key structure + let key = &keys[0]; + assert_eq!(key["kty"], "RSA"); + assert_eq!(key["use"], "sig"); + assert_eq!(key["alg"], "RS256"); + assert_eq!(key["kid"], "test-kid-123"); + assert!(key.get("n").is_some()); + assert!(key.get("e").is_some()); + assert!(key.get("d").is_none()); // Public key only + } + + #[test] + fn test_random_kid_uniqueness() { + // Generate multiple kids + let kid1 = random_kid(); + let kid2 = random_kid(); + let kid3 = random_kid(); + + // Verify they're all different + assert_ne!(kid1, kid2); + assert_ne!(kid2, kid3); + assert_ne!(kid1, kid3); + + // Verify length (16 bytes base64url-encoded) + // 16 bytes = 128 bits, base64url without padding is 22 chars + assert_eq!(kid1.len(), 22); + assert_eq!(kid2.len(), 22); + assert_eq!(kid3.len(), 22); + + // Verify all are valid base64url + for kid in [kid1, kid2, kid3] { + assert!(kid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b4fe3ae --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,17 @@ +//! Barycenter - OpenID Connect Identity Provider +//! +//! This library provides the core functionality for the Barycenter OpenID Connect IdP. +//! It exposes all modules for testing purposes. + +pub mod admin_graphql; +pub mod admin_mutations; +pub mod entities; +pub mod errors; +pub mod jobs; +pub mod jwks; +pub mod session; +pub mod settings; +pub mod storage; +pub mod user_sync; +pub mod web; +pub mod webauthn_manager; diff --git a/src/main.rs b/src/main.rs index aa1aae4..c19d231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,4 @@ -mod admin_graphql; -mod admin_mutations; -mod entities; -mod errors; -mod jobs; -mod jwks; -mod session; -mod settings; -mod storage; -mod user_sync; -mod web; - +use barycenter::*; use clap::Parser; use miette::{IntoDiagnostic, Result}; use sea_orm_migration::MigratorTrait; @@ -75,6 +64,13 @@ async fn main() -> Result<()> { // init jwks (generate if missing) let jwks_mgr = jwks::JwksManager::new(settings.keys.clone()).await?; + // init webauthn manager + let issuer_url = url::Url::parse(&settings.issuer()).into_diagnostic()?; + let rp_id = issuer_url + .host_str() + .ok_or_else(|| miette::miette!("Invalid issuer URL: missing host"))?; + let webauthn_mgr = webauthn_manager::WebAuthnManager::new(rp_id, &issuer_url).await?; + // build admin GraphQL schemas let seaography_schema = admin_graphql::build_seaography_schema(db.clone()); let jobs_schema = admin_graphql::build_jobs_schema(db.clone()); @@ -83,7 +79,15 @@ async fn main() -> Result<()> { let _scheduler = jobs::init_scheduler(db.clone()).await?; // start web server (includes both public and admin servers) - web::serve(settings, db, jwks_mgr, seaography_schema, jobs_schema).await?; + web::serve( + settings, + db, + jwks_mgr, + webauthn_mgr, + seaography_schema, + jobs_schema, + ) + .await?; } } diff --git a/src/settings.rs b/src/settings.rs index 2eb2afc..2c8d4ce 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -145,3 +145,167 @@ impl Settings { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_settings_load_defaults() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config_path = temp_dir.path().join("nonexistent.toml"); + + // Load settings with nonexistent file - should use defaults + let settings = Settings::load(config_path.to_str().unwrap()) + .expect("Failed to load settings"); + + assert_eq!(settings.server.host, "0.0.0.0"); + assert_eq!(settings.server.port, 8080); + assert_eq!(settings.server.allow_public_registration, false); + assert_eq!(settings.database.url, "sqlite://barycenter.db?mode=rwc"); + assert_eq!(settings.keys.alg, "RS256"); + } + + #[test] + fn test_settings_load_from_file() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config_path = temp_dir.path().join("test_config.toml"); + + // Write a test config file + let config_content = r#" +[server] +host = "127.0.0.1" +port = 9090 +public_base_url = "https://idp.example.com" +allow_public_registration = true + +[database] +url = "postgresql://user:pass@localhost/testdb" + +[keys] +alg = "RS256" +jwks_path = "test_jwks.json" +private_key_path = "test_private.pem" +"#; + fs::write(&config_path, config_content).expect("Failed to write config"); + + // Load settings + let settings = Settings::load(config_path.to_str().unwrap()) + .expect("Failed to load settings"); + + assert_eq!(settings.server.host, "127.0.0.1"); + assert_eq!(settings.server.port, 9090); + assert_eq!( + settings.server.public_base_url, + Some("https://idp.example.com".to_string()) + ); + assert_eq!(settings.server.allow_public_registration, true); + assert_eq!(settings.database.url, "postgresql://user:pass@localhost/testdb"); + } + + #[test] + fn test_settings_env_override() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config_path = temp_dir.path().join("test_config.toml"); + + // Write a base config + let config_content = r#" +[server] +host = "127.0.0.1" +port = 8080 +"#; + fs::write(&config_path, config_content).expect("Failed to write config"); + + // Set environment variable + env::set_var("BARYCENTER__SERVER__PORT", "9999"); + env::set_var("BARYCENTER__SERVER__HOST", "192.168.1.1"); + + // Load settings - env should override file + let settings = Settings::load(config_path.to_str().unwrap()) + .expect("Failed to load settings"); + + assert_eq!(settings.server.host, "192.168.1.1"); + assert_eq!(settings.server.port, 9999); + + // Cleanup + env::remove_var("BARYCENTER__SERVER__PORT"); + env::remove_var("BARYCENTER__SERVER__HOST"); + } + + #[test] + fn test_settings_issuer_with_public_base_url() { + let mut settings = Settings::default(); + settings.server.public_base_url = Some("https://idp.example.com".to_string()); + + let issuer = settings.issuer(); + assert_eq!(issuer, "https://idp.example.com"); + } + + #[test] + fn test_settings_issuer_with_trailing_slash() { + let mut settings = Settings::default(); + settings.server.public_base_url = Some("https://idp.example.com/".to_string()); + + let issuer = settings.issuer(); + // Should trim trailing slash + assert_eq!(issuer, "https://idp.example.com"); + } + + #[test] + fn test_settings_issuer_fallback() { + let mut settings = Settings::default(); + settings.server.host = "localhost".to_string(); + settings.server.port = 3000; + settings.server.public_base_url = None; + + let issuer = settings.issuer(); + assert_eq!(issuer, "http://localhost:3000"); + } + + #[test] + fn test_settings_path_normalization() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config_path = temp_dir.path().join("test_config.toml"); + + // Write config with relative paths + let config_content = r#" +[server] +host = "127.0.0.1" +port = 8080 + +[database] +url = "sqlite://test.db" + +[keys] +alg = "RS256" +jwks_path = "relative/jwks.json" +private_key_path = "relative/private.pem" +"#; + fs::write(&config_path, config_content).expect("Failed to write config"); + + let settings = Settings::load(config_path.to_str().unwrap()) + .expect("Failed to load settings"); + + // Paths should be normalized to absolute + assert!(settings.keys.jwks_path.is_absolute()); + assert!(settings.keys.private_key_path.is_absolute()); + + // Should end with the relative path components + assert!(settings.keys.jwks_path.ends_with("relative/jwks.json")); + assert!(settings.keys.private_key_path.ends_with("relative/private.pem")); + } + + #[test] + fn test_allow_public_registration_default() { + let settings = Settings::default(); + + // Should default to false (secure by default) + assert_eq!(settings.server.allow_public_registration, false); + + // Also test the default function directly + assert_eq!(default_allow_public_registration(), false); + } +} diff --git a/src/storage.rs b/src/storage.rs index bc2dfea..c7a5a06 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -376,6 +376,8 @@ pub async fn create_user( email_verified: Set(0), created_at: Set(created_at), enabled: Set(1), + requires_2fa: Set(0), + passkey_enrolled_at: Set(None), }; user.insert(db).await?; @@ -388,6 +390,8 @@ pub async fn create_user( email_verified: 0, created_at, enabled: 1, + requires_2fa: 0, + passkey_enrolled_at: None, }) } @@ -701,3 +705,701 @@ pub async fn cleanup_expired_refresh_tokens(db: &DatabaseConnection) -> Result DatabaseConnection { + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + let db_path = temp_file.path().to_str().expect("Invalid temp file path"); + let db_url = format!("sqlite://{}?mode=rwc", db_path); + + let db = Database::connect(&db_url) + .await + .expect("Failed to connect to test database"); + + migration::Migrator::up(&db, None) + .await + .expect("Failed to run migrations"); + + db + } + + // ============================================================================ + // Client Operations Tests + // ============================================================================ + + #[tokio::test] + async fn test_create_client() { + let db = test_db().await; + + let client = create_client( + &db, + NewClient { + client_name: Some("Test Client".to_string()), + redirect_uris: vec!["http://localhost:3000/callback".to_string()], + }, + ) + .await + .expect("Failed to create client"); + + assert!(!client.client_id.is_empty()); + assert!(!client.client_secret.is_empty()); + assert_eq!(client.client_name, Some("Test Client".to_string())); + } + + #[tokio::test] + async fn test_get_client() { + let db = test_db().await; + + let created = create_client( + &db, + NewClient { + client_name: Some("Test Client".to_string()), + redirect_uris: vec!["http://localhost:3000/callback".to_string()], + }, + ) + .await + .expect("Failed to create client"); + + let retrieved = get_client(&db, &created.client_id) + .await + .expect("Failed to get client") + .expect("Client not found"); + + assert_eq!(retrieved.client_id, created.client_id); + assert_eq!(retrieved.client_secret, created.client_secret); + } + + #[tokio::test] + async fn test_get_client_not_found() { + let db = test_db().await; + + let result = get_client(&db, "nonexistent_client_id") + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_client_redirect_uris_parsing() { + let db = test_db().await; + + let uris = vec![ + "http://localhost:3000/callback".to_string(), + "http://localhost:3000/callback2".to_string(), + ]; + + let client = create_client( + &db, + NewClient { + client_name: Some("Multi-URI Client".to_string()), + redirect_uris: uris.clone(), + }, + ) + .await + .expect("Failed to create client"); + + let retrieved = get_client(&db, &client.client_id) + .await + .expect("Failed to get client") + .expect("Client not found"); + + let parsed_uris: Vec = serde_json::from_str(&retrieved.redirect_uris) + .expect("Failed to parse redirect_uris"); + + assert_eq!(parsed_uris, uris); + } + + // ============================================================================ + // Auth Code Operations Tests + // ============================================================================ + + #[tokio::test] + async fn test_issue_auth_code() { + let db = test_db().await; + + let code = issue_auth_code( + &db, + "test_subject", + "test_client_id", + "openid profile", + Some("test_nonce"), + "http://localhost:3000/callback", + Some("challenge_string"), + Some("S256"), + ) + .await + .expect("Failed to issue auth code"); + + assert!(!code.is_empty()); + } + + #[tokio::test] + async fn test_consume_auth_code_success() { + let db = test_db().await; + + let code = issue_auth_code( + &db, + "test_subject", + "test_client_id", + "openid profile", + Some("test_nonce"), + "http://localhost:3000/callback", + Some("challenge_string"), + Some("S256"), + ) + .await + .expect("Failed to issue auth code"); + + let auth_code = consume_auth_code(&db, &code) + .await + .expect("Failed to consume auth code") + .expect("Auth code not found"); + + assert_eq!(auth_code.subject, "test_subject"); + assert_eq!(auth_code.client_id, "test_client_id"); + assert_eq!(auth_code.scope, "openid profile"); + } + + #[tokio::test] + async fn test_consume_auth_code_already_consumed() { + let db = test_db().await; + + let code = issue_auth_code( + &db, + "test_subject", + "test_client_id", + "openid profile", + None, + "http://localhost:3000/callback", + None, + None, + ) + .await + .expect("Failed to issue auth code"); + + // First consumption succeeds + consume_auth_code(&db, &code) + .await + .expect("Failed to consume auth code") + .expect("Auth code not found"); + + // Second consumption returns None + let result = consume_auth_code(&db, &code) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_consume_auth_code_expired() { + let db = test_db().await; + + let code = issue_auth_code( + &db, + "test_subject", + "test_client_id", + "openid profile", + None, + "http://localhost:3000/callback", + None, + None, + ) + .await + .expect("Failed to issue auth code"); + + // Manually expire the code by setting expires_at to past + use entities::auth_code::{ActiveModel, Column, Entity}; + use sea_orm::ActiveValue::Set; + use sea_orm::EntityTrait; + + let past_timestamp = chrono::Utc::now().timestamp() - 600; // 10 minutes ago + + Entity::update_many() + .col_expr(Column::ExpiresAt, sea_orm::sea_query::Expr::value(past_timestamp)) + .filter(Column::Code.eq(&code)) + .exec(&db) + .await + .expect("Failed to update expiry"); + + // Consumption should return None for expired code + let result = consume_auth_code(&db, &code) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_auth_code_pkce_storage() { + let db = test_db().await; + + let code = issue_auth_code( + &db, + "test_subject", + "test_client_id", + "openid profile", + None, + "http://localhost:3000/callback", + Some("challenge_string"), + Some("S256"), + ) + .await + .expect("Failed to issue auth code"); + + let auth_code = consume_auth_code(&db, &code) + .await + .expect("Failed to consume auth code") + .expect("Auth code not found"); + + assert_eq!(auth_code.code_challenge, Some("challenge_string".to_string())); + assert_eq!(auth_code.code_challenge_method, Some("S256".to_string())); + } + + // ============================================================================ + // Token Operations Tests + // ============================================================================ + + #[tokio::test] + async fn test_issue_access_token() { + let db = test_db().await; + + let token = issue_access_token(&db, "test_subject", "test_client_id", "openid profile") + .await + .expect("Failed to issue access token"); + + assert!(!token.is_empty()); + } + + #[tokio::test] + async fn test_get_access_token_valid() { + let db = test_db().await; + + let token = issue_access_token(&db, "test_subject", "test_client_id", "openid profile") + .await + .expect("Failed to issue access token"); + + let access_token = get_access_token(&db, &token) + .await + .expect("Failed to get access token") + .expect("Access token not found"); + + assert_eq!(access_token.subject, "test_subject"); + assert_eq!(access_token.scope, "openid profile"); + assert_eq!(access_token.revoked, 0); + } + + #[tokio::test] + async fn test_get_access_token_expired() { + let db = test_db().await; + + let token = issue_access_token(&db, "test_subject", "test_client_id", "openid profile") + .await + .expect("Failed to issue access token"); + + // Manually expire the token + use entities::access_token::{Column, Entity}; + use sea_orm::EntityTrait; + + let past_timestamp = chrono::Utc::now().timestamp() - 7200; // 2 hours ago + + Entity::update_many() + .col_expr(Column::ExpiresAt, sea_orm::sea_query::Expr::value(past_timestamp)) + .filter(Column::Token.eq(&token)) + .exec(&db) + .await + .expect("Failed to update expiry"); + + // Should return None for expired token + let result = get_access_token(&db, &token) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_get_access_token_revoked() { + let db = test_db().await; + + let token = issue_access_token(&db, "test_subject", "test_client_id", "openid profile") + .await + .expect("Failed to issue access token"); + + // Manually revoke the token + use entities::access_token::{Column, Entity}; + use sea_orm::EntityTrait; + + Entity::update_many() + .col_expr(Column::Revoked, sea_orm::sea_query::Expr::value(1)) + .filter(Column::Token.eq(&token)) + .exec(&db) + .await + .expect("Failed to revoke token"); + + // Should return None for revoked token + let result = get_access_token(&db, &token) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_refresh_token_rotation() { + let db = test_db().await; + + // Create initial refresh token + let token1 = issue_refresh_token(&db, "test_subject", "test_client_id", "openid profile", None) + .await + .expect("Failed to issue refresh token"); + + // Rotate to new token + let token2 = issue_refresh_token(&db, "test_subject", "test_client_id", "openid profile", Some(&token1)) + .await + .expect("Failed to rotate refresh token"); + + // Verify parent chain + let rt2 = get_refresh_token(&db, &token2) + .await + .expect("Failed to get token") + .expect("Token not found"); + + assert_eq!(rt2.parent_refresh_token, Some(token1)); + } + + #[tokio::test] + async fn test_revoke_refresh_token() { + let db = test_db().await; + + let token = issue_refresh_token(&db, "test_subject", "test_client_id", "openid profile", None) + .await + .expect("Failed to issue refresh token"); + + revoke_refresh_token(&db, &token) + .await + .expect("Failed to revoke token"); + + // Should return None for revoked token + let result = get_refresh_token(&db, &token) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + // ============================================================================ + // User Management Tests + // ============================================================================ + + #[tokio::test] + async fn test_create_user() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + assert!(!user.subject.is_empty()); + assert_eq!(user.username, "testuser"); + assert!(!user.password_hash.is_empty()); + // Verify it's Argon2 hash format + assert!(user.password_hash.starts_with("$argon2")); + } + + #[tokio::test] + async fn test_get_user_by_username() { + let db = test_db().await; + + let created = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let retrieved = get_user_by_username(&db, "testuser") + .await + .expect("Failed to get user") + .expect("User not found"); + + assert_eq!(retrieved.subject, created.subject); + assert_eq!(retrieved.username, "testuser"); + } + + #[tokio::test] + async fn test_verify_user_password_success() { + let db = test_db().await; + + create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let user = verify_user_password(&db, "testuser", "password123") + .await + .expect("Failed to verify password") + .expect("Verification failed"); + + assert_eq!(user.username, "testuser"); + } + + #[tokio::test] + async fn test_verify_user_password_wrong() { + let db = test_db().await; + + create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let result = verify_user_password(&db, "testuser", "wrongpassword") + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_verify_user_password_disabled() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + // Disable the user + update_user(&db, &user.subject, false, None, None) + .await + .expect("Failed to disable user"); + + // Verification should fail for disabled user + let result = verify_user_password(&db, "testuser", "password123") + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_update_user() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + update_user(&db, &user.subject, false, Some("test@example.com"), Some(true)) + .await + .expect("Failed to update user"); + + let updated = get_user_by_subject(&db, &user.subject) + .await + .expect("Failed to get user") + .expect("User not found"); + + assert_eq!(updated.enabled, 0); + assert_eq!(updated.email, Some("test@example.com".to_string())); + assert_eq!(updated.requires_2fa, 1); + } + + #[tokio::test] + async fn test_update_user_email() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + update_user(&db, &user.subject, true, Some("new@example.com"), None) + .await + .expect("Failed to update email"); + + let updated = get_user_by_subject(&db, &user.subject) + .await + .expect("Failed to get user") + .expect("User not found"); + + assert_eq!(updated.email, Some("new@example.com".to_string())); + } + + // ============================================================================ + // Session Management Tests + // ============================================================================ + + #[tokio::test] + async fn test_create_session() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let auth_time = chrono::Utc::now().timestamp(); + let session = create_session(&db, &user.subject, auth_time, 3600, None, None) + .await + .expect("Failed to create session"); + + assert!(!session.session_id.is_empty()); + assert_eq!(session.subject, user.subject); + } + + #[tokio::test] + async fn test_get_session_valid() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let auth_time = chrono::Utc::now().timestamp(); + let created = create_session(&db, &user.subject, auth_time, 3600, None, None) + .await + .expect("Failed to create session"); + + let retrieved = get_session(&db, &created.session_id) + .await + .expect("Failed to get session") + .expect("Session not found"); + + assert_eq!(retrieved.session_id, created.session_id); + assert_eq!(retrieved.subject, user.subject); + } + + #[tokio::test] + async fn test_get_session_expired() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let auth_time = chrono::Utc::now().timestamp() - 7200; // 2 hours ago + let session = create_session(&db, &user.subject, auth_time, 3600, None, None) // 1 hour TTL + .await + .expect("Failed to create session"); + + // Should return None for expired session + let result = get_session(&db, &session.session_id) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_delete_session() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + let auth_time = chrono::Utc::now().timestamp(); + let session = create_session(&db, &user.subject, auth_time, 3600, None, None) + .await + .expect("Failed to create session"); + + delete_session(&db, &session.session_id) + .await + .expect("Failed to delete session"); + + let result = get_session(&db, &session.session_id) + .await + .expect("Query failed"); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_cleanup_expired_sessions() { + let db = test_db().await; + + let user = create_user(&db, "testuser", "password123", None) + .await + .expect("Failed to create user"); + + // Create an expired session + let past_auth_time = chrono::Utc::now().timestamp() - 7200; + create_session(&db, &user.subject, past_auth_time, 3600, None, None) + .await + .expect("Failed to create session"); + + let deleted_count = cleanup_expired_sessions(&db) + .await + .expect("Failed to cleanup sessions"); + + assert_eq!(deleted_count, 1); + } + + // ============================================================================ + // Property Storage Tests + // ============================================================================ + + #[tokio::test] + async fn test_set_and_get_property() { + let db = test_db().await; + + let value = serde_json::json!({"key": "value"}); + set_property(&db, "owner1", "test_key", &value) + .await + .expect("Failed to set property"); + + let retrieved = get_property(&db, "owner1", "test_key") + .await + .expect("Failed to get property") + .expect("Property not found"); + + assert_eq!(retrieved, value); + } + + #[tokio::test] + async fn test_set_property_upsert() { + let db = test_db().await; + + let value1 = serde_json::json!({"version": 1}); + set_property(&db, "owner1", "test_key", &value1) + .await + .expect("Failed to set property"); + + let value2 = serde_json::json!({"version": 2}); + set_property(&db, "owner1", "test_key", &value2) + .await + .expect("Failed to update property"); + + let retrieved = get_property(&db, "owner1", "test_key") + .await + .expect("Failed to get property") + .expect("Property not found"); + + assert_eq!(retrieved, value2); + } + + #[tokio::test] + async fn test_property_complex_json() { + let db = test_db().await; + + let value = serde_json::json!({ + "nested": { + "array": [1, 2, 3], + "object": { + "key": "value" + } + } + }); + + set_property(&db, "owner1", "complex", &value) + .await + .expect("Failed to set property"); + + let retrieved = get_property(&db, "owner1", "complex") + .await + .expect("Failed to get property") + .expect("Property not found"); + + assert_eq!(retrieved, value); + } +} diff --git a/src/web.rs b/src/web.rs index 4708581..1de18c0 100644 --- a/src/web.rs +++ b/src/web.rs @@ -25,12 +25,14 @@ use sha2::{Digest, Sha256}; use std::net::SocketAddr; use std::sync::Arc; use std::time::SystemTime; +use tower_http::services::ServeDir; #[derive(Clone)] pub struct AppState { pub settings: Arc, pub db: DatabaseConnection, pub jwks: JwksManager, + pub webauthn: crate::webauthn_manager::WebAuthnManager, } // Security headers middleware @@ -56,10 +58,10 @@ async fn security_headers(request: Request, next: Next) -> impl IntoRespon HeaderValue::from_static("1; mode=block"), ); - // Content-Security-Policy: Restrict resource loading + // Content-Security-Policy: Restrict resource loading (allows WASM for passkeys) headers.insert( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'"), + HeaderValue::from_static("default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'"), ); // Referrer-Policy: Control referrer information @@ -81,6 +83,7 @@ pub async fn serve( settings: Settings, db: DatabaseConnection, jwks: JwksManager, + webauthn: crate::webauthn_manager::WebAuthnManager, seaography_schema: async_graphql::dynamic::Schema, jobs_schema: async_graphql::Schema< crate::admin_mutations::AdminQuery, @@ -92,6 +95,7 @@ pub async fn serve( settings: Arc::new(settings), db, jwks, + webauthn, }; // NOTE: Rate limiting should be implemented at the reverse proxy level (nginx, traefik, etc.) @@ -111,10 +115,23 @@ pub async fn serve( ) .route("/federation/trust-anchors", get(trust_anchors)) .route("/login", get(login_page).post(login_submit)) + .route("/login/2fa", get(login_2fa_page)) .route("/logout", get(logout)) .route("/authorize", get(authorize)) .route("/token", post(token)) - .route("/userinfo", get(userinfo)); + .route("/userinfo", get(userinfo)) + // WebAuthn / Passkey endpoints + .route("/webauthn/register/start", post(passkey_register_start)) + .route("/webauthn/register/finish", post(passkey_register_finish)) + .route("/webauthn/authenticate/start", post(passkey_auth_start)) + .route("/webauthn/authenticate/finish", post(passkey_auth_finish)) + .route("/webauthn/2fa/start", post(passkey_2fa_start)) + .route("/webauthn/2fa/finish", post(passkey_2fa_finish)) + .route("/account/passkeys", get(list_passkeys)) + .route( + "/account/passkeys/{credential_id}", + axum::routing::delete(delete_passkey_handler).patch(update_passkey_handler), + ); // Conditionally add public registration route if state.settings.server.allow_public_registration { @@ -124,7 +141,9 @@ pub async fn serve( tracing::info!("Public user registration is DISABLED - use admin API"); } + // Serve static files (WASM, JS, etc.) let router = router + .nest_service("/static", ServeDir::new("static")) .layer(middleware::from_fn(security_headers)) .with_state(state.clone()); @@ -242,6 +261,16 @@ fn url_append_query(mut base: String, params: &[(&str, String)]) -> String { base } +/// Check if the requested scope contains high-value scopes that require 2FA +fn is_high_value_scope(scope: &str) -> bool { + // Define scopes that require elevated authentication (2FA) + let high_value_scopes = ["admin", "payment", "transfer", "delete"]; + + scope + .split_whitespace() + .any(|s| high_value_scopes.contains(&s)) +} + fn oauth_error_redirect( redirect_uri: &str, state: Option<&str>, @@ -384,9 +413,9 @@ async fn authorize( false }; - let (subject, auth_time) = match session_opt { + let (subject, auth_time, session) = match session_opt { Some(sess) if sess.expires_at > chrono::Utc::now().timestamp() && !needs_fresh_auth => { - (sess.subject.clone(), Some(sess.auth_time)) + (sess.subject.clone(), Some(sess.auth_time), Some(sess)) } _ => { // No valid session or session too old @@ -442,6 +471,85 @@ async fn authorize( } }; + // Check if 2FA is required for this authorization request + if let Some(sess) = &session { + // Get user to check 2FA requirements + let user = match storage::get_user_by_subject(&state.db, &subject).await { + Ok(Some(u)) => u, + Ok(None) => { + return oauth_error_redirect( + &q.redirect_uri, + q.state.as_deref(), + "server_error", + "user not found", + ) + .into_response(); + } + Err(_) => { + return oauth_error_redirect( + &q.redirect_uri, + q.state.as_deref(), + "server_error", + "db error", + ) + .into_response(); + } + }; + + // Determine if 2FA is required + let requires_2fa = user.requires_2fa == 1 // Admin-enforced 2FA + || is_high_value_scope(&q.scope) // Context-based: high-value scope + || q.max_age.as_ref().and_then(|ma| ma.parse::().ok()) + .map_or(false, |ma| ma < 300); // Context-based: max_age < 5 minutes + + // If 2FA required but not verified, redirect to 2FA page + if requires_2fa && sess.mfa_verified == 0 { + // Build return_to URL with all parameters + let mut return_params = vec![ + ("client_id", q.client_id.clone()), + ("redirect_uri", q.redirect_uri.clone()), + ("response_type", q.response_type.clone()), + ("scope", q.scope.clone()), + ("code_challenge", code_challenge.clone()), + ("code_challenge_method", ccm.clone()), + ]; + if let Some(s) = &q.state { + return_params.push(("state", s.clone())); + } + if let Some(n) = &q.nonce { + return_params.push(("nonce", n.clone())); + } + if let Some(p) = &q.prompt { + return_params.push(("prompt", p.clone())); + } + if let Some(d) = &q.display { + return_params.push(("display", d.clone())); + } + if let Some(ui) = &q.ui_locales { + return_params.push(("ui_locales", ui.clone())); + } + if let Some(cl) = &q.claims_locales { + return_params.push(("claims_locales", cl.clone())); + } + if let Some(ma) = &q.max_age { + return_params.push(("max_age", ma.clone())); + } + if let Some(acr) = &q.acr_values { + return_params.push(("acr_values", acr.clone())); + } + + let return_to = url_append_query( + "/authorize".to_string(), + &return_params + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ); + let tfa_url = format!("/login/2fa?return_to={}", urlencoded(&return_to)); + return Redirect::temporary(&tfa_url).into_response(); + } + } + let scope = q.scope.clone(); let nonce = q.nonce.clone(); @@ -501,6 +609,8 @@ async fn authorize( nonce.as_deref(), auth_time, None, + session.as_ref().and_then(|s| s.amr.as_deref()), + session.as_ref().and_then(|s| s.acr.as_deref()), ) .await { @@ -561,6 +671,8 @@ async fn authorize( nonce.as_deref(), auth_time, Some(&access.token), + session.as_ref().and_then(|s| s.amr.as_deref()), + session.as_ref().and_then(|s| s.acr.as_deref()), ) .await { @@ -606,6 +718,8 @@ async fn build_id_token( nonce: Option<&str>, auth_time: Option, access_token: Option<&str>, // For at_hash calculation + amr: Option<&str>, // Authentication Method References (JSON array) + acr: Option<&str>, // Authentication Context Reference ) -> Result { let now = SystemTime::now(); let exp_unix = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64 + 3600; @@ -625,6 +739,21 @@ async fn build_id_token( let _ = payload.set_claim("auth_time", Some(serde_json::json!(at))); } + // Add AMR claim (Authentication Method References) + if let Some(amr_json) = amr { + if let Ok(amr_value) = serde_json::from_str::(amr_json) { + let _ = payload.set_claim("amr", Some(amr_value)); + } + } + + // Add ACR claim (Authentication Context Reference) + if let Some(acr_value) = acr { + let _ = payload.set_claim( + "acr", + Some(serde_json::Value::String(acr_value.to_string())), + ); + } + // Add at_hash if access_token is provided (for id_token token response type) if let Some(token) = access_token { let mut hasher = Sha256::new(); @@ -809,6 +938,16 @@ async fn handle_authorization_code_grant( } }; + // Get session to include AMR/ACR claims in ID token + let session_opt = if let Some(cookie) = SessionCookie::from_headers(&headers) { + storage::get_session(&state.db, &cookie.session_id) + .await + .ok() + .flatten() + } else { + None + }; + // Build ID Token using helper function let id_token = match build_id_token( &state, @@ -817,6 +956,8 @@ async fn handle_authorization_code_grant( code_row.nonce.as_deref(), code_row.auth_time, Some(&access.token), + session_opt.as_ref().and_then(|s| s.amr.as_deref()), + session_opt.as_ref().and_then(|s| s.acr.as_deref()), ) .await { @@ -960,6 +1101,16 @@ async fn handle_refresh_token_grant( } }; + // Get session to include AMR/ACR claims in ID token + let session_opt = if let Some(cookie) = SessionCookie::from_headers(&headers) { + storage::get_session(&state.db, &cookie.session_id) + .await + .ok() + .flatten() + } else { + None + }; + // Build ID Token (no nonce, no auth_time for refresh grants) let id_token = match build_id_token( &state, @@ -968,6 +1119,8 @@ async fn handle_refresh_token_grant( None, None, Some(&access.token), + session_opt.as_ref().and_then(|s| s.amr.as_deref()), + session_opt.as_ref().and_then(|s| s.acr.as_deref()), ) .await { @@ -1295,22 +1448,108 @@ async fn login_page(Query(q): Query) -> impl IntoResponse { input[type="text"], input[type="password"] {{ width: 100%; padding: 8px; margin-top: 5px; box-sizing: border-box; }} button {{ margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; border: none; cursor: pointer; }} button:hover {{ background-color: #0056b3; }} + .divider {{ margin: 30px 0; text-align: center; color: #666; }} + .divider::before, .divider::after {{ content: ""; display: inline-block; width: 40%; height: 1px; background: #ccc; vertical-align: middle; }} + .divider::before {{ margin-right: 10px; }} + .divider::after {{ margin-left: 10px; }} + #passkey-status {{ margin: 10px 0; padding: 10px; background: #e7f3ff; border-left: 4px solid #007bff; display: none; }} +

Login

{error_html} +
+
or sign in with password
- +
@@ -1377,6 +1616,17 @@ async fn login_submit( } }; + // Check if user requires 2FA + let user = match storage::get_user_by_subject(&state.db, &subject).await { + Ok(Some(u)) => u, + _ => { + let return_to = urlencoded(&form.return_to.unwrap_or_default()); + let error = urlencoded("User not found"); + return Redirect::temporary(&format!("/login?error={error}&return_to={return_to}")) + .into_response(); + } + }; + // Create session let user_agent = headers .get(axum::http::header::USER_AGENT) @@ -1393,11 +1643,19 @@ async fn login_submit( } }; - // Set cookie and redirect + // Set cookie let cookie = SessionCookie::new(session.session_id); let cookie_header = cookie.to_cookie_header(&state.settings); - let redirect_url = form.return_to.unwrap_or_else(|| "/".to_string()); + // If user requires 2FA, redirect to 2FA page with partial session + let redirect_url = if user.requires_2fa == 1 { + // Partial session - redirect to 2FA + let return_to = urlencoded(&form.return_to.unwrap_or_default()); + format!("/login/2fa?return_to={return_to}") + } else { + // Full session - redirect to destination + form.return_to.unwrap_or_else(|| "/".to_string()) + }; Response::builder() .status(StatusCode::SEE_OTHER) @@ -1425,6 +1683,113 @@ async fn logout(State(state): State, headers: HeaderMap) -> impl IntoR .into_response() } +/// GET /login/2fa - Show 2FA page +async fn login_2fa_page( + State(_state): State, + headers: HeaderMap, + Query(q): Query, +) -> impl IntoResponse { + // Verify user has a partial session + let _cookie = match SessionCookie::from_headers(&headers) { + Some(c) => c, + None => { + // No session - redirect to login + let return_to = urlencoded(&q.return_to.unwrap_or_default()); + return Redirect::temporary(&format!("/login?return_to={return_to}")).into_response(); + } + }; + + let return_to = q.return_to.unwrap_or_else(|| "/".to_string()); + let return_to_escaped = html_escape(&return_to); + let error_html = q + .error + .as_ref() + .map(|e| format!(r#"

{}

"#, html_escape(e))) + .unwrap_or_default(); + + Html(format!( + r#" + + + Two-Factor Authentication + + + + +

Two-Factor Authentication

+

Please use your security key or passkey to complete sign-in.

+ {error_html} + +
+ + + + +"#, + error_html = error_html, + return_to_escaped = return_to_escaped + )) + .into_response() +} + fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -1439,3 +1804,685 @@ fn urlencoded(s: &str) -> String { .trim_start_matches('=') .to_string() } + +// ============================================================================ +// WebAuthn / Passkey Endpoints +// ============================================================================ + +use webauthn_rs::prelude::*; + +// Request/Response types for passkey registration + +#[derive(Debug, Deserialize)] +struct PasskeyRegisterStartRequest { + name: Option, +} + +#[derive(Debug, Serialize)] +struct PasskeyRegisterStartResponse { + options: CreationChallengeResponse, +} + +#[derive(Debug, Deserialize)] +struct PasskeyRegisterFinishRequest { + credential: RegisterPublicKeyCredential, + name: Option, // Friendly name for the passkey +} + +#[derive(Debug, Serialize)] +struct PasskeyRegisterFinishResponse { + verified: bool, + credential_id: String, +} + +// Request/Response types for passkey authentication + +#[derive(Debug, Deserialize)] +struct PasskeyAuthStartRequest { + username: Option, +} + +#[derive(Debug, Serialize)] +struct PasskeyAuthStartResponse { + options: RequestChallengeResponse, +} + +#[derive(Debug, Deserialize)] +struct PasskeyAuthFinishRequest { + credential: PublicKeyCredential, + return_to: Option, +} + +#[derive(Debug, Serialize)] +struct PasskeyAuthFinishResponse { + success: bool, + redirect_url: Option, +} + +// Request/Response types for passkey management + +#[derive(Debug, Serialize)] +struct PasskeyInfo { + credential_id: String, + name: Option, + created_at: i64, + last_used_at: Option, + backup_state: i64, +} + +#[derive(Debug, Deserialize)] +struct UpdatePasskeyRequest { + name: Option, +} + +/// POST /webauthn/register/start +/// Start passkey registration flow - requires valid session +async fn passkey_register_start( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, (StatusCode, String)> { + // Get session from cookie + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Get user info + let user = storage::get_user_by_subject(&state.db, &session.subject) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; + + // Start passkey registration + let user_id = uuid::Uuid::parse_str(&session.subject).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid subject UUID: {}", e), + ) + })?; + + let (ccr, reg_state) = state + .webauthn + .start_passkey_registration(user_id, &user.username, &user.username) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Store challenge + let challenge_b64 = Base64UrlUnpadded::encode_string(&ccr.public_key.challenge); + let options_json = serde_json::to_string(®_state) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + storage::create_webauthn_challenge( + &state.db, + &challenge_b64, + Some(session.subject.clone()), + None, + "registration", + &options_json, + 300, // 5 minutes + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(PasskeyRegisterStartResponse { options: ccr })) +} + +/// POST /webauthn/register/finish +/// Complete passkey registration - requires valid session +async fn passkey_register_finish( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, (StatusCode, String)> { + // Get session + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Get the most recent registration challenge for this subject + let challenge_data = storage::get_latest_webauthn_challenge_by_subject( + &state.db, + &session.subject, + "registration", + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::BAD_REQUEST, + "No registration challenge found or expired".to_string(), + ))?; + + // Verify it's a registration challenge for this user + if challenge_data.challenge_type != "registration" { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid challenge type".to_string(), + )); + } + if challenge_data.subject.as_ref() != Some(&session.subject) { + return Err(( + StatusCode::FORBIDDEN, + "Challenge subject mismatch".to_string(), + )); + } + + // Deserialize registration state + let reg_state: PasskeyRegistration = serde_json::from_str(&challenge_data.options_json) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid state: {}", e), + ) + })?; + + // Finish registration + let passkey = state + .webauthn + .finish_passkey_registration(&req.credential, ®_state) + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Registration failed: {}", e), + ) + })?; + + // Serialize the entire Passkey object for storage + let passkey_json = serde_json::to_string(&passkey).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to serialize passkey: {}", e), + ) + })?; + + // Use credential ID from the request + let cred_id_b64 = Base64UrlUnpadded::encode_string(req.credential.id.as_bytes()); + + storage::create_passkey( + &state.db, + &cred_id_b64, + &session.subject, + &passkey_json, + 0, // counter - TODO: extract from passkey when we understand the API + None, // aaguid + false, // backup_eligible - TODO: extract from passkey + false, // backup_state - TODO: extract from passkey + None, // transports + req.name.clone(), // Name from request + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Delete challenge + storage::delete_webauthn_challenge(&state.db, &challenge_data.challenge) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(PasskeyRegisterFinishResponse { + verified: true, + credential_id: cred_id_b64, + })) +} + +/// POST /webauthn/authenticate/start +/// Start passkey authentication flow - public endpoint +async fn passkey_auth_start( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { + // If username provided, get their passkeys; otherwise allow discoverable + let passkeys = if let Some(username) = &req.username { + let user = storage::get_user_by_username(&state.db, username) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; + + let db_passkeys = storage::get_passkeys_by_subject(&state.db, &user.subject) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if db_passkeys.is_empty() { + return Err((StatusCode::NOT_FOUND, "No passkeys registered".to_string())); + } + + // Convert to webauthn-rs Passkey format + // TODO: This requires understanding the exact Passkey structure + // For now, we'll deserialize the entire Passkey object that we stored + db_passkeys + .into_iter() + .filter_map(|pk| { + // Deserialize the entire Passkey from JSON + serde_json::from_str::(&pk.public_key_cose).ok() + }) + .collect() + } else { + // Discoverable/resident key flow - empty list allows any registered credential + Vec::new() + }; + + // Start authentication + let (rcr, auth_state) = state + .webauthn + .start_passkey_authentication(passkeys) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Store challenge + let challenge_b64 = Base64UrlUnpadded::encode_string(&rcr.public_key.challenge); + let options_json = serde_json::to_string(&auth_state) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Get subject from earlier lookup if username was provided + let subject = if let Some(username) = &req.username { + storage::get_user_by_username(&state.db, username) + .await + .ok() + .flatten() + .map(|u| u.subject) + } else { + None + }; + + storage::create_webauthn_challenge( + &state.db, + &challenge_b64, + subject, + None, + "authentication", + &options_json, + 300, // 5 minutes + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(PasskeyAuthStartResponse { options: rcr })) +} + +/// POST /webauthn/authenticate/finish +/// Complete passkey authentication - creates session +async fn passkey_auth_finish( + State(state): State, + Json(req): Json, +) -> Result { + // Get passkey by credential ID to find the subject + let cred_id_b64 = Base64UrlUnpadded::encode_string(req.credential.id.as_bytes()); + let passkey = storage::get_passkey_by_credential_id(&state.db, &cred_id_b64) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Passkey not found".to_string()))?; + + // Get the most recent authentication challenge for this subject + let challenge_data = storage::get_latest_webauthn_challenge_by_subject( + &state.db, + &passkey.subject, + "authentication", + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::BAD_REQUEST, + "No authentication challenge found or expired".to_string(), + ))?; + + // Deserialize auth state + let auth_state: PasskeyAuthentication = serde_json::from_str(&challenge_data.options_json) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid state: {}", e), + ) + })?; + + // Finish authentication + let _auth_result = state + .webauthn + .finish_passkey_authentication(&req.credential, &auth_state) + .map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + format!("Authentication failed: {}", e), + ) + })?; + + // Update counter (TODO: extract counter from auth_result when we understand the API) + // For now, just update last_used_at + // storage::update_passkey_counter(&state.db, &cred_id_b64, new_counter).await?; + + // Determine AMR based on backup state + let amr = if passkey.backup_eligible == 1 && passkey.backup_state == 1 { + vec!["swk".to_string()] // Software key (cloud synced) + } else { + vec!["hwk".to_string()] // Hardware key + }; + + // Create session + let session = storage::create_session(&state.db, &passkey.subject, 3600, None, None) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Update session with passkey AMR + storage::update_session_auth_context( + &state.db, + &session.session_id, + Some(amr), + Some("aal1".to_string()), + false, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Delete challenge + storage::delete_webauthn_challenge(&state.db, &challenge_data.challenge) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Set session cookie and redirect + let cookie = SessionCookie::new(session.session_id); + let cookie_header = cookie.to_cookie_header(&state.settings); + let redirect_url = req.return_to.unwrap_or_else(|| "/".to_string()); + + Ok(Response::builder() + .status(StatusCode::SEE_OTHER) + .header(axum::http::header::SET_COOKIE, cookie_header) + .header(axum::http::header::LOCATION, redirect_url) + .body(Body::empty()) + .unwrap()) +} + +/// POST /webauthn/2fa/start +/// Start 2FA step-up with passkey - requires partial session +async fn passkey_2fa_start( + State(state): State, + headers: HeaderMap, +) -> Result, (StatusCode, String)> { + // Get session + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Check not already MFA verified + if session.mfa_verified == 1 { + return Err((StatusCode::BAD_REQUEST, "Already MFA verified".to_string())); + } + + // Get user's passkeys + let db_passkeys = storage::get_passkeys_by_subject(&state.db, &session.subject) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if db_passkeys.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + "No passkeys registered for 2FA".to_string(), + )); + } + + // Convert to webauthn-rs format + let passkeys: Vec = db_passkeys + .into_iter() + .filter_map(|pk| { + // Deserialize the entire Passkey from JSON + serde_json::from_str::(&pk.public_key_cose).ok() + }) + .collect(); + + // Start authentication + let (rcr, auth_state) = state + .webauthn + .start_passkey_authentication(passkeys) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Store challenge linked to session + let challenge_b64 = Base64UrlUnpadded::encode_string(&rcr.public_key.challenge); + let options_json = serde_json::to_string(&auth_state) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + storage::create_webauthn_challenge( + &state.db, + &challenge_b64, + Some(session.subject.clone()), + Some(session.session_id.clone()), + "2fa", + &options_json, + 300, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(PasskeyAuthStartResponse { options: rcr })) +} + +/// POST /webauthn/2fa/finish +/// Complete 2FA step-up - upgrades session to MFA +async fn passkey_2fa_finish( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result { + // Get session + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Get the most recent 2FA challenge for this subject + let challenge_data = + storage::get_latest_webauthn_challenge_by_subject(&state.db, &session.subject, "2fa") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::BAD_REQUEST, + "No 2FA challenge found or expired".to_string(), + ))?; + + // Verify the challenge is for this session + if challenge_data.session_id.as_ref() != Some(&session.session_id) { + return Err(( + StatusCode::FORBIDDEN, + "Challenge session mismatch".to_string(), + )); + } + + // Deserialize auth state + let auth_state: PasskeyAuthentication = serde_json::from_str(&challenge_data.options_json) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid state: {}", e), + ) + })?; + + // Finish authentication + let _auth_result = state + .webauthn + .finish_passkey_authentication(&req.credential, &auth_state) + .map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + format!("2FA verification failed: {}", e), + ) + })?; + + // Get passkey by credential ID from request + let cred_id_b64 = Base64UrlUnpadded::encode_string(req.credential.id.as_bytes()); + let passkey = storage::get_passkey_by_credential_id(&state.db, &cred_id_b64) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Passkey not found".to_string()))?; + + // Update counter (TODO: extract counter from auth_result when we understand the API) + // storage::update_passkey_counter(&state.db, &cred_id_b64, new_counter).await?; + + // Append passkey method to AMR + let amr_method = if passkey.backup_eligible == 1 && passkey.backup_state == 1 { + "swk" + } else { + "hwk" + }; + storage::append_session_amr(&state.db, &session.session_id, amr_method) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Upgrade session to MFA + storage::update_session_auth_context( + &state.db, + &session.session_id, + None, + Some("aal2".to_string()), + true, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Delete challenge + storage::delete_webauthn_challenge(&state.db, &challenge_data.challenge) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Return success - JavaScript will handle redirect + Ok(Response::builder() + .status(StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"verified":true,"message":"2FA verification successful"}"#, + )) + .unwrap()) +} + +/// GET /account/passkeys +/// List user's registered passkeys +async fn list_passkeys( + State(state): State, + headers: HeaderMap, +) -> Result>, (StatusCode, String)> { + // Get session + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Get passkeys + let passkeys = storage::get_passkeys_by_subject(&state.db, &session.subject) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let info: Vec = passkeys + .into_iter() + .map(|pk| PasskeyInfo { + credential_id: pk.credential_id, + name: pk.name, + created_at: pk.created_at, + last_used_at: pk.last_used_at, + backup_state: pk.backup_state, + }) + .collect(); + + Ok(Json(info)) +} + +/// DELETE /account/passkeys/{credential_id} +/// Delete a passkey +async fn delete_passkey_handler( + State(state): State, + headers: HeaderMap, + Path(credential_id): Path, +) -> Result { + // Get session + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Verify passkey belongs to user + let passkey = storage::get_passkey_by_credential_id(&state.db, &credential_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Passkey not found".to_string()))?; + + if passkey.subject != session.subject { + return Err(( + StatusCode::FORBIDDEN, + "Passkey belongs to another user".to_string(), + )); + } + + // Check user has at least one auth method remaining + let remaining_passkeys = storage::get_passkeys_by_subject(&state.db, &session.subject) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = storage::get_user_by_subject(&state.db, &session.subject) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; + + if remaining_passkeys.len() == 1 && user.password_hash.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "Cannot delete last auth method".to_string(), + )); + } + + // Delete passkey + storage::delete_passkey(&state.db, &credential_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +/// PATCH /account/passkeys/{credential_id} +/// Update passkey name +async fn update_passkey_handler( + State(state): State, + headers: HeaderMap, + Path(credential_id): Path, + Json(req): Json, +) -> Result { + // Get session + let cookie = SessionCookie::from_headers(&headers) + .ok_or((StatusCode::UNAUTHORIZED, "No session".to_string()))?; + + let session = storage::get_session(&state.db, &cookie.session_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid session".to_string()))?; + + // Verify passkey belongs to user + let passkey = storage::get_passkey_by_credential_id(&state.db, &credential_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Passkey not found".to_string()))?; + + if passkey.subject != session.subject { + return Err(( + StatusCode::FORBIDDEN, + "Passkey belongs to another user".to_string(), + )); + } + + // Update name + storage::update_passkey_name(&state.db, &credential_id, req.name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/webauthn_manager.rs b/src/webauthn_manager.rs new file mode 100644 index 0000000..d89d956 --- /dev/null +++ b/src/webauthn_manager.rs @@ -0,0 +1,82 @@ +use crate::errors::CrabError; +use std::sync::Arc; +use url::Url; +use webauthn_rs::prelude::*; + +#[derive(Clone)] +pub struct WebAuthnManager { + webauthn: Arc, +} + +impl WebAuthnManager { + pub async fn new(rp_id: &str, origin: &Url) -> Result { + let builder = WebauthnBuilder::new(rp_id, origin).map_err(|e| { + CrabError::Configuration(format!("Failed to create WebAuthn builder: {}", e)) + })?; + + let webauthn = builder.build().map_err(|e| { + CrabError::Configuration(format!("Failed to build WebAuthn instance: {}", e)) + })?; + + Ok(Self { + webauthn: Arc::new(webauthn), + }) + } + + /// Start passkey registration flow + /// Returns (challenge response for client, server state to store) + pub fn start_passkey_registration( + &self, + user_id: Uuid, + username: &str, + display_name: &str, + ) -> Result<(CreationChallengeResponse, PasskeyRegistration), CrabError> { + self.webauthn + .start_passkey_registration(user_id, username, display_name, None) + .map_err(|e| { + CrabError::WebAuthnError(format!("Failed to start passkey registration: {}", e)) + }) + } + + /// Finish passkey registration flow + /// Verifies the attestation response from the client + pub fn finish_passkey_registration( + &self, + reg: &RegisterPublicKeyCredential, + state: &PasskeyRegistration, + ) -> Result { + self.webauthn + .finish_passkey_registration(reg, state) + .map_err(|e| { + CrabError::WebAuthnError(format!("Failed to finish passkey registration: {}", e)) + }) + } + + /// Start passkey authentication flow + /// passkeys: list of user's registered passkeys + /// Returns (challenge response for client, server state to store) + pub fn start_passkey_authentication( + &self, + passkeys: Vec, + ) -> Result<(RequestChallengeResponse, PasskeyAuthentication), CrabError> { + self.webauthn + .start_passkey_authentication(&passkeys) + .map_err(|e| { + CrabError::WebAuthnError(format!("Failed to start passkey authentication: {}", e)) + }) + } + + /// Finish passkey authentication flow + /// Verifies the assertion response from the client + pub fn finish_passkey_authentication( + &self, + auth: &PublicKeyCredential, + state: &PasskeyAuthentication, + ) -> Result { + self.webauthn + .finish_passkey_authentication(auth, state) + .map_err(|e| { + CrabError::WebAuthnError(format!("Failed to finish passkey authentication: {}", e)) + }) + } +} diff --git a/tests/helpers/builders.rs b/tests/helpers/builders.rs new file mode 100644 index 0000000..8938cec --- /dev/null +++ b/tests/helpers/builders.rs @@ -0,0 +1,263 @@ +use barycenter::entities; +use barycenter::storage; +use sea_orm::DatabaseConnection; + +/// Builder for creating test users +pub struct UserBuilder { + username: String, + password: String, + email: Option, + enabled: bool, + requires_2fa: bool, +} + +impl UserBuilder { + pub fn new(username: &str) -> Self { + Self { + username: username.to_string(), + password: "password123".to_string(), + email: None, + enabled: true, + requires_2fa: false, + } + } + + pub fn with_password(mut self, password: &str) -> Self { + self.password = password.to_string(); + self + } + + pub fn with_email(mut self, email: &str) -> Self { + self.email = Some(email.to_string()); + self + } + + pub fn requires_2fa(mut self) -> Self { + self.requires_2fa = true; + self + } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + pub async fn create(self, db: &DatabaseConnection) -> entities::user::Model { + let user = storage::create_user(db, &self.username, &self.password, self.email) + .await + .expect("Failed to create test user"); + + // Update 2FA and enabled flags if needed + if !self.enabled || self.requires_2fa { + storage::update_user( + db, + &user.subject, + self.enabled, + None, + if self.requires_2fa { Some(true) } else { None }, + ) + .await + .expect("Failed to update user flags"); + + // Retrieve updated user + storage::get_user_by_subject(db, &user.subject) + .await + .expect("Failed to get updated user") + .expect("User not found") + } else { + user + } + } +} + +/// Builder for creating test OAuth clients +pub struct ClientBuilder { + client_name: Option, + redirect_uris: Vec, +} + +impl ClientBuilder { + pub fn new() -> Self { + Self { + client_name: Some("Test Client".to_string()), + redirect_uris: vec!["http://localhost:3000/callback".to_string()], + } + } + + pub fn with_name(mut self, name: &str) -> Self { + self.client_name = Some(name.to_string()); + self + } + + pub fn with_redirect_uri(mut self, uri: &str) -> Self { + self.redirect_uris = vec![uri.to_string()]; + self + } + + pub fn with_redirect_uris(mut self, uris: Vec) -> Self { + self.redirect_uris = uris; + self + } + + pub async fn create(self, db: &DatabaseConnection) -> entities::client::Model { + storage::create_client( + db, + storage::NewClient { + client_name: self.client_name, + redirect_uris: self.redirect_uris, + }, + ) + .await + .expect("Failed to create test client") + } +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Builder for creating test sessions +pub struct SessionBuilder { + subject: String, + auth_time: i64, + ttl: i64, + amr: Option>, + acr: Option, + mfa_verified: bool, +} + +impl SessionBuilder { + pub fn new(subject: &str) -> Self { + Self { + subject: subject.to_string(), + auth_time: chrono::Utc::now().timestamp(), + ttl: 3600, // 1 hour + amr: None, + acr: None, + mfa_verified: false, + } + } + + pub fn with_auth_time(mut self, auth_time: i64) -> Self { + self.auth_time = auth_time; + self + } + + pub fn with_ttl(mut self, ttl: i64) -> Self { + self.ttl = ttl; + self + } + + pub fn with_amr(mut self, amr: Vec) -> Self { + self.amr = Some(amr); + self + } + + pub fn with_acr(mut self, acr: &str) -> Self { + self.acr = Some(acr.to_string()); + self + } + + pub fn with_mfa_verified(mut self) -> Self { + self.mfa_verified = true; + self + } + + pub async fn create(self, db: &DatabaseConnection) -> entities::session::Model { + let session = storage::create_session(db, &self.subject, self.auth_time, self.ttl, None, None) + .await + .expect("Failed to create test session"); + + // Update AMR/ACR/MFA if needed + if self.amr.is_some() || self.acr.is_some() || self.mfa_verified { + let amr_json = self.amr.map(|a| serde_json::to_string(&a).unwrap()); + storage::update_session_auth_context( + db, + &session.session_id, + amr_json.as_deref(), + self.acr.as_deref(), + if self.mfa_verified { Some(true) } else { None }, + ) + .await + .expect("Failed to update session auth context"); + + // Retrieve updated session + storage::get_session(db, &session.session_id) + .await + .expect("Failed to get updated session") + .expect("Session not found") + } else { + session + } + } +} + +/// Builder for creating test passkeys +pub struct PasskeyBuilder { + subject: String, + name: Option, + backup_state: bool, + backup_eligible: bool, +} + +impl PasskeyBuilder { + pub fn new(subject: &str) -> Self { + Self { + subject: subject.to_string(), + name: None, + backup_state: false, + backup_eligible: false, + } + } + + pub fn with_name(mut self, name: &str) -> Self { + self.name = Some(name.to_string()); + self + } + + pub fn cloud_synced(mut self) -> Self { + self.backup_state = true; + self.backup_eligible = true; + self + } + + pub fn hardware_bound(mut self) -> Self { + self.backup_state = false; + self.backup_eligible = false; + self + } + + pub async fn create(self, db: &DatabaseConnection) -> entities::passkey::Model { + // Create a minimal test passkey + // In real tests, you'd use MockWebAuthnCredential to generate this + use webauthn_rs::prelude::*; + + // Create a test passkey with minimal data + let credential_id = uuid::Uuid::new_v4().as_bytes().to_vec(); + let passkey_json = serde_json::json!({ + "cred_id": base64::encode(&credential_id), + "cred": { + "counter": 0, + "backup_state": self.backup_state, + "backup_eligible": self.backup_eligible + } + }); + + storage::create_passkey( + db, + &base64ct::Base64UrlUnpadded::encode_string(&credential_id), + &self.subject, + &serde_json::to_string(&passkey_json).unwrap(), + 0, + None, + self.backup_eligible, + self.backup_state, + None, + self.name.as_deref(), + ) + .await + .expect("Failed to create test passkey") + } +} diff --git a/tests/helpers/db.rs b/tests/helpers/db.rs new file mode 100644 index 0000000..034d784 --- /dev/null +++ b/tests/helpers/db.rs @@ -0,0 +1,67 @@ +use sea_orm::{Database, DatabaseConnection}; +use sea_orm_migration::MigratorTrait; +use tempfile::NamedTempFile; + +/// Test database with automatic cleanup +pub struct TestDb { + connection: DatabaseConnection, + _temp_file: NamedTempFile, +} + +impl TestDb { + /// Create a new test database with migrations applied + pub async fn new() -> Self { + // Create temporary SQLite database file + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + let db_path = temp_file.path().to_str().expect("Invalid temp file path"); + let db_url = format!("sqlite://{}?mode=rwc", db_path); + + // Connect to database + let connection = Database::connect(&db_url) + .await + .expect("Failed to connect to test database"); + + // Run migrations + migration::Migrator::up(&connection, None) + .await + .expect("Failed to run migrations"); + + Self { + connection, + _temp_file: temp_file, + } + } + + /// Get database connection + pub fn connection(&self) -> &DatabaseConnection { + &self.connection + } +} + +/// Create a test user for testing +pub async fn seed_test_user( + db: &DatabaseConnection, + username: &str, + password: &str, +) -> barycenter::entities::user::Model { + barycenter::storage::create_user(db, username, password, None) + .await + .expect("Failed to create test user") +} + +/// Create a test OAuth client for testing +pub async fn seed_test_client( + db: &DatabaseConnection, +) -> barycenter::entities::client::Model { + use barycenter::storage::NewClient; + + barycenter::storage::create_client( + db, + NewClient { + client_name: Some("Test Client".to_string()), + redirect_uris: vec!["http://localhost:3000/callback".to_string()], + }, + ) + .await + .expect("Failed to create test client") +} diff --git a/tests/helpers/mock_webauthn.rs b/tests/helpers/mock_webauthn.rs new file mode 100644 index 0000000..cb19c43 --- /dev/null +++ b/tests/helpers/mock_webauthn.rs @@ -0,0 +1,87 @@ +use webauthn_rs::prelude::*; + +/// Mock WebAuthn credential for testing +/// +/// This creates mock WebAuthn credentials and responses for testing +/// passkey registration and authentication flows without requiring a browser. +pub struct MockWebAuthnCredential { + pub credential_id: Vec, + pub counter: u32, + pub backup_state: bool, + pub passkey: Passkey, +} + +impl MockWebAuthnCredential { + /// Create a new hardware-bound passkey (e.g., YubiKey, TouchID) + pub fn new_hardware_key(user_id: Uuid, username: &str) -> Self { + // Use webauthn-rs to generate a real passkey for testing + // This will create a valid passkey structure + let credential_id = uuid::Uuid::new_v4().as_bytes().to_vec(); + + // Create a minimal passkey structure for testing + // Note: In real tests, you'd use webauthn-rs test utilities + // or the webauthn library's test mode + Self { + credential_id: credential_id.clone(), + counter: 0, + backup_state: false, + passkey: create_test_passkey(user_id, username, &credential_id, false), + } + } + + /// Create a new cloud-synced passkey (e.g., iCloud Keychain, password manager) + pub fn new_cloud_synced(user_id: Uuid, username: &str) -> Self { + let credential_id = uuid::Uuid::new_v4().as_bytes().to_vec(); + + Self { + credential_id: credential_id.clone(), + counter: 0, + backup_state: true, + passkey: create_test_passkey(user_id, username, &credential_id, true), + } + } + + /// Increment counter (for clone detection testing) + pub fn increment_counter(&mut self) { + self.counter += 1; + } +} + +/// Helper function to create a test passkey +/// +/// This creates a minimal but valid passkey structure for testing. +/// Note: For full WebAuthn testing, you should use webauthn-rs's test utilities +/// or mock the WebAuthn responses at the HTTP level. +fn create_test_passkey( + user_id: Uuid, + username: &str, + credential_id: &[u8], + backup_state: bool, +) -> Passkey { + // Create a test passkey using webauthn-rs's test utilities + // This is a simplified version - in production tests you'd want + // to use the full webauthn-rs test framework or mock HTTP responses + + use webauthn_rs::prelude::*; + + // For now, we'll use a placeholder + // In actual implementation, you'd use webauthn-rs test utilities + // or mock the entire flow at the HTTP level + + // This is a marker to indicate where full WebAuthn mocking would go + unimplemented!("Full WebAuthn mocking should be implemented using webauthn-rs test utilities or HTTP-level mocking") +} + +// Note: For comprehensive WebAuthn testing, consider these approaches: +// +// 1. HTTP-level mocking: Mock the entire WebAuthn flow by creating valid +// JSON responses that match the WebAuthn spec, and test the HTTP endpoints +// +// 2. Use webauthn-rs test mode: The webauthn-rs library has test utilities +// for creating valid attestation and assertion responses +// +// 3. Integration tests: Test the WebAuthn endpoints with pre-recorded valid +// WebAuthn responses from real authenticators +// +// For the scope of this implementation, we'll focus on approach #1 (HTTP-level) +// and approach #3 (pre-recorded responses) in the integration tests. diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs new file mode 100644 index 0000000..89e7e3c --- /dev/null +++ b/tests/helpers/mod.rs @@ -0,0 +1,7 @@ +pub mod db; +pub mod mock_webauthn; +pub mod builders; + +pub use db::TestDb; +pub use mock_webauthn::MockWebAuthnCredential; +pub use builders::{UserBuilder, ClientBuilder, SessionBuilder, PasskeyBuilder}; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9c2716b..380e939 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -13,7 +13,7 @@ struct TestServer { impl TestServer { fn start() -> Self { let port = 8080; - let base_url = format!("http://0.0.0.0:{}", port); + let base_url = format!("http://localhost:{}", port); // Use the pre-built binary from target/debug instead of recompiling with cargo run // This avoids compilation timeouts in CI @@ -35,6 +35,7 @@ impl TestServer { let mut process = Command::new(&binary_path) .env("RUST_LOG", "error") .env("BARYCENTER__SERVER__ALLOW_PUBLIC_REGISTRATION", "true") // Enable registration for tests + .env("BARYCENTER__SERVER__PUBLIC_BASE_URL", &base_url) // Set public base URL for WebAuthn .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .spawn()