WIP Passkey implementation. Needs fixing storage.rs and more tests

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2025-12-07 13:18:22 +01:00
parent 47d9d24798
commit d7bdd51164
No known key found for this signature in database
30 changed files with 4480 additions and 46 deletions

View file

@ -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": []

316
CLAUDE.md
View file

@ -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
### 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

449
Cargo.lock generated
View file

@ -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"

View file

@ -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

32
client-wasm/Cargo.toml Normal file
View file

@ -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"

287
client-wasm/src/lib.rs Normal file
View file

@ -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<String, JsValue> {
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<String>,
) -> Result<String, JsValue> {
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<String, JsValue> {
// The credential returned is a PublicKeyCredential
// We need to extract and serialize the response
#[derive(Serialize)]
struct CredentialResponse {
id: String,
raw_id: Vec<u8>,
response: AttestationResponse,
#[serde(rename = "type")]
type_: String,
}
#[derive(Serialize)]
struct AttestationResponse {
attestation_object: Vec<u8>,
client_data_json: Vec<u8>,
}
// 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<String, JsValue> {
#[derive(Serialize)]
struct AssertionResponse {
id: String,
raw_id: Vec<u8>,
response: AuthenticatorResponse,
#[serde(rename = "type")]
type_: String,
}
#[derive(Serialize)]
struct AuthenticatorResponse {
authenticator_data: Vec<u8>,
client_data_json: Vec<u8>,
signature: Vec<u8>,
user_handle: Option<Vec<u8>>,
}
// 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();
}
}

View file

@ -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"

View file

@ -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<Box<dyn MigrationTrait>> {
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),
]
}
}

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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<User2FAResult> {
let db = ctx
.data::<Arc<DatabaseConnection>>()
.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<User2FAStatus> {
let db = ctx
.data::<Arc<DatabaseConnection>>()
.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<i64>,
}

View file

@ -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;

24
src/entities/passkey.rs Normal file
View file

@ -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<String>,
pub backup_eligible: i64,
pub backup_state: i64,
pub transports: Option<String>,
pub name: Option<String>,
pub created_at: i64,
pub last_used_at: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -12,6 +12,9 @@ pub struct Model {
pub expires_at: i64,
pub user_agent: Option<String>,
pub ip_address: Option<String>,
pub amr: Option<String>,
pub acr: Option<String>,
pub mfa_verified: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -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<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -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<String>,
pub session_id: Option<String>,
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 {}

View file

@ -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),

View file

@ -87,13 +87,49 @@ pub async fn init_scheduler(db: DatabaseConnection) -> Result<JobScheduler, Crab
.await
.map_err(|e| CrabError::Other(format!("Failed to add cleanup tokens job: {}", e)))?;
let db_clone = db.clone();
// Cleanup expired WebAuthn challenges job - runs every 5 minutes
let cleanup_challenges_job = Job::new_async("0 */5 * * * *", move |_uuid, _l| {
let db = db_clone.clone();
Box::pin(async move {
info!("Running cleanup_expired_challenges job");
let execution_id = start_job_execution(&db, "cleanup_expired_challenges")
.await
.ok();
match storage::cleanup_expired_challenges(&db).await {
Ok(count) => {
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));
}
}

View file

@ -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 == '_'));
}
}
}

17
src/lib.rs Normal file
View file

@ -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;

View file

@ -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?;
}
}

View file

@ -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);
}
}

View file

@ -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<u
Ok(result.rows_affected)
}
#[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
}
// ============================================================================
// 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<String> = 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);
}
}

1067
src/web.rs

File diff suppressed because it is too large Load diff

82
src/webauthn_manager.rs Normal file
View file

@ -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<Webauthn>,
}
impl WebAuthnManager {
pub async fn new(rp_id: &str, origin: &Url) -> Result<Self, CrabError> {
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<Passkey, CrabError> {
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<Passkey>,
) -> 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<AuthenticationResult, CrabError> {
self.webauthn
.finish_passkey_authentication(auth, state)
.map_err(|e| {
CrabError::WebAuthnError(format!("Failed to finish passkey authentication: {}", e))
})
}
}

263
tests/helpers/builders.rs Normal file
View file

@ -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<String>,
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<String>,
redirect_uris: Vec<String>,
}
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<String>) -> 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<Vec<String>>,
acr: Option<String>,
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<String>) -> 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<String>,
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")
}
}

67
tests/helpers/db.rs Normal file
View file

@ -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")
}

View file

@ -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<u8>,
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.

7
tests/helpers/mod.rs Normal file
View file

@ -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};

View file

@ -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()