From 4db80fda2b3f1ec455d32193cdb1e56121fcf4c3 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 16 Apr 2026 17:01:51 -0500 Subject: [PATCH] feat: switch to DPoP auth --- docs/getting-started/authentication.md | 143 ++++++- .../20260416000000_add_api_client_type.sql | 2 + .../20260416000001_create_dpop_keys.sql | 12 + .../20260416000002_create_dpop_sessions.sql | 17 + ...0416000003_add_dpop_session_token_hash.sql | 2 + .../20260416000000_add_api_client_type.sql | 2 + .../20260416000001_create_dpop_keys.sql | 12 + .../20260416000002_create_dpop_sessions.sql | 17 + ...0416000003_add_dpop_session_token_hash.sql | 2 + src/admin/api_clients.rs | 31 +- src/admin/types.rs | 9 + src/auth/middleware.rs | 135 ++++++ src/auth/mod.rs | 1 + src/lib.rs | 1 + src/oauth/client_auth.rs | 252 +++++++++++ src/oauth/dpop_proof.rs | 219 ++++++++++ src/oauth/keys.rs | 260 +++++++++++ src/oauth/mod.rs | 6 + src/oauth/pds_write.rs | 289 +++++++++++++ src/oauth/routes.rs | 404 ++++++++++++++++++ src/oauth/sessions.rs | 274 ++++++++++++ src/repo/mod.rs | 2 +- src/repo/session.rs | 22 + src/repo/upload_blob.rs | 37 +- src/server.rs | 14 +- src/xrpc/mod.rs | 16 +- src/xrpc/procedure.rs | 148 +++++++ tests/common/app.rs | 61 +++ tests/dpop_auth.rs | 396 +++++++++++++++++ 29 files changed, 2766 insertions(+), 20 deletions(-) create mode 100644 migrations/postgres/20260416000000_add_api_client_type.sql create mode 100644 migrations/postgres/20260416000001_create_dpop_keys.sql create mode 100644 migrations/postgres/20260416000002_create_dpop_sessions.sql create mode 100644 migrations/postgres/20260416000003_add_dpop_session_token_hash.sql create mode 100644 migrations/sqlite/20260416000000_add_api_client_type.sql create mode 100644 migrations/sqlite/20260416000001_create_dpop_keys.sql create mode 100644 migrations/sqlite/20260416000002_create_dpop_sessions.sql create mode 100644 migrations/sqlite/20260416000003_add_dpop_session_token_hash.sql create mode 100644 src/oauth/client_auth.rs create mode 100644 src/oauth/dpop_proof.rs create mode 100644 src/oauth/keys.rs create mode 100644 src/oauth/mod.rs create mode 100644 src/oauth/pds_write.rs create mode 100644 src/oauth/routes.rs create mode 100644 src/oauth/sessions.rs create mode 100644 tests/dpop_auth.rs diff --git a/docs/getting-started/authentication.md b/docs/getting-started/authentication.md index 0ea0d67..058fa8c 100644 --- a/docs/getting-started/authentication.md +++ b/docs/getting-started/authentication.md @@ -100,9 +100,148 @@ To add more users after that, use `POST /admin/users` or the [dashboard](dashboa ## Proxying procedures to the user's PDS -When a client calls an XRPC procedure that writes a record, HappyView proxies the write to the user's PDS using the user's stored OAuth session. `atrium-oauth` attaches a DPoP proof and a DPoP-bound access token to the outbound request automatically — HappyView doesn't do any manual DPoP handling. +When a client calls an XRPC procedure that writes a record, HappyView proxies the write to the user's PDS. There are two auth paths that support this: -This only works if HappyView has a live OAuth session for the caller, which in practice means the caller logged in through the dashboard or through an API client's OAuth flow. A request that only carries an `X-Client-Key` header (no session cookie) can hit queries but can't be used to proxy writes — there's no user to write as. Service auth JWTs and admin API keys similarly don't carry a user OAuth session. +- **Cookie auth (dashboard)** — `atrium-oauth` attaches a DPoP proof and a DPoP-bound access token to the outbound request automatically. +- **DPoP key provisioning (third-party apps)** — HappyView uses the app's provisioned DPoP key to generate fresh proofs and attach the stored access token (see below). + +A request that only carries an `X-Client-Key` header (no session cookie or DPoP token) can hit queries but can't proxy writes — there's no user to write as. Service auth JWTs and admin API keys similarly don't carry a user session. + +## DPoP key provisioning for third-party apps + +Third-party apps that want HappyView to make PDS writes on behalf of their users use the **DPoP key provisioning** flow instead of cookie auth. This avoids browser-based redirects through HappyView's domain, which can be blocked by Firefox's Bounce Tracker Protection. + +The idea: the app gets a DPoP keypair from HappyView, uses that keypair during its own OAuth flow with the user's PDS, then registers the resulting tokens back with HappyView. From that point on, XRPC requests authenticated with `Authorization: DPoP ` plus a `DPoP` proof header and `X-Client-Key` will have HappyView proxy writes using the stored session. + +### API clients: confidential vs public + +API clients have a `client_type` field — either `confidential` (default) or `public`. + +- **Confidential clients** authenticate with `X-Client-Key` + `X-Client-Secret` headers on every `/oauth/*` request. +- **Public clients** (browser apps that can't keep a secret) authenticate with `X-Client-Key` header + PKCE. The app sends a `pkce_challenge` (S256) in the body when provisioning a key, then proves possession with `pkce_verifier` when registering a session. Public clients also have `allowed_origins` — the `Origin` header must match. + +### The full flow + +#### 1. Provision a DPoP key + +``` +POST /oauth/dpop-keys +X-Client-Key: hvc_... +X-Client-Secret: hvs_... +Content-Type: application/json + +{} +``` + +For public clients, omit `X-Client-Secret` and include the PKCE challenge in the body: + +``` +POST /oauth/dpop-keys +X-Client-Key: hvc_... +Origin: http://localhost:3000 +Content-Type: application/json + +{ "pkce_challenge": "base64url..." } +``` + +Response: + +```json +{ + "provision_id": "hvp_...", + "dpop_key": { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "d": "..." } +} +``` + +The `dpop_key` is the private JWK. Use it to generate DPoP proofs during your OAuth flow with the user's PDS. + +#### 2. Run OAuth with the user's PDS + +Use the provisioned DPoP key as your DPoP keypair in a standard AT Protocol OAuth flow with the user's PDS. HappyView is not involved in this step — the app talks directly to the PDS authorization server. + +#### 3. Register the session + +After the OAuth callback, register the token set with HappyView: + +``` +POST /oauth/sessions +X-Client-Key: hvc_... +X-Client-Secret: hvs_... +Content-Type: application/json + +{ + "provision_id": "hvp_...", + "did": "did:plc:user123", + "access_token": "...", + "refresh_token": "...", + "expires_at": "2026-04-17T00:00:00Z", + "scopes": "atproto transition:generic", + "pds_url": "https://bsky.social", + "issuer": "https://bsky.social" +} +``` + +For public clients, omit `X-Client-Secret` and include the PKCE verifier in the body: + +```json +{ + "provision_id": "hvp_...", + "pkce_verifier": "...", + "did": "did:plc:user123", + ... +} +``` + +Response: + +```json +{ + "session_id": "uuid", + "did": "did:plc:user123" +} +``` + +#### 4. Make XRPC requests + +With a registered session, send XRPC requests using DPoP auth: + +```sh +curl -X POST 'https://happyview.example.com/xrpc/com.example.feed.createPost' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{"text": "Hello world"}' +``` + +HappyView validates the DPoP proof, looks up the stored session, and proxies the write to the user's PDS using the provisioned DPoP key to generate a fresh proof. + +#### 5. Logout + +Confidential clients authenticate with `X-Client-Key` + `X-Client-Secret`: + +``` +DELETE /oauth/sessions/did:plc:user123 +X-Client-Key: hvc_... +X-Client-Secret: hvs_... +``` + +Public clients must provide a valid DPoP proof to prove they hold the key: + +``` +DELETE /oauth/sessions/did:plc:user123 +X-Client-Key: hvc_... +Authorization: DPoP +DPoP: +``` + +This deletes the stored session and the associated DPoP key. + +### Security notes + +- Private keys and tokens are encrypted at rest with AES-256-GCM using `TOKEN_ENCRYPTION_KEY`. +- DPoP proofs are validated for method, URL, timestamp (5-minute window), access token binding, and JWK thumbprint. +- Scopes requested must include `atproto` and must be a subset of the API client's registered scopes. ## Next steps diff --git a/migrations/postgres/20260416000000_add_api_client_type.sql b/migrations/postgres/20260416000000_add_api_client_type.sql new file mode 100644 index 0000000..064c35c --- /dev/null +++ b/migrations/postgres/20260416000000_add_api_client_type.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_clients ADD COLUMN client_type TEXT NOT NULL DEFAULT 'confidential'; +ALTER TABLE api_clients ADD COLUMN allowed_origins TEXT; diff --git a/migrations/postgres/20260416000001_create_dpop_keys.sql b/migrations/postgres/20260416000001_create_dpop_keys.sql new file mode 100644 index 0000000..4cef20a --- /dev/null +++ b/migrations/postgres/20260416000001_create_dpop_keys.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS dpop_keys ( + id TEXT PRIMARY KEY, + provision_id TEXT NOT NULL UNIQUE, + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, + private_key_enc BYTEA NOT NULL, + jwk_thumbprint TEXT NOT NULL, + pkce_challenge TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_dpop_keys_api_client_id ON dpop_keys(api_client_id); +CREATE INDEX idx_dpop_keys_provision_id ON dpop_keys(provision_id); diff --git a/migrations/postgres/20260416000002_create_dpop_sessions.sql b/migrations/postgres/20260416000002_create_dpop_sessions.sql new file mode 100644 index 0000000..f93c552 --- /dev/null +++ b/migrations/postgres/20260416000002_create_dpop_sessions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS dpop_sessions ( + id TEXT PRIMARY KEY, + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, + dpop_key_id TEXT NOT NULL REFERENCES dpop_keys(id) ON DELETE CASCADE, + user_did TEXT NOT NULL, + access_token_enc BYTEA NOT NULL, + refresh_token_enc BYTEA, + token_expires_at TEXT, + scopes TEXT NOT NULL, + pds_url TEXT, + issuer TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_dpop_sessions_client_user ON dpop_sessions(api_client_id, user_did); +CREATE INDEX idx_dpop_sessions_dpop_key_id ON dpop_sessions(dpop_key_id); diff --git a/migrations/postgres/20260416000003_add_dpop_session_token_hash.sql b/migrations/postgres/20260416000003_add_dpop_session_token_hash.sql new file mode 100644 index 0000000..d37fe77 --- /dev/null +++ b/migrations/postgres/20260416000003_add_dpop_session_token_hash.sql @@ -0,0 +1,2 @@ +ALTER TABLE dpop_sessions ADD COLUMN access_token_hash TEXT; +CREATE INDEX IF NOT EXISTS idx_dpop_sessions_token_hash ON dpop_sessions (api_client_id, access_token_hash); diff --git a/migrations/sqlite/20260416000000_add_api_client_type.sql b/migrations/sqlite/20260416000000_add_api_client_type.sql new file mode 100644 index 0000000..064c35c --- /dev/null +++ b/migrations/sqlite/20260416000000_add_api_client_type.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_clients ADD COLUMN client_type TEXT NOT NULL DEFAULT 'confidential'; +ALTER TABLE api_clients ADD COLUMN allowed_origins TEXT; diff --git a/migrations/sqlite/20260416000001_create_dpop_keys.sql b/migrations/sqlite/20260416000001_create_dpop_keys.sql new file mode 100644 index 0000000..aed6836 --- /dev/null +++ b/migrations/sqlite/20260416000001_create_dpop_keys.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS dpop_keys ( + id TEXT PRIMARY KEY, + provision_id TEXT NOT NULL UNIQUE, + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, + private_key_enc BLOB NOT NULL, + jwk_thumbprint TEXT NOT NULL, + pkce_challenge TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_dpop_keys_api_client_id ON dpop_keys(api_client_id); +CREATE INDEX idx_dpop_keys_provision_id ON dpop_keys(provision_id); diff --git a/migrations/sqlite/20260416000002_create_dpop_sessions.sql b/migrations/sqlite/20260416000002_create_dpop_sessions.sql new file mode 100644 index 0000000..cba903d --- /dev/null +++ b/migrations/sqlite/20260416000002_create_dpop_sessions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS dpop_sessions ( + id TEXT PRIMARY KEY, + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, + dpop_key_id TEXT NOT NULL REFERENCES dpop_keys(id) ON DELETE CASCADE, + user_did TEXT NOT NULL, + access_token_enc BLOB NOT NULL, + refresh_token_enc BLOB, + token_expires_at TEXT, + scopes TEXT NOT NULL, + pds_url TEXT, + issuer TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_dpop_sessions_client_user ON dpop_sessions(api_client_id, user_did); +CREATE INDEX idx_dpop_sessions_dpop_key_id ON dpop_sessions(dpop_key_id); diff --git a/migrations/sqlite/20260416000003_add_dpop_session_token_hash.sql b/migrations/sqlite/20260416000003_add_dpop_session_token_hash.sql new file mode 100644 index 0000000..d37fe77 --- /dev/null +++ b/migrations/sqlite/20260416000003_add_dpop_session_token_hash.sql @@ -0,0 +1,2 @@ +ALTER TABLE dpop_sessions ADD COLUMN access_token_hash TEXT; +CREATE INDEX IF NOT EXISTS idx_dpop_sessions_token_hash ON dpop_sessions (api_client_id, access_token_hash); diff --git a/src/admin/api_clients.rs b/src/admin/api_clients.rs index 2960cba..720039b 100644 --- a/src/admin/api_clients.rs +++ b/src/admin/api_clients.rs @@ -41,8 +41,13 @@ pub(super) async fn create_api_client( let redirect_uris_json = serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string()); + let allowed_origins_json = body + .allowed_origins + .as_ref() + .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); + let insert_sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", state.db_backend, ); @@ -57,6 +62,8 @@ pub(super) async fn create_api_client( .bind(&body.scopes) .bind(body.rate_limit_capacity) .bind(body.rate_limit_refill_rate) + .bind(&body.client_type) + .bind(&allowed_origins_json) .bind(&auth.did) .bind(&now) .bind(&now) @@ -143,7 +150,7 @@ pub(super) async fn list_api_clients( auth.require(Permission::ApiClientsView).await?; let select_sql = adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC", + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC", state.db_backend, ); @@ -156,6 +163,8 @@ pub(super) async fn list_api_clients( String, String, String, + String, + Option, Option, Option, i32, @@ -178,6 +187,8 @@ pub(super) async fn list_api_clients( client_uri, redirect_uris_json, scopes, + client_type, + allowed_origins_json, rate_limit_capacity, rate_limit_refill_rate, is_active, @@ -187,6 +198,9 @@ pub(super) async fn list_api_clients( )| { let redirect_uris: Vec = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); + let allowed_origins: Option> = allowed_origins_json + .as_deref() + .and_then(|j| serde_json::from_str(j).ok()); ApiClientSummary { id, client_key, @@ -195,6 +209,8 @@ pub(super) async fn list_api_clients( client_uri, redirect_uris, scopes, + client_type, + allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active: is_active != 0, @@ -218,7 +234,7 @@ pub(super) async fn get_api_client( auth.require(Permission::ApiClientsView).await?; let select_sql = adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients WHERE id = ?", + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients WHERE id = ?", state.db_backend, ); @@ -230,6 +246,8 @@ pub(super) async fn get_api_client( String, String, String, + String, + Option, Option, Option, i32, @@ -251,6 +269,8 @@ pub(super) async fn get_api_client( client_uri, redirect_uris_json, scopes, + client_type, + allowed_origins_json, rate_limit_capacity, rate_limit_refill_rate, is_active, @@ -263,6 +283,9 @@ pub(super) async fn get_api_client( }; let redirect_uris: Vec = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); + let allowed_origins: Option> = allowed_origins_json + .as_deref() + .and_then(|j| serde_json::from_str(j).ok()); Ok(Json(ApiClientSummary { id, @@ -272,6 +295,8 @@ pub(super) async fn get_api_client( client_uri, redirect_uris, scopes, + client_type, + allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active: is_active != 0, diff --git a/src/admin/types.rs b/src/admin/types.rs index 98bfd55..101f667 100644 --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -356,12 +356,19 @@ pub(super) struct CreateApiClientBody { pub(super) scopes: String, pub(super) rate_limit_capacity: Option, pub(super) rate_limit_refill_rate: Option, + #[serde(default = "default_client_type")] + pub(super) client_type: String, + pub(super) allowed_origins: Option>, } fn default_scopes() -> String { "atproto".to_string() } +fn default_client_type() -> String { + "confidential".to_string() +} + #[derive(Deserialize)] pub(super) struct UpdateApiClientBody { pub(super) name: Option, @@ -382,6 +389,8 @@ pub(super) struct ApiClientSummary { pub(super) client_uri: String, pub(super) redirect_uris: Vec, pub(super) scopes: String, + pub(super) client_type: String, + pub(super) allowed_origins: Option>, pub(super) rate_limit_capacity: Option, pub(super) rate_limit_refill_rate: Option, pub(super) is_active: bool, diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index bf579fb..0bb8ffa 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -101,6 +101,10 @@ impl FromRequestParts for Claims { }); } + if let Some(token) = header.strip_prefix("DPoP ") { + return resolve_dpop_claims(state, parts, token).await; + } + Err(AppError::Auth("invalid Authorization scheme".into())) } } @@ -124,3 +128,134 @@ async fn resolve_api_key_did(state: &AppState, token: &str) -> Result` +/// - `DPoP: ` header +/// - `X-Client-Key: ` header +pub async fn resolve_dpop_claims( + state: &AppState, + parts: &Parts, + access_token: &str, +) -> Result { + let client_key = parts + .headers + .get("x-client-key") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::Auth("DPoP auth requires X-Client-Key header".into()))?; + + let dpop_proof = parts + .headers + .get("dpop") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::Auth("DPoP auth requires DPoP header".into()))?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + // Resolve the API client + let client = + crate::oauth::client_auth::resolve_client_by_key(&state.db, state.db_backend, client_key) + .await?; + + // Look up the session by token + let session = crate::oauth::sessions::get_dpop_session_by_token_hash( + &state.db, + state.db_backend, + encryption_key, + &client.id, + access_token, + ) + .await?; + + // Check token expiry + if let Some(ref expires_at) = session.token_expires_at + && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at) + && exp < chrono::Utc::now() + { + return Err(AppError::Auth("token_expired".into())); + } + + // Get the DPoP key thumbprint for proof validation + let thumbprint = crate::oauth::keys::get_dpop_key_thumbprint( + &state.db, + state.db_backend, + &session.dpop_key_id, + ) + .await?; + + // Build the request URL for htu validation + let scheme = if state.config.public_url.starts_with("https") { + "https" + } else { + "http" + }; + let host = parts + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let request_url = format!("{}://{}{}", scheme, host, parts.uri.path()); + let method = parts.method.as_str(); + + // Validate the DPoP proof + crate::oauth::dpop_proof::validate_dpop_proof( + dpop_proof, + method, + &request_url, + access_token, + &thumbprint, + )?; + + Ok(Claims { + did: session.user_did, + client_key: Some(client_key.to_string()), + }) +} + +/// XRPC-specific claims extractor. +/// +/// Only accepts DPoP auth (`Authorization: DPoP ` + `DPoP` proof + `X-Client-Key`). +/// Cookie auth, Bearer API keys, and service JWTs are rejected on XRPC routes. +/// Wraps `Option` — `None` means anonymous (client-key-only) access. +#[derive(Debug, Clone)] +pub struct XrpcClaims(pub Option); + +impl FromRequestParts for XrpcClaims { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()); + + match header { + Some(h) if h.starts_with("DPoP ") => { + let token = &h[5..]; + let claims = resolve_dpop_claims(state, parts, token).await?; + Ok(XrpcClaims(Some(claims))) + } + Some(h) if h.starts_with("Bearer ") => { + Err(AppError::Auth( + "XRPC routes do not accept Bearer auth. Use DPoP auth or omit the Authorization header for anonymous access.".into(), + )) + } + Some(_) => { + Err(AppError::Auth("invalid Authorization scheme".into())) + } + None => { + // No auth header — anonymous access (client-key only) + Ok(XrpcClaims(None)) + } + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 538f142..02d8fb7 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -6,6 +6,7 @@ pub mod service_auth; pub use client_registry::OAuthClientRegistry; pub use middleware::Claims; +pub use middleware::XrpcClaims; pub use routes::parse_scope_string; pub use service_auth::ServiceAuth; diff --git a/src/lib.rs b/src/lib.rs index 9cd6c8d..f34fada 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod jetstream; pub mod labeler; pub mod lexicon; pub mod lua; +pub mod oauth; pub mod plugin; pub mod profile; pub mod rate_limit; diff --git a/src/oauth/client_auth.rs b/src/oauth/client_auth.rs new file mode 100644 index 0000000..a4c14b2 --- /dev/null +++ b/src/oauth/client_auth.rs @@ -0,0 +1,252 @@ +use sha2::{Digest, Sha256}; + +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; + +/// Resolved API client identity for DPoP operations. +pub struct ResolvedClient { + pub id: String, + pub client_key: String, + pub client_type: String, + pub scopes: String, + pub allowed_origins: Option>, +} + +/// Authenticate a confidential client using client_key + client_secret. +pub async fn authenticate_confidential( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + client_key: &str, + client_secret: &str, +) -> Result { + let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); + + let sql = adapt_sql( + "SELECT id, client_key, client_type, scopes, allowed_origins, client_secret_hash FROM api_clients WHERE client_key = ? AND is_active = 1", + backend, + ); + + let row: Option<(String, String, String, String, Option, String)> = + sqlx::query_as(&sql) + .bind(client_key) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; + + let (id, key, client_type, scopes, origins_json, stored_hash) = + row.ok_or_else(|| AppError::Auth("invalid client credentials".into()))?; + + if stored_hash != secret_hash { + return Err(AppError::Auth("invalid client credentials".into())); + } + + if client_type != "confidential" { + return Err(AppError::Auth( + "this endpoint requires confidential client authentication".into(), + )); + } + + let allowed_origins = + origins_json.map(|json| serde_json::from_str::>(&json).unwrap_or_default()); + + Ok(ResolvedClient { + id, + client_key: key, + client_type, + scopes, + allowed_origins, + }) +} + +/// Authenticate a public client using client_key + origin validation. +/// Returns the client but does NOT verify PKCE — that's done at session registration. +pub async fn authenticate_public( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + client_key: &str, + origin: Option<&str>, +) -> Result { + let sql = adapt_sql( + "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1", + backend, + ); + + let row: Option<(String, String, String, String, Option)> = sqlx::query_as(&sql) + .bind(client_key) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; + + let (id, key, client_type, scopes, origins_json) = + row.ok_or_else(|| AppError::Auth("unknown client".into()))?; + + if client_type != "public" { + return Err(AppError::Auth( + "this client is not registered as a public client".into(), + )); + } + + // Validate origin if the client has allowed_origins configured + if let Some(ref origins_str) = origins_json { + let allowed: Vec = serde_json::from_str(origins_str).unwrap_or_default(); + if !allowed.is_empty() { + match origin { + Some(o) if allowed.iter().any(|a| a == o) => {} + Some(o) => { + tracing::warn!(client_key, origin = o, "Origin mismatch for public client"); + return Err(AppError::Auth("origin not allowed for this client".into())); + } + None => { + tracing::warn!(client_key, "No Origin header for public client"); + return Err(AppError::Auth( + "Origin header required for public clients".into(), + )); + } + } + } + } + + let allowed_origins = + origins_json.map(|json| serde_json::from_str::>(&json).unwrap_or_default()); + + Ok(ResolvedClient { + id, + client_key: key, + client_type, + scopes, + allowed_origins, + }) +} + +/// Resolve an API client by client_key only (no secret verification). +/// Used when the caller has already been authenticated by other means (e.g. DPoP proof). +pub async fn resolve_client_by_key( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + client_key: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1", + backend, + ); + + let row: Option<(String, String, String, String, Option)> = sqlx::query_as(&sql) + .bind(client_key) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; + + let (id, key, client_type, scopes, origins_json) = + row.ok_or_else(|| AppError::Auth("unknown client".into()))?; + + let allowed_origins = + origins_json.map(|json| serde_json::from_str::>(&json).unwrap_or_default()); + + Ok(ResolvedClient { + id, + client_key: key, + client_type, + scopes, + allowed_origins, + }) +} + +/// Validate that token scopes are allowed by the client's registered scopes. +/// +/// Rules: +/// - `atproto` must be present in token scopes (always implicitly allowed) +/// - Every non-`atproto` scope in the token must appear in the client's registered scopes +pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), AppError> { + let token_set: std::collections::HashSet<&str> = token_scopes.split_whitespace().collect(); + let client_set: std::collections::HashSet<&str> = client_scopes.split_whitespace().collect(); + + if !token_set.contains("atproto") { + return Err(AppError::BadRequest( + "token must include the 'atproto' scope".into(), + )); + } + + for scope in &token_set { + if *scope == "atproto" { + continue; // always allowed + } + if !client_set.contains(scope) { + return Err(AppError::BadRequest(format!( + "scope '{}' is not allowed for this client", + scope + ))); + } + } + + Ok(()) +} + +/// Verify a PKCE challenge against a verifier. +pub fn verify_pkce(challenge: &str, verifier: &str) -> bool { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let hash = Sha256::digest(verifier.as_bytes()); + let computed = URL_SAFE_NO_PAD.encode(hash); + computed == challenge +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_scopes_requires_atproto() { + let result = validate_scopes("transition:generic", "atproto transition:generic"); + assert!(result.is_err()); + } + + #[test] + fn validate_scopes_atproto_only_always_passes() { + let result = validate_scopes("atproto", "com.example.whatever"); + assert!(result.is_ok()); + } + + #[test] + fn validate_scopes_subset_passes() { + let result = validate_scopes( + "atproto com.example.basic", + "atproto com.example.basic com.example.advanced", + ); + assert!(result.is_ok()); + } + + #[test] + fn validate_scopes_excess_scope_fails() { + let result = validate_scopes( + "atproto com.example.basic com.example.advanced", + "atproto com.example.basic", + ); + assert!(result.is_err()); + } + + #[test] + fn validate_scopes_transition_generic_requires_registration() { + let result = validate_scopes("atproto transition:generic", "atproto"); + assert!(result.is_err()); + + let result = validate_scopes("atproto transition:generic", "atproto transition:generic"); + assert!(result.is_ok()); + } + + #[test] + fn verify_pkce_valid() { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let verifier = "test-verifier-string-12345678901234567890"; + let hash = sha2::Sha256::digest(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(hash); + + assert!(verify_pkce(&challenge, verifier)); + } + + #[test] + fn verify_pkce_invalid() { + assert!(!verify_pkce("wrong-challenge", "some-verifier")); + } +} diff --git a/src/oauth/dpop_proof.rs b/src/oauth/dpop_proof.rs new file mode 100644 index 0000000..6ff4422 --- /dev/null +++ b/src/oauth/dpop_proof.rs @@ -0,0 +1,219 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::error::AppError; + +#[derive(Debug, Deserialize)] +struct DpopHeader { + alg: String, + typ: String, + jwk: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct DpopPayload { + htm: String, + htu: String, + iat: u64, + ath: Option, + jti: String, +} + +/// Validate a DPoP proof JWT. +/// +/// Checks: +/// - `typ` is `dpop+jwt` +/// - `alg` is `ES256` +/// - `htm` matches the request method +/// - `htu` matches the request URL (scheme + host + path, no query/fragment) +/// - `iat` is within 5 minutes of now +/// - `ath` matches SHA256(access_token) if provided +/// - Signature is valid against the embedded JWK +/// - JWK thumbprint matches the expected thumbprint +pub fn validate_dpop_proof( + proof_jwt: &str, + expected_method: &str, + expected_url: &str, + access_token: &str, + expected_thumbprint: &str, +) -> Result<(), AppError> { + let parts: Vec<&str> = proof_jwt.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid DPoP proof format".into())); + } + + // Decode header + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid DPoP proof header encoding".into()))?; + let header: DpopHeader = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid DPoP proof header".into()))?; + + // Check typ and alg + if header.typ != "dpop+jwt" { + return Err(AppError::Auth("DPoP proof typ must be dpop+jwt".into())); + } + if header.alg != "ES256" { + return Err(AppError::Auth("DPoP proof alg must be ES256".into())); + } + + // Decode payload + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid DPoP proof payload encoding".into()))?; + let payload: DpopPayload = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid DPoP proof payload".into()))?; + + // Check htm + if !payload.htm.eq_ignore_ascii_case(expected_method) { + return Err(AppError::Auth("DPoP proof htm mismatch".into())); + } + + // Check htu (strip query and fragment from expected URL for comparison) + let expected_htu = strip_query_fragment(expected_url); + if payload.htu != expected_htu { + return Err(AppError::Auth("DPoP proof htu mismatch".into())); + } + + // Check iat (within 5 minutes) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if now.abs_diff(payload.iat) > 300 { + return Err(AppError::Auth( + "DPoP proof expired or too far in the future".into(), + )); + } + + // Check ath (access token hash) — required per RFC 9449 section 4.2 + let expected_ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())); + let ath = payload + .ath + .as_ref() + .ok_or_else(|| AppError::Auth("DPoP proof missing required ath claim".into()))?; + if *ath != expected_ath { + return Err(AppError::Auth("DPoP proof ath mismatch".into())); + } + + // Verify JWK thumbprint matches expected + let proof_thumbprint = super::keys::compute_jwk_thumbprint(&header.jwk)?; + if proof_thumbprint != expected_thumbprint { + return Err(AppError::Auth( + "DPoP proof key does not match session".into(), + )); + } + + // Verify signature + let message = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid DPoP proof signature encoding".into()))?; + + verify_es256_jwk(&message, &sig_bytes, &header.jwk)?; + + Ok(()) +} + +/// Verify an ES256 signature using a JWK public key. +fn verify_es256_jwk( + message: &str, + sig_bytes: &[u8], + jwk: &serde_json::Value, +) -> Result<(), AppError> { + let x_b64 = jwk["x"] + .as_str() + .ok_or_else(|| AppError::Auth("DPoP JWK missing x".into()))?; + let y_b64 = jwk["y"] + .as_str() + .ok_or_else(|| AppError::Auth("DPoP JWK missing y".into()))?; + + let x_bytes = URL_SAFE_NO_PAD + .decode(x_b64) + .map_err(|_| AppError::Auth("invalid DPoP JWK x".into()))?; + let y_bytes = URL_SAFE_NO_PAD + .decode(y_b64) + .map_err(|_| AppError::Auth("invalid DPoP JWK y".into()))?; + + // Build SEC1 uncompressed point: 0x04 || x || y + let mut sec1 = Vec::with_capacity(1 + 32 + 32); + sec1.push(0x04); + sec1.extend_from_slice(&x_bytes); + sec1.extend_from_slice(&y_bytes); + + let verifying_key = VerifyingKey::from_sec1_bytes(&sec1) + .map_err(|_| AppError::Auth("invalid DPoP public key".into()))?; + + let signature = Signature::from_bytes(sig_bytes.into()) + .map_err(|_| AppError::Auth("invalid DPoP signature format".into()))?; + + verifying_key + .verify(message.as_bytes(), &signature) + .map_err(|_| AppError::Auth("DPoP proof signature verification failed".into()))?; + + Ok(()) +} + +/// Strip query string and fragment from a URL (per RFC 9449 section 4.2). +fn strip_query_fragment(url: &str) -> &str { + let end = url + .find('#') + .unwrap_or(url.len()) + .min(url.find('?').unwrap_or(url.len())); + &url[..end] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_query_fragment_works() { + assert_eq!( + strip_query_fragment("https://example.com/path"), + "https://example.com/path" + ); + assert_eq!( + strip_query_fragment("https://example.com/path?query=1"), + "https://example.com/path" + ); + assert_eq!( + strip_query_fragment("https://example.com/path#frag"), + "https://example.com/path" + ); + assert_eq!( + strip_query_fragment("https://example.com/path?q=1#f"), + "https://example.com/path" + ); + } + + #[test] + fn rejects_invalid_format() { + let result = validate_dpop_proof( + "not.a.valid.jwt.too-many", + "GET", + "https://example.com", + "token", + "thumb", + ); + assert!(result.is_err()); + } + + #[test] + fn rejects_non_dpop_typ() { + // Build a JWT with typ: "JWT" instead of "dpop+jwt" + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256","typ":"JWT","jwk":{}}"#); + let payload = URL_SAFE_NO_PAD + .encode(r#"{"htm":"GET","htu":"https://example.com","iat":0,"jti":"x"}"#); + let fake_sig = URL_SAFE_NO_PAD.encode(b"fakesig"); + let jwt = format!("{}.{}.{}", header, payload, fake_sig); + + let result = validate_dpop_proof(&jwt, "GET", "https://example.com", "token", "thumb"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("dpop+jwt")); + } +} diff --git a/src/oauth/keys.rs b/src/oauth/keys.rs new file mode 100644 index 0000000..7990efd --- /dev/null +++ b/src/oauth/keys.rs @@ -0,0 +1,260 @@ +use p256::ecdsa::SigningKey; +use rand::RngCore; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::plugin::encryption::{decrypt, encrypt}; + +/// A generated ES256 DPoP keypair with its JWK representation. +#[derive(Debug, Clone, Serialize)] +pub struct DpopKeypair { + /// The private key as a JWK (returned to the app, also stored encrypted). + pub private_jwk: serde_json::Value, + /// The public key as a JWK. + pub public_jwk: serde_json::Value, + /// The JWK thumbprint (RFC 7638) using SHA-256. + pub thumbprint: String, +} + +/// Generate a new ES256 (P-256) DPoP keypair. +pub fn generate_dpop_keypair() -> Result { + let mut rng_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut rng_bytes); + + let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) + .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?; + + let verifying_key = signing_key.verifying_key(); + let public_point = verifying_key.to_encoded_point(false); + + let x_bytes = public_point + .x() + .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?; + let y_bytes = public_point + .y() + .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes); + let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes); + let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes); + + let public_jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": x_b64, + "y": y_b64, + }); + + let private_jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": x_b64, + "y": y_b64, + "d": d_b64, + }); + + let thumbprint = compute_jwk_thumbprint(&public_jwk)?; + + Ok(DpopKeypair { + private_jwk, + public_jwk, + thumbprint, + }) +} + +/// Compute the JWK Thumbprint (RFC 7638) using SHA-256. +/// +/// For EC keys, the canonical JSON is: {"crv":"...","kty":"EC","x":"...","y":"..."} +/// (alphabetical order of required members). +pub fn compute_jwk_thumbprint(jwk: &serde_json::Value) -> Result { + let kty = jwk["kty"] + .as_str() + .ok_or_else(|| AppError::Internal("JWK missing kty".into()))?; + let crv = jwk["crv"] + .as_str() + .ok_or_else(|| AppError::Internal("JWK missing crv".into()))?; + let x = jwk["x"] + .as_str() + .ok_or_else(|| AppError::Internal("JWK missing x".into()))?; + let y = jwk["y"] + .as_str() + .ok_or_else(|| AppError::Internal("JWK missing y".into()))?; + + // RFC 7638: lexicographic order of required members + let canonical = format!( + r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#, + crv, kty, x, y + ); + + let hash = Sha256::digest(canonical.as_bytes()); + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + Ok(URL_SAFE_NO_PAD.encode(hash)) +} + +/// Store a DPoP key in the database with the private key encrypted. +#[allow(clippy::too_many_arguments)] +pub async fn store_dpop_key( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + id: &str, + provision_id: &str, + api_client_id: &str, + keypair: &DpopKeypair, + pkce_challenge: Option<&str>, +) -> Result<(), AppError> { + let private_jwk_bytes = serde_json::to_vec(&keypair.private_jwk) + .map_err(|e| AppError::Internal(format!("failed to serialize JWK: {e}")))?; + + let encrypted = encrypt(encryption_key, &private_jwk_bytes) + .map_err(|e| AppError::Internal(format!("failed to encrypt DPoP key: {e}")))?; + + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO dpop_keys (id, provision_id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(id) + .bind(provision_id) + .bind(api_client_id) + .bind(&encrypted) + .bind(&keypair.thumbprint) + .bind(pkce_challenge) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to store DPoP key: {e}")))?; + + Ok(()) +} + +/// Retrieve and decrypt a DPoP key by provision_id. +pub async fn get_dpop_key( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + provision_id: &str, +) -> Result<(String, String, serde_json::Value, String, Option), AppError> { + // Returns: (id, api_client_id, private_jwk, thumbprint, pkce_challenge) + let sql = adapt_sql( + "SELECT id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge FROM dpop_keys WHERE provision_id = ?", + backend, + ); + + #[allow(clippy::type_complexity)] + let row: Option<(String, String, Vec, String, Option)> = sqlx::query_as(&sql) + .bind(provision_id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; + + let (id, api_client_id, encrypted, thumbprint, pkce_challenge) = + row.ok_or_else(|| AppError::NotFound("DPoP key not found".into()))?; + + let decrypted = decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; + + let private_jwk: serde_json::Value = serde_json::from_slice(&decrypted) + .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; + + Ok((id, api_client_id, private_jwk, thumbprint, pkce_challenge)) +} + +/// Retrieve the JWK thumbprint for a DPoP key by its database ID. +pub async fn get_dpop_key_thumbprint( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + key_id: &str, +) -> Result { + let sql = adapt_sql("SELECT jwk_thumbprint FROM dpop_keys WHERE id = ?", backend); + + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(key_id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; + + row.map(|(t,)| t) + .ok_or_else(|| AppError::NotFound("DPoP key not found".into())) +} + +/// Delete a DPoP key and its associated session. +pub async fn delete_dpop_key( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + dpop_key_id: &str, +) -> Result<(), AppError> { + // Session is deleted by CASCADE, but be explicit for clarity + let session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE dpop_key_id = ?", backend); + let _ = sqlx::query(&session_sql) + .bind(dpop_key_id) + .execute(pool) + .await; + + let key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + sqlx::query(&key_sql) + .bind(dpop_key_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_keypair_produces_valid_jwk() { + let keypair = generate_dpop_keypair().unwrap(); + + // Private JWK has d parameter + assert!(keypair.private_jwk["d"].is_string()); + assert_eq!(keypair.private_jwk["kty"], "EC"); + assert_eq!(keypair.private_jwk["crv"], "P-256"); + + // Public JWK has no d parameter + assert!(keypair.public_jwk["d"].is_null()); + assert_eq!(keypair.public_jwk["kty"], "EC"); + assert_eq!(keypair.public_jwk["crv"], "P-256"); + + // Thumbprint is a base64url string + assert!(!keypair.thumbprint.is_empty()); + assert!(!keypair.thumbprint.contains('=')); + } + + #[test] + fn generate_keypair_produces_unique_keys() { + let kp1 = generate_dpop_keypair().unwrap(); + let kp2 = generate_dpop_keypair().unwrap(); + assert_ne!(kp1.private_jwk["d"], kp2.private_jwk["d"]); + assert_ne!(kp1.thumbprint, kp2.thumbprint); + } + + #[test] + fn thumbprint_is_deterministic() { + let keypair = generate_dpop_keypair().unwrap(); + let t1 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap(); + let t2 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap(); + assert_eq!(t1, t2); + assert_eq!(t1, keypair.thumbprint); + } + + #[test] + fn thumbprint_differs_for_different_keys() { + let kp1 = generate_dpop_keypair().unwrap(); + let kp2 = generate_dpop_keypair().unwrap(); + let t1 = compute_jwk_thumbprint(&kp1.public_jwk).unwrap(); + let t2 = compute_jwk_thumbprint(&kp2.public_jwk).unwrap(); + assert_ne!(t1, t2); + } +} diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs new file mode 100644 index 0000000..9063f32 --- /dev/null +++ b/src/oauth/mod.rs @@ -0,0 +1,6 @@ +pub mod client_auth; +pub mod dpop_proof; +pub mod keys; +pub mod pds_write; +pub mod routes; +pub mod sessions; diff --git a/src/oauth/pds_write.rs b/src/oauth/pds_write.rs new file mode 100644 index 0000000..0597f69 --- /dev/null +++ b/src/oauth/pds_write.rs @@ -0,0 +1,289 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::{SigningKey, signature::Signer}; +use sha2::{Digest, Sha256}; + +use crate::db::DatabaseBackend; +use crate::error::AppError; +use crate::plugin::encryption::decrypt; + +/// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session. +#[allow(clippy::too_many_arguments)] +pub async fn dpop_pds_post( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + plc_url: &str, + api_client_id: &str, + user_did: &str, + xrpc_method: &str, + body: &serde_json::Value, +) -> Result { + let session = + super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) + .await?; + + let pds_url = match session.pds_url { + Some(ref url) => url.clone(), + None => resolve_pds_from_did(http, plc_url, user_did).await?, + }; + + let target_url = format!("{}/xrpc/{}", pds_url.trim_end_matches('/'), xrpc_method); + + // Decrypt the DPoP private key + let key_sql = crate::db::adapt_sql( + "SELECT private_key_enc FROM dpop_keys WHERE id = ?", + backend, + ); + let row: Option<(Vec,)> = sqlx::query_as(&key_sql) + .bind(&session.dpop_key_id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; + + let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?; + + let key_bytes = decrypt(encryption_key, &encrypted_key) + .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; + + let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes) + .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; + + let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?; + + let resp = http + .post(&target_url) + .header("Authorization", format!("DPoP {}", session.access_token)) + .header("DPoP", proof) + .header("Content-Type", "application/json") + .json(body) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + Ok(resp) +} + +/// Make an authenticated blob upload to a PDS using a DPoP session. +#[allow(clippy::too_many_arguments)] +pub async fn dpop_pds_post_blob( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + plc_url: &str, + api_client_id: &str, + user_did: &str, + content_type: &str, + blob: bytes::Bytes, +) -> Result { + let session = + super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) + .await?; + + let pds_url = match session.pds_url { + Some(ref url) => url.clone(), + None => resolve_pds_from_did(http, plc_url, user_did).await?, + }; + + let target_url = format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + pds_url.trim_end_matches('/') + ); + + // Decrypt the DPoP private key + let key_sql = crate::db::adapt_sql( + "SELECT private_key_enc FROM dpop_keys WHERE id = ?", + backend, + ); + let row: Option<(Vec,)> = sqlx::query_as(&key_sql) + .bind(&session.dpop_key_id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; + + let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?; + + let key_bytes = decrypt(encryption_key, &encrypted_key) + .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; + + let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes) + .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; + + let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?; + + let resp = http + .post(&target_url) + .header("Authorization", format!("DPoP {}", session.access_token)) + .header("DPoP", proof) + .header("Content-Type", content_type) + .body(blob) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS uploadBlob request failed: {e}")))?; + + Ok(resp) +} + +/// Generate a DPoP proof JWT for a PDS request. +pub fn generate_dpop_proof( + private_jwk: &serde_json::Value, + method: &str, + url: &str, + access_token: &str, +) -> Result { + let d_b64 = private_jwk["d"] + .as_str() + .ok_or_else(|| AppError::Internal("DPoP key missing d parameter".into()))?; + let x_b64 = private_jwk["x"] + .as_str() + .ok_or_else(|| AppError::Internal("DPoP key missing x parameter".into()))?; + let y_b64 = private_jwk["y"] + .as_str() + .ok_or_else(|| AppError::Internal("DPoP key missing y parameter".into()))?; + + let d_bytes = URL_SAFE_NO_PAD + .decode(d_b64) + .map_err(|_| AppError::Internal("invalid DPoP key d parameter".into()))?; + + let signing_key = SigningKey::from_bytes((&d_bytes[..]).into()) + .map_err(|e| AppError::Internal(format!("invalid DPoP signing key: {e}")))?; + + let public_jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": x_b64, + "y": y_b64, + }); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())); + + let header = serde_json::json!({ + "alg": "ES256", + "typ": "dpop+jwt", + "jwk": public_jwk, + }); + + let payload = serde_json::json!({ + "htm": method, + "htu": url, + "iat": now, + "ath": ath, + "jti": format!("{:x}", rand::random::()), + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + + let message = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) +} + +/// Resolve a user's PDS URL from their DID document. +async fn resolve_pds_from_did( + http: &reqwest::Client, + plc_url: &str, + did: &str, +) -> Result { + let url = if did.starts_with("did:plc:") { + format!("{}/{}", plc_url.trim_end_matches('/'), did) + } else if did.starts_with("did:web:") { + let host = did.strip_prefix("did:web:").unwrap(); + format!("https://{}/.well-known/did.json", host) + } else { + return Err(AppError::Internal(format!("unsupported DID method: {did}"))); + }; + + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to resolve DID: {e}")))?; + + let doc: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("failed to parse DID document: {e}")))?; + + let services = doc["service"] + .as_array() + .ok_or_else(|| AppError::Internal("DID document missing service array".into()))?; + + for service in services { + let id = service["id"].as_str().unwrap_or(""); + if (id == "#atproto_pds" || id.ends_with("#atproto_pds")) + && let Some(endpoint) = service["serviceEndpoint"].as_str() + { + return Ok(endpoint.to_string()); + } + } + + Err(AppError::Internal(format!( + "no #atproto_pds service found in DID document for {did}" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_dpop_proof_produces_valid_jwt() { + let keypair = super::super::keys::generate_dpop_keypair().unwrap(); + + let proof = generate_dpop_proof( + &keypair.private_jwk, + "POST", + "https://pds.example.com/xrpc/com.atproto.repo.createRecord", + "test-access-token", + ) + .unwrap(); + + let parts: Vec<&str> = proof.split('.').collect(); + assert_eq!(parts.len(), 3); + + let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap(); + let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); + assert_eq!(header["alg"], "ES256"); + assert_eq!(header["typ"], "dpop+jwt"); + assert_eq!(header["jwk"]["kty"], "EC"); + + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); + assert_eq!(payload["htm"], "POST"); + assert_eq!( + payload["htu"], + "https://pds.example.com/xrpc/com.atproto.repo.createRecord" + ); + assert!(payload["iat"].is_number()); + assert!(payload["ath"].is_string()); + assert!(payload["jti"].is_string()); + } + + #[test] + fn generated_proof_validates_against_own_key() { + let keypair = super::super::keys::generate_dpop_keypair().unwrap(); + let url = "https://pds.example.com/xrpc/test.method"; + let token = "my-access-token"; + + let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token).unwrap(); + + let result = super::super::dpop_proof::validate_dpop_proof( + &proof, + "POST", + url, + token, + &keypair.thumbprint, + ); + assert!(result.is_ok(), "validation failed: {:?}", result.err()); + } +} diff --git a/src/oauth/routes.rs b/src/oauth/routes.rs new file mode 100644 index 0000000..46785c0 --- /dev/null +++ b/src/oauth/routes.rs @@ -0,0 +1,404 @@ +use axum::extract::{FromRequest, Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::client_auth; +use super::keys; +use super::sessions; + +pub fn routes() -> Router { + Router::new() + .route("/dpop-keys", post(provision_dpop_key)) + .route("/sessions", post(register_session)) + .route("/sessions/{did}", delete(delete_session)) +} + +// --- Request / response types --- + +#[derive(Deserialize)] +struct ProvisionKeyBody { + pkce_challenge: Option, +} + +#[derive(Serialize)] +struct ProvisionKeyResponse { + provision_id: String, + dpop_key: serde_json::Value, +} + +#[derive(Deserialize)] +struct RegisterSessionBody { + provision_id: String, + pkce_verifier: Option, + did: String, + access_token: String, + refresh_token: Option, + expires_at: Option, + scopes: String, + pds_url: Option, + issuer: Option, +} + +#[derive(Serialize)] +struct RegisterSessionResponse { + session_id: String, + did: String, +} + +// --- Handlers --- + +/// POST /oauth/dpop-keys — provision a new DPoP keypair. +/// +/// Client credentials come from `X-Client-Key` and `X-Client-Secret` headers. +async fn provision_dpop_key( + State(state): State, + req: axum::extract::Request, +) -> Result<(StatusCode, Json), AppError> { + let client_key = req + .headers() + .get("x-client-key") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))? + .to_string(); + + let client_secret = req + .headers() + .get("x-client-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let origin = req + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let body: ProvisionKeyBody = Json::::from_request(req, &state) + .await + .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? + .0; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + // Authenticate the client + let client = if let Some(ref secret) = client_secret { + client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) + .await? + } else { + // Public client — must provide PKCE challenge + if body.pkce_challenge.is_none() { + return Err(AppError::BadRequest( + "public clients must provide pkce_challenge".into(), + )); + } + client_auth::authenticate_public( + &state.db, + state.db_backend, + &client_key, + origin.as_deref(), + ) + .await? + }; + + // Generate keypair + let keypair = keys::generate_dpop_keypair()?; + let id = Uuid::new_v4().to_string(); + let provision_id = format!("hvp_{}", hex::encode(rand::random::<[u8; 16]>())); + + // Store encrypted key + keys::store_dpop_key( + &state.db, + state.db_backend, + encryption_key, + &id, + &provision_id, + &client.id, + &keypair, + body.pkce_challenge.as_deref(), + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "dpop_key.provisioned".to_string(), + severity: Severity::Info, + actor_did: None, + subject: Some(provision_id.clone()), + detail: serde_json::json!({ + "client_key": client.client_key, + "thumbprint": keypair.thumbprint, + }), + }, + state.db_backend, + ) + .await; + + Ok(( + StatusCode::CREATED, + Json(ProvisionKeyResponse { + provision_id, + dpop_key: keypair.private_jwk, + }), + )) +} + +/// POST /oauth/sessions — register a token set after OAuth callback. +/// +/// Client credentials come from `X-Client-Key` and `X-Client-Secret` headers. +async fn register_session( + State(state): State, + req: axum::extract::Request, +) -> Result<(StatusCode, Json), AppError> { + let client_key = req + .headers() + .get("x-client-key") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))? + .to_string(); + + let client_secret = req + .headers() + .get("x-client-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let body: RegisterSessionBody = Json::::from_request(req, &state) + .await + .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? + .0; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + // Look up the DPoP key by provision_id + let (dpop_key_id, dpop_client_id, _private_jwk, _thumbprint, pkce_challenge) = + keys::get_dpop_key( + &state.db, + state.db_backend, + encryption_key, + &body.provision_id, + ) + .await?; + + // Authenticate the client and verify it matches the key's client + let client = if let Some(ref secret) = client_secret { + client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) + .await? + } else { + // Public client — verify PKCE + let verifier = body.pkce_verifier.as_deref().ok_or_else(|| { + AppError::BadRequest("public clients must provide pkce_verifier".into()) + })?; + + let challenge = pkce_challenge.as_deref().ok_or_else(|| { + AppError::BadRequest("no PKCE challenge found for this provision".into()) + })?; + + if !client_auth::verify_pkce(challenge, verifier) { + return Err(AppError::Auth("PKCE verification failed".into())); + } + + client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await? + }; + + // Verify client_key matches the key's owning client + if client.id != dpop_client_id { + return Err(AppError::Auth( + "provision_id does not belong to this client".into(), + )); + } + + // Validate scopes + client_auth::validate_scopes(&body.scopes, &client.scopes)?; + + // Clean up any existing session's DPoP key before upserting + // (the ON CONFLICT upsert would orphan the old key otherwise) + { + let lookup_sql = crate::db::adapt_sql( + "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + state.db_backend, + ); + if let Ok(Some((old_key_id,))) = sqlx::query_as::<_, (String,)>(&lookup_sql) + .bind(&client.id) + .bind(&body.did) + .fetch_optional(&state.db) + .await + && old_key_id != dpop_key_id + { + let del_sql = + crate::db::adapt_sql("DELETE FROM dpop_keys WHERE id = ?", state.db_backend); + let _ = sqlx::query(&del_sql) + .bind(&old_key_id) + .execute(&state.db) + .await; + } + } + + // Store the session + let session_id = Uuid::new_v4().to_string(); + sessions::store_dpop_session( + &state.db, + state.db_backend, + encryption_key, + &session_id, + &client.id, + &dpop_key_id, + &body.did, + &body.access_token, + body.refresh_token.as_deref(), + body.expires_at.as_deref(), + &body.scopes, + body.pds_url.as_deref(), + body.issuer.as_deref(), + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "dpop_session.created".to_string(), + severity: Severity::Info, + actor_did: Some(body.did.clone()), + subject: Some(client.client_key.clone()), + detail: serde_json::json!({ + "scopes": body.scopes, + }), + }, + state.db_backend, + ) + .await; + + Ok(( + StatusCode::CREATED, + Json(RegisterSessionResponse { + session_id, + did: body.did, + }), + )) +} + +/// DELETE /oauth/sessions/:did — logout / revoke a session. +/// +/// Confidential clients authenticate with `X-Client-Key` + `X-Client-Secret`. +/// Public clients authenticate with `X-Client-Key` + `Authorization: DPoP ` + `DPoP` proof. +async fn delete_session( + State(state): State, + Path(did): Path, + req: axum::extract::Request, +) -> Result { + let client_key = req + .headers() + .get("x-client-key") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))? + .to_string(); + + let client_secret = req + .headers() + .get("x-client-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let client = if let Some(ref secret) = client_secret { + client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) + .await? + } else { + let resolved = + client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await?; + + // Public clients must prove they hold the DPoP key + token + if resolved.client_type == "public" { + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + AppError::Auth("public clients must provide Authorization: DPoP ".into()) + })?; + let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { + AppError::Auth("public clients must use DPoP authorization scheme".into()) + })?; + let dpop_proof = req + .headers() + .get("dpop") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + AppError::Auth("public clients must provide DPoP proof header".into()) + })?; + + let encryption_key = + state.config.token_encryption_key.as_ref().ok_or_else(|| { + AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()) + })?; + + // Look up the session to get the DPoP key thumbprint + let session = sessions::get_dpop_session_by_token_hash( + &state.db, + state.db_backend, + encryption_key, + &resolved.id, + access_token, + ) + .await?; + + let thumbprint = + keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id) + .await?; + + // Build request URL for htu validation + let scheme = if state.config.public_url.starts_with("https") { + "https" + } else { + "http" + }; + let host = req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did); + + crate::oauth::dpop_proof::validate_dpop_proof( + dpop_proof, + "DELETE", + &request_url, + access_token, + &thumbprint, + )?; + } + + resolved + }; + + sessions::delete_dpop_session(&state.db, state.db_backend, &client.id, &did).await?; + + log_event( + &state.db, + EventLog { + event_type: "dpop_session.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(did), + subject: Some(client.client_key), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/oauth/sessions.rs b/src/oauth/sessions.rs new file mode 100644 index 0000000..9258d29 --- /dev/null +++ b/src/oauth/sessions.rs @@ -0,0 +1,274 @@ +use sha2::{Digest, Sha256}; + +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::plugin::encryption::{decrypt, encrypt}; + +/// Compute a hex-encoded SHA-256 hash of a token for indexed lookup. +fn token_hash(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +/// Stored DPoP session data (decrypted). +pub struct DpopSession { + pub id: String, + pub api_client_id: String, + pub dpop_key_id: String, + pub user_did: String, + pub access_token: String, + pub refresh_token: Option, + pub token_expires_at: Option, + pub scopes: String, + pub pds_url: Option, + pub issuer: Option, +} + +/// Store or update a DPoP session. +/// +/// Uses ON CONFLICT to upsert — if a session already exists for this +/// (api_client_id, user_did), it updates the token data. +#[allow(clippy::too_many_arguments)] +pub async fn store_dpop_session( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + id: &str, + api_client_id: &str, + dpop_key_id: &str, + user_did: &str, + access_token: &str, + refresh_token: Option<&str>, + token_expires_at: Option<&str>, + scopes: &str, + pds_url: Option<&str>, + issuer: Option<&str>, +) -> Result<(), AppError> { + let access_enc = encrypt(encryption_key, access_token.as_bytes()) + .map_err(|e| AppError::Internal(format!("failed to encrypt access token: {e}")))?; + + let access_hash = token_hash(access_token); + + let refresh_enc = refresh_token + .map(|t| { + encrypt(encryption_key, t.as_bytes()) + .map_err(|e| AppError::Internal(format!("failed to encrypt refresh token: {e}"))) + }) + .transpose()?; + + let now = now_rfc3339(); + let sql = adapt_sql( + r#"INSERT INTO dpop_sessions (id, api_client_id, dpop_key_id, user_did, access_token_enc, access_token_hash, refresh_token_enc, token_expires_at, scopes, pds_url, issuer, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (api_client_id, user_did) DO UPDATE SET + dpop_key_id = EXCLUDED.dpop_key_id, + access_token_enc = EXCLUDED.access_token_enc, + access_token_hash = EXCLUDED.access_token_hash, + refresh_token_enc = EXCLUDED.refresh_token_enc, + token_expires_at = EXCLUDED.token_expires_at, + scopes = EXCLUDED.scopes, + pds_url = EXCLUDED.pds_url, + issuer = EXCLUDED.issuer, + updated_at = EXCLUDED.updated_at"#, + backend, + ); + + sqlx::query(&sql) + .bind(id) + .bind(api_client_id) + .bind(dpop_key_id) + .bind(user_did) + .bind(&access_enc) + .bind(&access_hash) + .bind(&refresh_enc) + .bind(token_expires_at) + .bind(scopes) + .bind(pds_url) + .bind(issuer) + .bind(&now) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to store DPoP session: {e}")))?; + + Ok(()) +} + +/// Look up a DPoP session by api_client_id and user_did, decrypting tokens. +pub async fn get_dpop_session( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + api_client_id: &str, + user_did: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id, dpop_key_id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + backend, + ); + + #[allow(clippy::type_complexity)] + let row: Option<( + String, + String, + Vec, + Option>, + Option, + String, + Option, + Option, + )> = sqlx::query_as(&sql) + .bind(api_client_id) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; + + let (id, dpop_key_id, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) = + row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; + + let access_token = String::from_utf8( + decrypt(encryption_key, &access_enc) + .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?, + ) + .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?; + + let refresh_token = refresh_enc + .map(|enc| { + let bytes = decrypt(encryption_key, &enc) + .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?; + String::from_utf8(bytes) + .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}"))) + }) + .transpose()?; + + Ok(DpopSession { + id, + api_client_id: api_client_id.to_string(), + dpop_key_id, + user_did: user_did.to_string(), + access_token, + refresh_token, + token_expires_at, + scopes, + pds_url, + issuer, + }) +} + +/// Look up a DPoP session by api_client_id and access token. +/// Uses the `access_token_hash` column for indexed lookup instead of +/// decrypting every session. +pub async fn get_dpop_session_by_token_hash( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + api_client_id: &str, + access_token: &str, +) -> Result { + let hash = token_hash(access_token); + let sql = adapt_sql( + "SELECT id, dpop_key_id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND access_token_hash = ?", + backend, + ); + + #[allow(clippy::type_complexity)] + let row: Option<( + String, + String, + String, + Vec, + Option>, + Option, + String, + Option, + Option, + )> = sqlx::query_as(&sql) + .bind(api_client_id) + .bind(&hash) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; + + let ( + id, + dpop_key_id, + user_did, + access_enc, + refresh_enc, + token_expires_at, + scopes, + pds_url, + issuer, + ) = row.ok_or_else(|| AppError::Auth("no matching DPoP session".into()))?; + + let access_token_dec = String::from_utf8( + decrypt(encryption_key, &access_enc) + .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?, + ) + .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?; + + let refresh_token = refresh_enc + .map(|enc| { + let bytes = decrypt(encryption_key, &enc) + .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?; + String::from_utf8(bytes) + .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}"))) + }) + .transpose()?; + + Ok(DpopSession { + id, + api_client_id: api_client_id.to_string(), + dpop_key_id, + user_did, + access_token: access_token_dec, + refresh_token, + token_expires_at, + scopes, + pds_url, + issuer, + }) +} + +/// Delete a DPoP session by api_client_id and user_did. +pub async fn delete_dpop_session( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + api_client_id: &str, + user_did: &str, +) -> Result { + // Look up the dpop_key_id before deleting so we can clean up the key too + let lookup_sql = adapt_sql( + "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + backend, + ); + + let row: Option<(String,)> = sqlx::query_as(&lookup_sql) + .bind(api_client_id) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; + + let (dpop_key_id,) = row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; + + let del_session_sql = adapt_sql( + "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + backend, + ); + sqlx::query(&del_session_sql) + .bind(api_client_id) + .bind(user_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?; + + let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + sqlx::query(&del_key_sql) + .bind(&dpop_key_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?; + + Ok(dpop_key_id) +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs index b072c82..d70cbd9 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -3,5 +3,5 @@ pub(crate) mod session; mod upload_blob; pub(crate) use pds::{forward_pds_response, pds_post_json_raw}; -pub(crate) use session::get_oauth_session; +pub(crate) use session::{get_dpop_client_id, get_oauth_session}; pub use upload_blob::upload_blob; diff --git a/src/repo/session.rs b/src/repo/session.rs index be38a5e..b573433 100644 --- a/src/repo/session.rs +++ b/src/repo/session.rs @@ -2,8 +2,30 @@ use atrium_api::types::string::Did; use crate::AppState; use crate::HappyViewOAuthSession; +use crate::db::adapt_sql; use crate::error::AppError; +/// Resolve an API client ID from a client_key. +/// Used by the procedure handler to route DPoP PDS writes. +pub(crate) async fn get_dpop_client_id( + state: &AppState, + client_key: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id FROM api_clients WHERE client_key = ? AND is_active = 1", + state.db_backend, + ); + + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(client_key) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?; + + row.map(|(id,)| id) + .ok_or_else(|| AppError::Auth("unknown API client".into())) +} + /// Resume an OAuth session for the given DID via atrium. /// The returned `OAuthSession` handles DPoP and token refresh internally. pub(crate) async fn get_oauth_session( diff --git a/src/repo/upload_blob.rs b/src/repo/upload_blob.rs index 03c5afd..8bb7c44 100644 --- a/src/repo/upload_blob.rs +++ b/src/repo/upload_blob.rs @@ -4,7 +4,7 @@ use axum::http::HeaderMap; use axum::response::Response; use crate::AppState; -use crate::auth::Claims; +use crate::auth::XrpcClaims; use crate::error::AppError; use crate::rate_limit::CheckResult; @@ -13,10 +13,13 @@ use super::session::get_oauth_session; pub async fn upload_blob( State(state): State, - claims: Claims, + xrpc_claims: XrpcClaims, headers: HeaderMap, body: Bytes, ) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("uploadBlob requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state .rate_limiter @@ -39,14 +42,38 @@ pub async fn upload_blob( }); } - let session = get_oauth_session(&state, claims.did()).await?; - let content_type = headers .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream"); - let mut response = pds_post_blob(&state, &session, content_type, body).await?; + let mut response = if let Some(client_key) = claims.client_key() { + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?; + + let resp = crate::oauth::pds_write::dpop_pds_post_blob( + &state.http, + &state.db, + state.db_backend, + encryption_key, + &state.config.plc_url, + &api_client_id, + claims.did(), + content_type, + body, + ) + .await?; + + crate::repo::forward_pds_response(resp).await? + } else { + let session = get_oauth_session(&state, claims.did()).await?; + pds_post_blob(&state, &session, content_type, body).await? + }; if let Some(CheckResult::Allowed { remaining, diff --git a/src/server.rs b/src/server.rs index 0b03c8d..52308d5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -12,7 +12,7 @@ use tower_http::trace::TraceLayer; use crate::AppState; use crate::admin; -use crate::auth::Claims; +use crate::auth::XrpcClaims; use crate::domain_middleware::resolve_domain; use crate::error::AppError; use crate::profile; @@ -64,6 +64,7 @@ pub fn router(state: AppState) -> Router { let domain_routes = Router::new() .nest("/auth", crate::auth::routes::routes()) .nest("/external-auth", crate::external_auth::routes()) + .nest("/oauth", crate::oauth::routes::routes()) // https://atproto.com/specs/oauth#types-of-clients .route("/oauth-client-metadata.json", get(client_metadata)) .route("/xrpc/app.bsky.actor.getProfile", get(get_profile)) @@ -89,13 +90,14 @@ pub fn router(state: AppState) -> Router { .layer( CorsLayer::new() .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE, axum::http::HeaderName::from_static("x-client-key"), axum::http::HeaderName::from_static("x-client-secret"), + axum::http::HeaderName::from_static("dpop"), ]) .allow_credentials(true), ) @@ -208,7 +210,13 @@ async fn client_metadata( Json(metadata) } -async fn get_profile(State(state): State, claims: Claims) -> Result { +async fn get_profile( + State(state): State, + xrpc_claims: XrpcClaims, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("getProfile requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state .rate_limiter diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index 5555c6e..42d694a 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -3,7 +3,7 @@ pub(crate) mod query; use axum::Json; use axum::body::Body; -use axum::extract::{FromRequestParts, Path, RawQuery, State}; +use axum::extract::{Path, RawQuery, State}; use axum::http::StatusCode; use axum::http::request::Parts; use axum::response::Response; @@ -11,7 +11,7 @@ use serde_json::Value; use std::collections::HashMap; use crate::AppState; -use crate::auth::Claims; +use crate::auth::{Claims, XrpcClaims}; use crate::error::AppError; use crate::lexicon::LexiconType; use crate::rate_limit::CheckResult; @@ -253,11 +253,12 @@ pub async fn xrpc_get( State(state): State, Path(method): Path, RawQuery(raw_query): RawQuery, - mut parts: Parts, + xrpc_claims: XrpcClaims, + parts: Parts, ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let claims = Claims::from_request_parts(&mut parts, &state).await.ok(); + let claims = xrpc_claims.0; let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; @@ -336,12 +337,15 @@ pub async fn xrpc_post( State(state): State, Path(method): Path, RawQuery(raw_query): RawQuery, - mut parts: Parts, + xrpc_claims: XrpcClaims, + parts: Parts, Json(body): Json, ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let claims = Claims::from_request_parts(&mut parts, &state).await?; + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; let rate_key = resolve_client_key(&state, Some(&claims), &parts, ¶ms)?; diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index fb1e14b..4dbec53 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -29,6 +29,29 @@ pub(crate) async fn handle_procedure( AppError::BadRequest(format!("{method} has no target_collection configured")) })?; + // If the user authenticated via a DPoP session (has a client_key from DPoP auth), + // use the DPoP PDS write path. Otherwise, fall back to the atrium OAuth session. + if let Some(client_key) = claims.client_key() { + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let api_client_id = repo::get_dpop_client_id(state, client_key).await?; + + return handle_dpop_procedure( + state, + claims, + input, + collection, + &lexicon.action, + &api_client_id, + encryption_key, + ) + .await; + } + let session = repo::get_oauth_session(state, claims.did()).await?; match &lexicon.action { @@ -263,3 +286,128 @@ async fn handle_delete_record( repo::forward_pds_response(resp).await } } + +async fn handle_dpop_procedure( + state: &AppState, + claims: &Claims, + input: &Value, + collection: &str, + action: &ProcedureAction, + api_client_id: &str, + encryption_key: &[u8; 32], +) -> Result { + let (xrpc_method, pds_body) = match action { + ProcedureAction::Create => { + let mut record = input.clone(); + if let Some(obj) = record.as_object_mut() { + obj.insert("$type".to_string(), json!(collection)); + obj.remove("shouldPublish"); + } + ( + "com.atproto.repo.createRecord", + json!({ + "repo": claims.did(), + "collection": collection, + "record": record, + }), + ) + } + ProcedureAction::Update => { + let uri = input + .get("uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::BadRequest("missing uri field".into()))?; + let rkey = uri + .split('/') + .next_back() + .ok_or_else(|| AppError::Internal("invalid AT URI".into()))?; + let mut record = input.clone(); + if let Some(obj) = record.as_object_mut() { + obj.insert("$type".to_string(), json!(collection)); + obj.remove("uri"); + obj.remove("shouldPublish"); + } + ( + "com.atproto.repo.putRecord", + json!({ + "repo": claims.did(), + "collection": collection, + "rkey": rkey, + "record": record, + }), + ) + } + ProcedureAction::Delete => { + let uri = input + .get("uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::BadRequest("missing uri field".into()))?; + let rkey = uri + .split('/') + .next_back() + .ok_or_else(|| AppError::Internal("invalid AT URI".into()))?; + ( + "com.atproto.repo.deleteRecord", + json!({ + "repo": claims.did(), + "collection": collection, + "rkey": rkey, + }), + ) + } + ProcedureAction::Upsert => { + let has_uri = input.get("uri").and_then(|v| v.as_str()).is_some(); + if has_uri { + let uri = input["uri"].as_str().unwrap(); + let rkey = uri + .split('/') + .next_back() + .ok_or_else(|| AppError::Internal("invalid AT URI".into()))?; + let mut record = input.clone(); + if let Some(obj) = record.as_object_mut() { + obj.insert("$type".to_string(), json!(collection)); + obj.remove("uri"); + obj.remove("shouldPublish"); + } + ( + "com.atproto.repo.putRecord", + json!({ + "repo": claims.did(), + "collection": collection, + "rkey": rkey, + "record": record, + }), + ) + } else { + let mut record = input.clone(); + if let Some(obj) = record.as_object_mut() { + obj.insert("$type".to_string(), json!(collection)); + obj.remove("shouldPublish"); + } + ( + "com.atproto.repo.createRecord", + json!({ + "repo": claims.did(), + "collection": collection, + "record": record, + }), + ) + } + } + }; + + let resp = crate::oauth::pds_write::dpop_pds_post( + &state.http, + &state.db, + state.db_backend, + encryption_key, + &state.config.plc_url, + api_client_id, + claims.did(), + xrpc_method, + &pds_body, + ) + .await?; + + repo::forward_pds_response(resp).await +} diff --git a/tests/common/app.rs b/tests/common/app.rs index 34e980f..a254b4e 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -165,6 +165,67 @@ impl TestApp { } } + pub async fn new_with_encryption() -> Self { + let mut app = Self::new().await; + // Set a test encryption key (32 bytes) + app.state.config.token_encryption_key = Some([0x42u8; 32]); + // Rebuild the router with the updated state + app.router = server::router(app.state.clone()); + app + } + + /// Create an API client in the database for testing. + /// Returns (client_key, client_secret, api_client_id). + pub async fn create_api_client( + &self, + client_type: &str, + allowed_origins: Option>, + ) -> (String, String, String) { + use happyview::db::{adapt_sql, now_rfc3339}; + use rand::RngCore; + use sha2::{Digest, Sha256}; + + let mut key_bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut key_bytes); + let client_key = format!("hvc_{}", hex::encode(key_bytes)); + + let mut secret_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut secret_bytes); + let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); + let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); + + let id = uuid::Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let origins_json = allowed_origins + .as_ref() + .map(|o| serde_json::to_string(o).unwrap_or_else(|_| "[]".to_string())); + + let sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", + self.state.db_backend, + ); + + sqlx::query(&sql) + .bind(&id) + .bind(&client_key) + .bind(&secret_hash) + .bind("test-client") + .bind(format!("https://test.example.com/oauth/{}", &id[..8])) + .bind("https://test.example.com") + .bind("[]") + .bind("atproto") + .bind(client_type) + .bind(&origins_json) + .bind(&self.admin_did) + .bind(&now) + .bind(&now) + .execute(&self.state.db) + .await + .expect("failed to create test API client"); + + (client_key, client_secret, id) + } + /// Build a Cookie header that authenticates as the admin user. pub fn admin_cookie(&self) -> (axum::http::HeaderName, axum::http::HeaderValue) { crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key) diff --git a/tests/dpop_auth.rs b/tests/dpop_auth.rs new file mode 100644 index 0000000..0a02532 --- /dev/null +++ b/tests/dpop_auth.rs @@ -0,0 +1,396 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use happyview::oauth::pds_write::generate_dpop_proof; +use http_body_util::BodyExt; +use serde_json::json; +use serial_test::serial; +use tower::ServiceExt; + +/// Helper to make a POST request with JSON body and extra headers +fn post_json_with_headers( + uri: &str, + body: &serde_json::Value, + headers: Vec<(&str, &str)>, +) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +/// Helper to make a DELETE request with headers +fn delete_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder().method("DELETE").uri(uri); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder.body(Body::empty()).unwrap() +} + +async fn response_json(resp: axum::http::Response) -> serde_json::Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(json!(null)) +} + +#[tokio::test] +#[serial] +async fn test_provision_dpop_key_confidential_client() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + let req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let body = response_json(resp).await; + assert!(body["provision_id"].is_string()); + assert!(body["dpop_key"]["d"].is_string()); + assert_eq!(body["dpop_key"]["kty"], "EC"); + assert_eq!(body["dpop_key"]["crv"], "P-256"); +} + +#[tokio::test] +#[serial] +async fn test_provision_dpop_key_public_client_requires_pkce() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, _secret, _id) = app + .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) + .await; + + // Without PKCE challenge should fail + let req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("origin", "http://localhost:3000"), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn test_provision_dpop_key_public_client_with_pkce() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, _secret, _id) = app + .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + let verifier = "test-verifier-string-for-pkce-challenge-1234"; + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + + let req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({ + "pkce_challenge": challenge, + }), + vec![ + ("x-client-key", &client_key), + ("origin", "http://localhost:3000"), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let body = response_json(resp).await; + assert!(body["provision_id"].is_string()); +} + +#[tokio::test] +#[serial] +async fn test_register_session_validates_scopes() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + // Provision a key first + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + + // Try to register with a scope the client doesn't have + let req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": "did:plc:test123", + "access_token": "test-token", + "scopes": "atproto com.unauthorized.scope", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn test_register_session_requires_atproto_scope() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + + let req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": "did:plc:test123", + "access_token": "test-token", + "scopes": "transition:generic", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn test_full_flow_provision_register_delete() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + // 1. Provision key + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + + // 2. Register session + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": "did:plc:testuser", + "access_token": "test-access-token-123", + "refresh_token": "test-refresh-token-456", + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + let session_body = response_json(session_resp).await; + assert_eq!(session_body["did"], "did:plc:testuser"); + + // 3. Delete session + let delete_req = delete_with_headers( + "/oauth/sessions/did:plc:testuser", + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let delete_resp = app.router.clone().oneshot(delete_req).await.unwrap(); + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); + + // 4. Verify session is gone (try to delete again) + let delete_req2 = delete_with_headers( + "/oauth/sessions/did:plc:testuser", + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let delete_resp2 = app.router.clone().oneshot(delete_req2).await.unwrap(); + assert_eq!(delete_resp2.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn test_xrpc_rejects_bearer_auth() { + let app = common::app::TestApp::new_with_encryption().await; + + // Bearer auth should be explicitly rejected on XRPC routes + let req = Request::builder() + .method("GET") + .uri("/xrpc/com.example.test.getStuff") + .header("x-client-key", "hvc_fake") + .header("authorization", "Bearer hv_some-api-key") + .body(Body::empty()) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = response_json(resp).await; + let msg = body["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("XRPC routes do not accept Bearer auth"), + "expected Bearer rejection message, got: {msg}" + ); +} + +#[tokio::test] +#[serial] +async fn test_xrpc_allows_anonymous_queries() { + let app = common::app::TestApp::new_with_encryption().await; + + // Anonymous access (no auth header) should pass through to lexicon lookup. + // Since no lexicon is registered, we expect a proxy attempt or 502, not 401. + let req = Request::builder() + .method("GET") + .uri("/xrpc/com.example.test.getStuff") + .header("x-client-key", "hvc_fake") + .body(Body::empty()) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + // Not a 401 — anonymous access was allowed, the error is from the handler (no lexicon) + assert_ne!( + resp.status(), + StatusCode::UNAUTHORIZED, + "anonymous XRPC queries should not require auth" + ); +} + +#[tokio::test] +#[serial] +async fn test_xrpc_procedure_requires_dpop_auth() { + let app = common::app::TestApp::new_with_encryption().await; + + // POST to an XRPC procedure without DPoP auth should be rejected + let req = Request::builder() + .method("POST") + .uri("/xrpc/com.example.test.createStuff") + .header("x-client-key", "hvc_fake") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = response_json(resp).await; + let msg = body["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("DPoP authentication"), + "expected DPoP requirement message, got: {msg}" + ); +} + +#[tokio::test] +#[serial] +async fn test_xrpc_dpop_auth_accepted() { + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + // 1. Provision key + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + let dpop_key = &key_body["dpop_key"]; + + // 2. Register session + let access_token = "test-xrpc-access-token"; + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": "did:plc:xrpcuser", + "access_token": access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + // 3. Generate a DPoP proof for an XRPC GET request + let request_url = "http://127.0.0.1:0/xrpc/com.example.test.getStuff"; + let proof = generate_dpop_proof(dpop_key, "GET", request_url, access_token) + .expect("failed to generate DPoP proof"); + + // 4. Make an XRPC request with DPoP auth + let xrpc_req = Request::builder() + .method("GET") + .uri("/xrpc/com.example.test.getStuff") + .header("host", "127.0.0.1:0") + .header("x-client-key", &client_key) + .header("authorization", format!("DPoP {}", access_token)) + .header("dpop", &proof) + .body(Body::empty()) + .unwrap(); + + let xrpc_resp = app.router.clone().oneshot(xrpc_req).await.unwrap(); + // Auth should succeed — any non-401 status means DPoP auth was accepted. + // We expect a 502 (proxy attempt for unknown lexicon) or similar, not 401. + assert_ne!( + xrpc_resp.status(), + StatusCode::UNAUTHORIZED, + "DPoP-authenticated XRPC request should not get 401" + ); +} -- 2.51.2