mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 13:10:42 +00:00
WIP Passkey implementation. Needs fixing storage.rs and more tests
Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
parent
47d9d24798
commit
d7bdd51164
30 changed files with 4480 additions and 46 deletions
|
|
@ -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
316
CLAUDE.md
|
|
@ -134,15 +134,35 @@ Implements OpenID Connect and OAuth 2.0 endpoints:
|
|||
|
||||
**OAuth/OIDC Flow:**
|
||||
- `GET /authorize` - Authorization endpoint (issues authorization code with PKCE)
|
||||
- Currently uses fixed subject "demo-user" (pending login flow implementation per docs/next-iteration-plan.md)
|
||||
- Validates client_id, redirect_uri, scope (must include "openid"), PKCE S256
|
||||
- Checks 2FA requirements (admin-enforced, high-value scopes, max_age)
|
||||
- Redirects to /login or /login/2fa if authentication needed
|
||||
- Returns redirect with code and state
|
||||
- `POST /token` - Token endpoint (exchanges code for tokens)
|
||||
- Supports `client_secret_basic` (Authorization header) and `client_secret_post` (form body)
|
||||
- Validates PKCE S256 code_verifier
|
||||
- Returns access_token, id_token (JWT), token_type, expires_in
|
||||
- Returns access_token, id_token (JWT with AMR/ACR claims), token_type, expires_in
|
||||
- `GET /userinfo` - UserInfo endpoint (returns claims for Bearer token)
|
||||
|
||||
**Authentication:**
|
||||
- `GET /login` - Login page with passkey autofill and password fallback
|
||||
- `POST /login` - Password authentication, checks 2FA requirements
|
||||
- `GET /login/2fa` - Two-factor authentication page
|
||||
- `POST /logout` - End user session
|
||||
|
||||
**Passkey/WebAuthn Endpoints:**
|
||||
- `POST /webauthn/register/start` - Start passkey registration (requires session)
|
||||
- `POST /webauthn/register/finish` - Complete passkey registration
|
||||
- `POST /webauthn/authenticate/start` - Start passkey authentication (public)
|
||||
- `POST /webauthn/authenticate/finish` - Complete passkey authentication
|
||||
- `POST /webauthn/2fa/start` - Start 2FA passkey verification (requires partial session)
|
||||
- `POST /webauthn/2fa/finish` - Complete 2FA passkey verification
|
||||
|
||||
**Passkey Management:**
|
||||
- `GET /account/passkeys` - List user's registered passkeys
|
||||
- `DELETE /account/passkeys/:credential_id` - Delete a passkey
|
||||
- `PATCH /account/passkeys/:credential_id` - Update passkey name
|
||||
|
||||
**Non-Standard:**
|
||||
- `GET /properties/:owner/:key` - Get property value
|
||||
- `PUT /properties/:owner/:key` - Set property value
|
||||
|
|
@ -168,12 +188,60 @@ Generated ID tokens include:
|
|||
- Standard claims: iss, sub, aud, exp, iat
|
||||
- Optional: nonce (if provided in authorize request)
|
||||
- at_hash: hash of access token per OIDC spec (left 128 bits of SHA-256, base64url)
|
||||
- auth_time: timestamp of authentication (from session)
|
||||
- amr: Authentication Method References array (e.g., ["pwd"], ["hwk"], ["pwd", "hwk"])
|
||||
- acr: Authentication Context Reference ("aal1" for single-factor, "aal2" for two-factor)
|
||||
- Signed with RS256, includes kid header matching JWKS
|
||||
|
||||
### State Management
|
||||
- Authorization codes: 5 minute TTL, single-use (marked consumed)
|
||||
- Access tokens: 1 hour TTL, checked for expiration and revoked flag
|
||||
- Both stored in SQLite with timestamps
|
||||
- Sessions: Track authentication methods (AMR), context (ACR), and MFA status
|
||||
- WebAuthn challenges: 5 minute TTL, cleaned up every 5 minutes by background job
|
||||
- All stored in database with timestamps
|
||||
|
||||
### WebAuthn/Passkey Authentication
|
||||
|
||||
Barycenter supports passwordless authentication using WebAuthn/FIDO2 passkeys with the following features:
|
||||
|
||||
**Authentication Modes:**
|
||||
- **Single-factor passkey login**: Passkeys as primary authentication method
|
||||
- **Two-factor authentication**: Passkeys as second factor after password login
|
||||
- **Password fallback**: Traditional password authentication remains available
|
||||
|
||||
**Client Implementation:**
|
||||
- Rust WASM module (`client-wasm/`) compiled with wasm-pack
|
||||
- Browser-side WebAuthn API calls via wasm-bindgen
|
||||
- Conditional UI support for autofill in Chrome 108+, Safari 16+
|
||||
- Progressive enhancement: falls back to explicit button if autofill unavailable
|
||||
|
||||
**Passkey Storage:**
|
||||
- Full `Passkey` object stored as JSON in database
|
||||
- Tracks signature counter for clone detection
|
||||
- Records backup state (cloud-synced vs hardware-bound)
|
||||
- Supports friendly names for user management
|
||||
|
||||
**AMR (Authentication Method References) Values:**
|
||||
- `"pwd"`: Password authentication
|
||||
- `"hwk"`: Hardware-bound passkey (YubiKey, security key)
|
||||
- `"swk"`: Software/cloud-synced passkey (iCloud Keychain, password manager)
|
||||
- Multiple values indicate multi-factor auth (e.g., `["pwd", "hwk"]`)
|
||||
|
||||
**2FA Enforcement Modes:**
|
||||
|
||||
1. **User-Optional 2FA**: Users can enable 2FA in account settings (future UI)
|
||||
2. **Admin-Enforced 2FA**: Set `users.requires_2fa = 1` via GraphQL mutation
|
||||
3. **Context-Based 2FA**: Triggered by:
|
||||
- High-value scopes: "admin", "payment", "transfer", "delete"
|
||||
- Fresh authentication required: `max_age < 300` seconds
|
||||
- Can be configured per-scope or per-request
|
||||
|
||||
**2FA Flow:**
|
||||
1. User logs in with password → creates partial session (`mfa_verified=0`)
|
||||
2. If 2FA required, redirect to `/login/2fa`
|
||||
3. User verifies with passkey
|
||||
4. Session upgraded: `mfa_verified=1`, `acr="aal2"`, `amr=["pwd", "hwk"]`
|
||||
5. Authorization proceeds, ID token includes full authentication context
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
|
|
@ -183,33 +251,245 @@ See `docs/oidc-conformance.md` for detailed OIDC compliance requirements.
|
|||
- Authorization Code flow with PKCE (S256)
|
||||
- Dynamic client registration
|
||||
- Token endpoint with client_secret_basic and client_secret_post
|
||||
- ID Token signing (RS256) with at_hash and nonce
|
||||
- ID Token signing (RS256) with at_hash, nonce, auth_time, AMR, and ACR claims
|
||||
- UserInfo endpoint with Bearer token authentication
|
||||
- Discovery and JWKS publication
|
||||
- Property storage API
|
||||
- User authentication with sessions
|
||||
- Password authentication with argon2 hashing
|
||||
- WebAuthn/passkey authentication (single-factor and two-factor)
|
||||
- WASM client for browser-side WebAuthn operations
|
||||
- Conditional UI/autofill for passkey login
|
||||
- Three 2FA modes: user-optional, admin-enforced, context-based
|
||||
- Background jobs for cleanup (sessions, tokens, challenges)
|
||||
- Admin GraphQL API for user management and job triggering
|
||||
- Refresh token grant with rotation
|
||||
- Session-based AMR/ACR tracking
|
||||
|
||||
**Pending (see docs/next-iteration-plan.md):**
|
||||
- User authentication and session management (currently uses fixed "demo-user" subject)
|
||||
- auth_time claim in ID Token (requires session tracking)
|
||||
**Pending:**
|
||||
- Cache-Control headers on token endpoint
|
||||
- Consent flow (currently auto-consents)
|
||||
- Refresh tokens
|
||||
- Token revocation and introspection
|
||||
- Token revocation and introspection endpoints
|
||||
- OpenID Federation trust chain validation
|
||||
- User account management UI
|
||||
|
||||
## Admin GraphQL API
|
||||
|
||||
The admin API is served on a separate port (default: 9091) and provides GraphQL queries and mutations for management:
|
||||
|
||||
**Mutations:**
|
||||
```graphql
|
||||
mutation {
|
||||
# Trigger background jobs manually
|
||||
triggerJob(jobName: "cleanup_expired_sessions") {
|
||||
success
|
||||
message
|
||||
}
|
||||
|
||||
# Enable 2FA requirement for a user
|
||||
setUser2faRequired(username: "alice", required: true) {
|
||||
success
|
||||
message
|
||||
requires2fa
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Queries:**
|
||||
```graphql
|
||||
query {
|
||||
# Get job execution history
|
||||
jobLogs(limit: 10, onlyFailures: false) {
|
||||
id
|
||||
jobName
|
||||
startedAt
|
||||
completedAt
|
||||
success
|
||||
recordsProcessed
|
||||
}
|
||||
|
||||
# Get user 2FA status
|
||||
user2faStatus(username: "alice") {
|
||||
username
|
||||
requires2fa
|
||||
passkeyEnrolled
|
||||
passkeyCount
|
||||
passkeyEnrolledAt
|
||||
}
|
||||
|
||||
# List available jobs
|
||||
availableJobs {
|
||||
name
|
||||
description
|
||||
schedule
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available job names:
|
||||
- `cleanup_expired_sessions` (hourly at :00)
|
||||
- `cleanup_expired_refresh_tokens` (hourly at :30)
|
||||
- `cleanup_expired_challenges` (every 5 minutes)
|
||||
|
||||
## Building the WASM Client
|
||||
|
||||
The passkey authentication client is written in Rust and compiled to WebAssembly:
|
||||
|
||||
```bash
|
||||
# Install wasm-pack if not already installed
|
||||
cargo install wasm-pack
|
||||
|
||||
# Build the WASM module
|
||||
cd client-wasm
|
||||
wasm-pack build --target web --out-dir ../static/wasm
|
||||
|
||||
# The built files will be in static/wasm/:
|
||||
# - barycenter_webauthn_client_bg.wasm
|
||||
# - barycenter_webauthn_client.js
|
||||
# - TypeScript definitions (.d.ts files)
|
||||
```
|
||||
|
||||
The WASM module is automatically loaded by the login page and provides:
|
||||
- `supports_webauthn()`: Check if WebAuthn is available
|
||||
- `supports_conditional_ui()`: Check for autofill support
|
||||
- `register_passkey(options)`: Create a new passkey
|
||||
- `authenticate_passkey(options, mediation)`: Authenticate with passkey
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
No automated tests currently exist. Manual testing can be done with curl commands following the OAuth 2.0 Authorization Code + PKCE flow:
|
||||
### Manual Testing Flow
|
||||
|
||||
1. Register a client via `POST /connect/register`
|
||||
2. Generate PKCE verifier and challenge
|
||||
3. Navigate to `/authorize` with required parameters
|
||||
4. Exchange authorization code at `/token` with code_verifier
|
||||
5. Access `/userinfo` with Bearer access_token
|
||||
|
||||
Example PKCE generation (bash):
|
||||
**1. Test Password Login:**
|
||||
```bash
|
||||
# Navigate to http://localhost:9090/login
|
||||
# Enter username: admin, password: password123
|
||||
# Should create session and redirect
|
||||
```
|
||||
|
||||
**2. Test Passkey Registration:**
|
||||
```bash
|
||||
# After logging in with password
|
||||
# Navigate to http://localhost:9090/account/passkeys
|
||||
# (Future UI - currently use browser console)
|
||||
|
||||
# Call via JavaScript console:
|
||||
fetch('/webauthn/register/start', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Use browser's navigator.credentials.create() with returned options
|
||||
});
|
||||
```
|
||||
|
||||
**3. Test Passkey Authentication:**
|
||||
- Navigate to `/login`
|
||||
- Click on username field
|
||||
- Browser should show passkey autofill (Chrome 108+, Safari 16+)
|
||||
- Select a passkey to authenticate
|
||||
|
||||
**4. Test Admin-Enforced 2FA:**
|
||||
```graphql
|
||||
# Via admin API (port 9091)
|
||||
mutation {
|
||||
setUser2faRequired(username: "admin", required: true) {
|
||||
success
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
1. Log out
|
||||
2. Log in with password
|
||||
3. Should redirect to `/login/2fa`
|
||||
4. Complete passkey verification
|
||||
5. Should complete authorization with ACR="aal2"
|
||||
|
||||
**5. Test Context-Based 2FA:**
|
||||
```bash
|
||||
# Request authorization with max_age < 300
|
||||
curl "http://localhost:9090/authorize?...&max_age=60"
|
||||
# Should trigger 2FA even if not admin-enforced
|
||||
```
|
||||
|
||||
### OIDC Flow Testing
|
||||
|
||||
```bash
|
||||
# 1. Register a client
|
||||
curl -X POST http://localhost:9090/connect/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"redirect_uris": ["http://localhost:8080/callback"],
|
||||
"client_name": "Test Client"
|
||||
}'
|
||||
|
||||
# 2. Generate PKCE
|
||||
verifier=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
|
||||
challenge=$(echo -n "$verifier" | openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '+/' '-_')
|
||||
|
||||
# 3. Navigate to authorize endpoint (in browser)
|
||||
http://localhost:9090/authorize?client_id=CLIENT_ID&redirect_uri=http://localhost:8080/callback&response_type=code&scope=openid&code_challenge=$challenge&code_challenge_method=S256&state=random
|
||||
|
||||
# 4. After redirect, exchange code for tokens
|
||||
curl -X POST http://localhost:9090/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code&code=CODE&redirect_uri=http://localhost:8080/callback&client_id=CLIENT_ID&client_secret=SECRET&code_verifier=$verifier"
|
||||
|
||||
# 5. Decode ID token to verify AMR/ACR claims
|
||||
# Use jwt.io or similar to inspect the token
|
||||
```
|
||||
- I have github team plan
|
||||
|
||||
### 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
449
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
22
Cargo.toml
22
Cargo.toml
|
|
@ -1,3 +1,6 @@
|
|||
[workspace]
|
||||
members = [".", "migration", "client-wasm"]
|
||||
|
||||
[package]
|
||||
name = "barycenter"
|
||||
version = "0.2.0-alpha.15"
|
||||
|
|
@ -10,6 +13,10 @@ documentation = "https://github.com/CloudNebulaProject/barycenter/blob/main/READ
|
|||
keywords = ["openid", "oauth2", "identity", "authentication", "oidc"]
|
||||
categories = ["authentication", "web-programming"]
|
||||
|
||||
[lib]
|
||||
name = "barycenter"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["json", "form"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
@ -31,6 +38,10 @@ migration = { path = "migration" }
|
|||
# JOSE / JWKS & JWT
|
||||
josekit = "0.10"
|
||||
|
||||
# WebAuthn / Passkeys
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||
time = "0.3"
|
||||
rand = "0.8"
|
||||
|
|
@ -45,6 +56,7 @@ argon2 = "0.5"
|
|||
# Rate limiting
|
||||
tower = "0.5"
|
||||
tower_governor = "0.4"
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
|
||||
# Validation
|
||||
regex = "1"
|
||||
|
|
@ -57,13 +69,23 @@ async-graphql-axum = "7"
|
|||
|
||||
# Background job scheduler
|
||||
tokio-cron-scheduler = "0.13"
|
||||
bincode = "2.0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# Existing OIDC/OAuth testing
|
||||
openidconnect = { version = "4", features = ["reqwest-blocking"] }
|
||||
oauth2 = "5"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json", "cookies"] }
|
||||
urlencoding = "2"
|
||||
|
||||
# New test utilities
|
||||
tempfile = "3" # Temp SQLite databases for test isolation
|
||||
tokio-test = "0.4" # Async test utilities
|
||||
assert_matches = "1" # Pattern matching assertions
|
||||
pretty_assertions = "1" # Better assertion output with color diffs
|
||||
test-log = "0.2" # Capture tracing logs in tests
|
||||
serde_cbor = "0.11" # CBOR encoding for WebAuthn mocks
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
|
|
|
|||
32
client-wasm/Cargo.toml
Normal file
32
client-wasm/Cargo.toml
Normal 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
287
client-wasm/src/lib.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
migration/src/m20250107_000001_add_passkeys.rs
Normal file
139
migration/src/m20250107_000001_add_passkeys.rs
Normal 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,
|
||||
}
|
||||
137
migration/src/m20250107_000002_extend_sessions_users.rs
Normal file
137
migration/src/m20250107_000002_extend_sessions_users.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
src/entities/passkey.rs
Normal 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 {}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
20
src/entities/webauthn_challenge.rs
Normal file
20
src/entities/webauthn_challenge.rs
Normal 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 {}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
242
src/jobs.rs
242
src/jobs.rs
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
216
src/jwks.rs
216
src/jwks.rs
|
|
@ -88,3 +88,219 @@ fn random_kid() -> String {
|
|||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
base64ct::Base64UrlUnpadded::encode_string(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Helper to create test Keys config
|
||||
fn test_keys_config(temp_dir: &TempDir) -> Keys {
|
||||
Keys {
|
||||
jwks_path: temp_dir.path().join("jwks.json"),
|
||||
private_key_path: temp_dir.path().join("private_key.json"),
|
||||
alg: "RS256".to_string(),
|
||||
key_id: Some("test-kid-123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwks_manager_generates_key() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let cfg = test_keys_config(&temp_dir);
|
||||
|
||||
let manager = JwksManager::new(cfg.clone())
|
||||
.await
|
||||
.expect("Failed to create JwksManager");
|
||||
|
||||
let private_jwk = manager.private_jwk();
|
||||
|
||||
// Verify it's RSA key
|
||||
assert_eq!(private_jwk.key_type(), "RSA");
|
||||
assert_eq!(private_jwk.algorithm(), Some("RS256"));
|
||||
assert_eq!(private_jwk.key_use(), Some("sig"));
|
||||
assert_eq!(private_jwk.key_id(), Some("test-kid-123"));
|
||||
|
||||
// Verify 2048-bit key (check modulus size)
|
||||
if let Some(n) = private_jwk.parameter("n") {
|
||||
if let Some(n_str) = n.as_str() {
|
||||
let decoded = base64ct::Base64UrlUnpadded::decode_vec(n_str)
|
||||
.expect("Failed to decode modulus");
|
||||
// 2048-bit key = 256 bytes modulus
|
||||
assert_eq!(decoded.len(), 256);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwks_manager_persists_private_key() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let cfg = test_keys_config(&temp_dir);
|
||||
|
||||
let _manager = JwksManager::new(cfg.clone())
|
||||
.await
|
||||
.expect("Failed to create JwksManager");
|
||||
|
||||
// Verify private key file exists
|
||||
assert!(cfg.private_key_path.exists());
|
||||
|
||||
// Verify it contains valid JSON JWK
|
||||
let content = fs::read_to_string(&cfg.private_key_path)
|
||||
.expect("Failed to read private key");
|
||||
let jwk: Jwk = serde_json::from_str(&content)
|
||||
.expect("Failed to parse private key JSON");
|
||||
|
||||
assert_eq!(jwk.key_type(), "RSA");
|
||||
assert!(jwk.parameter("d").is_some()); // Private exponent exists
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwks_manager_persists_jwks() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let cfg = test_keys_config(&temp_dir);
|
||||
|
||||
let _manager = JwksManager::new(cfg.clone())
|
||||
.await
|
||||
.expect("Failed to create JwksManager");
|
||||
|
||||
// Verify JWKS file exists
|
||||
assert!(cfg.jwks_path.exists());
|
||||
|
||||
// Verify it contains valid JWKS structure
|
||||
let content = fs::read_to_string(&cfg.jwks_path)
|
||||
.expect("Failed to read JWKS");
|
||||
let jwks: Value = serde_json::from_str(&content)
|
||||
.expect("Failed to parse JWKS JSON");
|
||||
|
||||
assert!(jwks.get("keys").is_some());
|
||||
assert!(jwks["keys"].is_array());
|
||||
assert_eq!(jwks["keys"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Verify public key (should not have private parameters)
|
||||
let public_key = &jwks["keys"][0];
|
||||
assert!(public_key.get("n").is_some()); // Modulus
|
||||
assert!(public_key.get("e").is_some()); // Exponent
|
||||
assert!(public_key.get("d").is_none()); // No private exponent
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwks_manager_loads_existing() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let cfg = test_keys_config(&temp_dir);
|
||||
|
||||
// Create first manager
|
||||
let manager1 = JwksManager::new(cfg.clone())
|
||||
.await
|
||||
.expect("Failed to create first JwksManager");
|
||||
|
||||
let kid1 = manager1.private_jwk().key_id().unwrap().to_string();
|
||||
|
||||
// Create second manager - should reuse the same key
|
||||
let manager2 = JwksManager::new(cfg.clone())
|
||||
.await
|
||||
.expect("Failed to create second JwksManager");
|
||||
|
||||
let kid2 = manager2.private_jwk().key_id().unwrap().to_string();
|
||||
|
||||
// Verify same key was loaded
|
||||
assert_eq!(kid1, kid2);
|
||||
|
||||
// Verify modulus is identical (same key)
|
||||
let jwk1 = manager1.private_jwk();
|
||||
let jwk2 = manager2.private_jwk();
|
||||
|
||||
assert_eq!(
|
||||
jwk1.parameter("n"),
|
||||
jwk2.parameter("n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_jwt_rs256() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let cfg = test_keys_config(&temp_dir);
|
||||
|
||||
let manager = JwksManager::new(cfg.clone())
|
||||
.await
|
||||
.expect("Failed to create JwksManager");
|
||||
|
||||
// Create a test payload
|
||||
let mut payload = JwtPayload::new();
|
||||
payload.set_issuer("https://example.com");
|
||||
payload.set_subject("user123");
|
||||
payload.set_expires_at(&(chrono::Utc::now() + chrono::Duration::hours(1)));
|
||||
|
||||
// Sign the JWT
|
||||
let token = manager.sign_jwt_rs256(&payload)
|
||||
.expect("Failed to sign JWT");
|
||||
|
||||
// Verify token is not empty and has 3 parts (header.payload.signature)
|
||||
assert!(!token.is_empty());
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
|
||||
// Decode and verify header contains kid
|
||||
let header_json = base64ct::Base64UrlUnpadded::decode_vec(parts[0])
|
||||
.expect("Failed to decode header");
|
||||
let header: Value = serde_json::from_slice(&header_json)
|
||||
.expect("Failed to parse header");
|
||||
|
||||
assert_eq!(header["alg"], "RS256");
|
||||
assert_eq!(header["kid"], "test-kid-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwks_json_format() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let cfg = test_keys_config(&temp_dir);
|
||||
|
||||
let manager = JwksManager::new(cfg)
|
||||
.await
|
||||
.expect("Failed to create JwksManager");
|
||||
|
||||
let jwks = manager.jwks_json();
|
||||
|
||||
// Verify JWKS structure
|
||||
assert!(jwks.is_object());
|
||||
assert!(jwks.get("keys").is_some());
|
||||
assert!(jwks["keys"].is_array());
|
||||
|
||||
let keys = jwks["keys"].as_array().unwrap();
|
||||
assert_eq!(keys.len(), 1);
|
||||
|
||||
// Verify first key structure
|
||||
let key = &keys[0];
|
||||
assert_eq!(key["kty"], "RSA");
|
||||
assert_eq!(key["use"], "sig");
|
||||
assert_eq!(key["alg"], "RS256");
|
||||
assert_eq!(key["kid"], "test-kid-123");
|
||||
assert!(key.get("n").is_some());
|
||||
assert!(key.get("e").is_some());
|
||||
assert!(key.get("d").is_none()); // Public key only
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random_kid_uniqueness() {
|
||||
// Generate multiple kids
|
||||
let kid1 = random_kid();
|
||||
let kid2 = random_kid();
|
||||
let kid3 = random_kid();
|
||||
|
||||
// Verify they're all different
|
||||
assert_ne!(kid1, kid2);
|
||||
assert_ne!(kid2, kid3);
|
||||
assert_ne!(kid1, kid3);
|
||||
|
||||
// Verify length (16 bytes base64url-encoded)
|
||||
// 16 bytes = 128 bits, base64url without padding is 22 chars
|
||||
assert_eq!(kid1.len(), 22);
|
||||
assert_eq!(kid2.len(), 22);
|
||||
assert_eq!(kid3.len(), 22);
|
||||
|
||||
// Verify all are valid base64url
|
||||
for kid in [kid1, kid2, kid3] {
|
||||
assert!(kid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/lib.rs
Normal file
17
src/lib.rs
Normal 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;
|
||||
30
src/main.rs
30
src/main.rs
|
|
@ -1,15 +1,4 @@
|
|||
mod admin_graphql;
|
||||
mod admin_mutations;
|
||||
mod entities;
|
||||
mod errors;
|
||||
mod jobs;
|
||||
mod jwks;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod storage;
|
||||
mod user_sync;
|
||||
mod web;
|
||||
|
||||
use barycenter::*;
|
||||
use clap::Parser;
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use sea_orm_migration::MigratorTrait;
|
||||
|
|
@ -75,6 +64,13 @@ async fn main() -> Result<()> {
|
|||
// init jwks (generate if missing)
|
||||
let jwks_mgr = jwks::JwksManager::new(settings.keys.clone()).await?;
|
||||
|
||||
// init webauthn manager
|
||||
let issuer_url = url::Url::parse(&settings.issuer()).into_diagnostic()?;
|
||||
let rp_id = issuer_url
|
||||
.host_str()
|
||||
.ok_or_else(|| miette::miette!("Invalid issuer URL: missing host"))?;
|
||||
let webauthn_mgr = webauthn_manager::WebAuthnManager::new(rp_id, &issuer_url).await?;
|
||||
|
||||
// build admin GraphQL schemas
|
||||
let seaography_schema = admin_graphql::build_seaography_schema(db.clone());
|
||||
let jobs_schema = admin_graphql::build_jobs_schema(db.clone());
|
||||
|
|
@ -83,7 +79,15 @@ async fn main() -> Result<()> {
|
|||
let _scheduler = jobs::init_scheduler(db.clone()).await?;
|
||||
|
||||
// start web server (includes both public and admin servers)
|
||||
web::serve(settings, db, jwks_mgr, seaography_schema, jobs_schema).await?;
|
||||
web::serve(
|
||||
settings,
|
||||
db,
|
||||
jwks_mgr,
|
||||
webauthn_mgr,
|
||||
seaography_schema,
|
||||
jobs_schema,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
164
src/settings.rs
164
src/settings.rs
|
|
@ -145,3 +145,167 @@ impl Settings {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_settings_load_defaults() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let config_path = temp_dir.path().join("nonexistent.toml");
|
||||
|
||||
// Load settings with nonexistent file - should use defaults
|
||||
let settings = Settings::load(config_path.to_str().unwrap())
|
||||
.expect("Failed to load settings");
|
||||
|
||||
assert_eq!(settings.server.host, "0.0.0.0");
|
||||
assert_eq!(settings.server.port, 8080);
|
||||
assert_eq!(settings.server.allow_public_registration, false);
|
||||
assert_eq!(settings.database.url, "sqlite://barycenter.db?mode=rwc");
|
||||
assert_eq!(settings.keys.alg, "RS256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_load_from_file() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test config file
|
||||
let config_content = r#"
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 9090
|
||||
public_base_url = "https://idp.example.com"
|
||||
allow_public_registration = true
|
||||
|
||||
[database]
|
||||
url = "postgresql://user:pass@localhost/testdb"
|
||||
|
||||
[keys]
|
||||
alg = "RS256"
|
||||
jwks_path = "test_jwks.json"
|
||||
private_key_path = "test_private.pem"
|
||||
"#;
|
||||
fs::write(&config_path, config_content).expect("Failed to write config");
|
||||
|
||||
// Load settings
|
||||
let settings = Settings::load(config_path.to_str().unwrap())
|
||||
.expect("Failed to load settings");
|
||||
|
||||
assert_eq!(settings.server.host, "127.0.0.1");
|
||||
assert_eq!(settings.server.port, 9090);
|
||||
assert_eq!(
|
||||
settings.server.public_base_url,
|
||||
Some("https://idp.example.com".to_string())
|
||||
);
|
||||
assert_eq!(settings.server.allow_public_registration, true);
|
||||
assert_eq!(settings.database.url, "postgresql://user:pass@localhost/testdb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_env_override() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a base config
|
||||
let config_content = r#"
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
"#;
|
||||
fs::write(&config_path, config_content).expect("Failed to write config");
|
||||
|
||||
// Set environment variable
|
||||
env::set_var("BARYCENTER__SERVER__PORT", "9999");
|
||||
env::set_var("BARYCENTER__SERVER__HOST", "192.168.1.1");
|
||||
|
||||
// Load settings - env should override file
|
||||
let settings = Settings::load(config_path.to_str().unwrap())
|
||||
.expect("Failed to load settings");
|
||||
|
||||
assert_eq!(settings.server.host, "192.168.1.1");
|
||||
assert_eq!(settings.server.port, 9999);
|
||||
|
||||
// Cleanup
|
||||
env::remove_var("BARYCENTER__SERVER__PORT");
|
||||
env::remove_var("BARYCENTER__SERVER__HOST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_issuer_with_public_base_url() {
|
||||
let mut settings = Settings::default();
|
||||
settings.server.public_base_url = Some("https://idp.example.com".to_string());
|
||||
|
||||
let issuer = settings.issuer();
|
||||
assert_eq!(issuer, "https://idp.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_issuer_with_trailing_slash() {
|
||||
let mut settings = Settings::default();
|
||||
settings.server.public_base_url = Some("https://idp.example.com/".to_string());
|
||||
|
||||
let issuer = settings.issuer();
|
||||
// Should trim trailing slash
|
||||
assert_eq!(issuer, "https://idp.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_issuer_fallback() {
|
||||
let mut settings = Settings::default();
|
||||
settings.server.host = "localhost".to_string();
|
||||
settings.server.port = 3000;
|
||||
settings.server.public_base_url = None;
|
||||
|
||||
let issuer = settings.issuer();
|
||||
assert_eq!(issuer, "http://localhost:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_path_normalization() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write config with relative paths
|
||||
let config_content = r#"
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
[database]
|
||||
url = "sqlite://test.db"
|
||||
|
||||
[keys]
|
||||
alg = "RS256"
|
||||
jwks_path = "relative/jwks.json"
|
||||
private_key_path = "relative/private.pem"
|
||||
"#;
|
||||
fs::write(&config_path, config_content).expect("Failed to write config");
|
||||
|
||||
let settings = Settings::load(config_path.to_str().unwrap())
|
||||
.expect("Failed to load settings");
|
||||
|
||||
// Paths should be normalized to absolute
|
||||
assert!(settings.keys.jwks_path.is_absolute());
|
||||
assert!(settings.keys.private_key_path.is_absolute());
|
||||
|
||||
// Should end with the relative path components
|
||||
assert!(settings.keys.jwks_path.ends_with("relative/jwks.json"));
|
||||
assert!(settings.keys.private_key_path.ends_with("relative/private.pem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_public_registration_default() {
|
||||
let settings = Settings::default();
|
||||
|
||||
// Should default to false (secure by default)
|
||||
assert_eq!(settings.server.allow_public_registration, false);
|
||||
|
||||
// Also test the default function directly
|
||||
assert_eq!(default_allow_public_registration(), false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
702
src/storage.rs
702
src/storage.rs
|
|
@ -376,6 +376,8 @@ pub async fn create_user(
|
|||
email_verified: Set(0),
|
||||
created_at: Set(created_at),
|
||||
enabled: Set(1),
|
||||
requires_2fa: Set(0),
|
||||
passkey_enrolled_at: Set(None),
|
||||
};
|
||||
|
||||
user.insert(db).await?;
|
||||
|
|
@ -388,6 +390,8 @@ pub async fn create_user(
|
|||
email_verified: 0,
|
||||
created_at,
|
||||
enabled: 1,
|
||||
requires_2fa: 0,
|
||||
passkey_enrolled_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -701,3 +705,701 @@ pub async fn cleanup_expired_refresh_tokens(db: &DatabaseConnection) -> Result<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
1067
src/web.rs
File diff suppressed because it is too large
Load diff
82
src/webauthn_manager.rs
Normal file
82
src/webauthn_manager.rs
Normal 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
263
tests/helpers/builders.rs
Normal 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
67
tests/helpers/db.rs
Normal 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")
|
||||
}
|
||||
87
tests/helpers/mock_webauthn.rs
Normal file
87
tests/helpers/mock_webauthn.rs
Normal 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
7
tests/helpers/mod.rs
Normal 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};
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue