A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: switch to DPoP auth

Trezy 4db80fda fd9352d2

+2766 -20
+141 -2
docs/getting-started/authentication.md
··· 100 100 101 101 ## Proxying procedures to the user's PDS 102 102 103 - 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. 103 + 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: 104 + 105 + - **Cookie auth (dashboard)** — `atrium-oauth` attaches a DPoP proof and a DPoP-bound access token to the outbound request automatically. 106 + - **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). 107 + 108 + 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. 109 + 110 + ## DPoP key provisioning for third-party apps 111 + 112 + 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. 113 + 114 + 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 <access_token>` plus a `DPoP` proof header and `X-Client-Key` will have HappyView proxy writes using the stored session. 115 + 116 + ### API clients: confidential vs public 117 + 118 + API clients have a `client_type` field — either `confidential` (default) or `public`. 119 + 120 + - **Confidential clients** authenticate with `X-Client-Key` + `X-Client-Secret` headers on every `/oauth/*` request. 121 + - **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. 122 + 123 + ### The full flow 124 + 125 + #### 1. Provision a DPoP key 126 + 127 + ``` 128 + POST /oauth/dpop-keys 129 + X-Client-Key: hvc_... 130 + X-Client-Secret: hvs_... 131 + Content-Type: application/json 132 + 133 + {} 134 + ``` 135 + 136 + For public clients, omit `X-Client-Secret` and include the PKCE challenge in the body: 137 + 138 + ``` 139 + POST /oauth/dpop-keys 140 + X-Client-Key: hvc_... 141 + Origin: http://localhost:3000 142 + Content-Type: application/json 143 + 144 + { "pkce_challenge": "base64url..." } 145 + ``` 146 + 147 + Response: 148 + 149 + ```json 150 + { 151 + "provision_id": "hvp_...", 152 + "dpop_key": { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "d": "..." } 153 + } 154 + ``` 155 + 156 + The `dpop_key` is the private JWK. Use it to generate DPoP proofs during your OAuth flow with the user's PDS. 157 + 158 + #### 2. Run OAuth with the user's PDS 159 + 160 + 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. 161 + 162 + #### 3. Register the session 163 + 164 + After the OAuth callback, register the token set with HappyView: 165 + 166 + ``` 167 + POST /oauth/sessions 168 + X-Client-Key: hvc_... 169 + X-Client-Secret: hvs_... 170 + Content-Type: application/json 171 + 172 + { 173 + "provision_id": "hvp_...", 174 + "did": "did:plc:user123", 175 + "access_token": "...", 176 + "refresh_token": "...", 177 + "expires_at": "2026-04-17T00:00:00Z", 178 + "scopes": "atproto transition:generic", 179 + "pds_url": "https://bsky.social", 180 + "issuer": "https://bsky.social" 181 + } 182 + ``` 183 + 184 + For public clients, omit `X-Client-Secret` and include the PKCE verifier in the body: 185 + 186 + ```json 187 + { 188 + "provision_id": "hvp_...", 189 + "pkce_verifier": "...", 190 + "did": "did:plc:user123", 191 + ... 192 + } 193 + ``` 194 + 195 + Response: 196 + 197 + ```json 198 + { 199 + "session_id": "uuid", 200 + "did": "did:plc:user123" 201 + } 202 + ``` 203 + 204 + #### 4. Make XRPC requests 205 + 206 + With a registered session, send XRPC requests using DPoP auth: 207 + 208 + ```sh 209 + curl -X POST 'https://happyview.example.com/xrpc/com.example.feed.createPost' \ 210 + -H 'X-Client-Key: hvc_...' \ 211 + -H 'Authorization: DPoP <access_token>' \ 212 + -H 'DPoP: <proof_jwt>' \ 213 + -H 'Content-Type: application/json' \ 214 + -d '{"text": "Hello world"}' 215 + ``` 216 + 217 + 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. 218 + 219 + #### 5. Logout 220 + 221 + Confidential clients authenticate with `X-Client-Key` + `X-Client-Secret`: 222 + 223 + ``` 224 + DELETE /oauth/sessions/did:plc:user123 225 + X-Client-Key: hvc_... 226 + X-Client-Secret: hvs_... 227 + ``` 228 + 229 + Public clients must provide a valid DPoP proof to prove they hold the key: 230 + 231 + ``` 232 + DELETE /oauth/sessions/did:plc:user123 233 + X-Client-Key: hvc_... 234 + Authorization: DPoP <access_token> 235 + DPoP: <proof_jwt> 236 + ``` 237 + 238 + This deletes the stored session and the associated DPoP key. 104 239 105 - 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. 240 + ### Security notes 241 + 242 + - Private keys and tokens are encrypted at rest with AES-256-GCM using `TOKEN_ENCRYPTION_KEY`. 243 + - DPoP proofs are validated for method, URL, timestamp (5-minute window), access token binding, and JWK thumbprint. 244 + - Scopes requested must include `atproto` and must be a subset of the API client's registered scopes. 106 245 107 246 ## Next steps 108 247
+2
migrations/postgres/20260416000000_add_api_client_type.sql
··· 1 + ALTER TABLE api_clients ADD COLUMN client_type TEXT NOT NULL DEFAULT 'confidential'; 2 + ALTER TABLE api_clients ADD COLUMN allowed_origins TEXT;
+12
migrations/postgres/20260416000001_create_dpop_keys.sql
··· 1 + CREATE TABLE IF NOT EXISTS dpop_keys ( 2 + id TEXT PRIMARY KEY, 3 + provision_id TEXT NOT NULL UNIQUE, 4 + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, 5 + private_key_enc BYTEA NOT NULL, 6 + jwk_thumbprint TEXT NOT NULL, 7 + pkce_challenge TEXT, 8 + created_at TEXT NOT NULL 9 + ); 10 + 11 + CREATE INDEX idx_dpop_keys_api_client_id ON dpop_keys(api_client_id); 12 + CREATE INDEX idx_dpop_keys_provision_id ON dpop_keys(provision_id);
+17
migrations/postgres/20260416000002_create_dpop_sessions.sql
··· 1 + CREATE TABLE IF NOT EXISTS dpop_sessions ( 2 + id TEXT PRIMARY KEY, 3 + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, 4 + dpop_key_id TEXT NOT NULL REFERENCES dpop_keys(id) ON DELETE CASCADE, 5 + user_did TEXT NOT NULL, 6 + access_token_enc BYTEA NOT NULL, 7 + refresh_token_enc BYTEA, 8 + token_expires_at TEXT, 9 + scopes TEXT NOT NULL, 10 + pds_url TEXT, 11 + issuer TEXT, 12 + created_at TEXT NOT NULL, 13 + updated_at TEXT NOT NULL 14 + ); 15 + 16 + CREATE UNIQUE INDEX idx_dpop_sessions_client_user ON dpop_sessions(api_client_id, user_did); 17 + CREATE INDEX idx_dpop_sessions_dpop_key_id ON dpop_sessions(dpop_key_id);
+2
migrations/postgres/20260416000003_add_dpop_session_token_hash.sql
··· 1 + ALTER TABLE dpop_sessions ADD COLUMN access_token_hash TEXT; 2 + CREATE INDEX IF NOT EXISTS idx_dpop_sessions_token_hash ON dpop_sessions (api_client_id, access_token_hash);
+2
migrations/sqlite/20260416000000_add_api_client_type.sql
··· 1 + ALTER TABLE api_clients ADD COLUMN client_type TEXT NOT NULL DEFAULT 'confidential'; 2 + ALTER TABLE api_clients ADD COLUMN allowed_origins TEXT;
+12
migrations/sqlite/20260416000001_create_dpop_keys.sql
··· 1 + CREATE TABLE IF NOT EXISTS dpop_keys ( 2 + id TEXT PRIMARY KEY, 3 + provision_id TEXT NOT NULL UNIQUE, 4 + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, 5 + private_key_enc BLOB NOT NULL, 6 + jwk_thumbprint TEXT NOT NULL, 7 + pkce_challenge TEXT, 8 + created_at TEXT NOT NULL 9 + ); 10 + 11 + CREATE INDEX idx_dpop_keys_api_client_id ON dpop_keys(api_client_id); 12 + CREATE INDEX idx_dpop_keys_provision_id ON dpop_keys(provision_id);
+17
migrations/sqlite/20260416000002_create_dpop_sessions.sql
··· 1 + CREATE TABLE IF NOT EXISTS dpop_sessions ( 2 + id TEXT PRIMARY KEY, 3 + api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE, 4 + dpop_key_id TEXT NOT NULL REFERENCES dpop_keys(id) ON DELETE CASCADE, 5 + user_did TEXT NOT NULL, 6 + access_token_enc BLOB NOT NULL, 7 + refresh_token_enc BLOB, 8 + token_expires_at TEXT, 9 + scopes TEXT NOT NULL, 10 + pds_url TEXT, 11 + issuer TEXT, 12 + created_at TEXT NOT NULL, 13 + updated_at TEXT NOT NULL 14 + ); 15 + 16 + CREATE UNIQUE INDEX idx_dpop_sessions_client_user ON dpop_sessions(api_client_id, user_did); 17 + CREATE INDEX idx_dpop_sessions_dpop_key_id ON dpop_sessions(dpop_key_id);
+2
migrations/sqlite/20260416000003_add_dpop_session_token_hash.sql
··· 1 + ALTER TABLE dpop_sessions ADD COLUMN access_token_hash TEXT; 2 + CREATE INDEX IF NOT EXISTS idx_dpop_sessions_token_hash ON dpop_sessions (api_client_id, access_token_hash);
+28 -3
src/admin/api_clients.rs
··· 41 41 let redirect_uris_json = 42 42 serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string()); 43 43 44 + let allowed_origins_json = body 45 + .allowed_origins 46 + .as_ref() 47 + .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); 48 + 44 49 let insert_sql = adapt_sql( 45 - "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, ?, ?, ?)", 50 + "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, ?, ?, ?)", 46 51 state.db_backend, 47 52 ); 48 53 ··· 57 62 .bind(&body.scopes) 58 63 .bind(body.rate_limit_capacity) 59 64 .bind(body.rate_limit_refill_rate) 65 + .bind(&body.client_type) 66 + .bind(&allowed_origins_json) 60 67 .bind(&auth.did) 61 68 .bind(&now) 62 69 .bind(&now) ··· 143 150 auth.require(Permission::ApiClientsView).await?; 144 151 145 152 let select_sql = adapt_sql( 146 - "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", 153 + "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", 147 154 state.db_backend, 148 155 ); 149 156 ··· 156 163 String, 157 164 String, 158 165 String, 166 + String, 167 + Option<String>, 159 168 Option<i32>, 160 169 Option<f64>, 161 170 i32, ··· 178 187 client_uri, 179 188 redirect_uris_json, 180 189 scopes, 190 + client_type, 191 + allowed_origins_json, 181 192 rate_limit_capacity, 182 193 rate_limit_refill_rate, 183 194 is_active, ··· 187 198 )| { 188 199 let redirect_uris: Vec<String> = 189 200 serde_json::from_str(&redirect_uris_json).unwrap_or_default(); 201 + let allowed_origins: Option<Vec<String>> = allowed_origins_json 202 + .as_deref() 203 + .and_then(|j| serde_json::from_str(j).ok()); 190 204 ApiClientSummary { 191 205 id, 192 206 client_key, ··· 195 209 client_uri, 196 210 redirect_uris, 197 211 scopes, 212 + client_type, 213 + allowed_origins, 198 214 rate_limit_capacity, 199 215 rate_limit_refill_rate, 200 216 is_active: is_active != 0, ··· 218 234 auth.require(Permission::ApiClientsView).await?; 219 235 220 236 let select_sql = adapt_sql( 221 - "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 = ?", 237 + "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 = ?", 222 238 state.db_backend, 223 239 ); 224 240 ··· 230 246 String, 231 247 String, 232 248 String, 249 + String, 250 + Option<String>, 233 251 Option<i32>, 234 252 Option<f64>, 235 253 i32, ··· 251 269 client_uri, 252 270 redirect_uris_json, 253 271 scopes, 272 + client_type, 273 + allowed_origins_json, 254 274 rate_limit_capacity, 255 275 rate_limit_refill_rate, 256 276 is_active, ··· 263 283 }; 264 284 265 285 let redirect_uris: Vec<String> = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); 286 + let allowed_origins: Option<Vec<String>> = allowed_origins_json 287 + .as_deref() 288 + .and_then(|j| serde_json::from_str(j).ok()); 266 289 267 290 Ok(Json(ApiClientSummary { 268 291 id, ··· 272 295 client_uri, 273 296 redirect_uris, 274 297 scopes, 298 + client_type, 299 + allowed_origins, 275 300 rate_limit_capacity, 276 301 rate_limit_refill_rate, 277 302 is_active: is_active != 0,
+9
src/admin/types.rs
··· 356 356 pub(super) scopes: String, 357 357 pub(super) rate_limit_capacity: Option<i32>, 358 358 pub(super) rate_limit_refill_rate: Option<f64>, 359 + #[serde(default = "default_client_type")] 360 + pub(super) client_type: String, 361 + pub(super) allowed_origins: Option<Vec<String>>, 359 362 } 360 363 361 364 fn default_scopes() -> String { 362 365 "atproto".to_string() 366 + } 367 + 368 + fn default_client_type() -> String { 369 + "confidential".to_string() 363 370 } 364 371 365 372 #[derive(Deserialize)] ··· 382 389 pub(super) client_uri: String, 383 390 pub(super) redirect_uris: Vec<String>, 384 391 pub(super) scopes: String, 392 + pub(super) client_type: String, 393 + pub(super) allowed_origins: Option<Vec<String>>, 385 394 pub(super) rate_limit_capacity: Option<i32>, 386 395 pub(super) rate_limit_refill_rate: Option<f64>, 387 396 pub(super) is_active: bool,
+135
src/auth/middleware.rs
··· 101 101 }); 102 102 } 103 103 104 + if let Some(token) = header.strip_prefix("DPoP ") { 105 + return resolve_dpop_claims(state, parts, token).await; 106 + } 107 + 104 108 Err(AppError::Auth("invalid Authorization scheme".into())) 105 109 } 106 110 } ··· 124 128 row.map(|(did,)| did) 125 129 .ok_or_else(|| AppError::Auth("invalid API key".into())) 126 130 } 131 + 132 + /// Resolve claims from a DPoP-authenticated request. 133 + /// 134 + /// Expects: 135 + /// - `Authorization: DPoP <access_token>` 136 + /// - `DPoP: <proof_jwt>` header 137 + /// - `X-Client-Key: <client_key>` header 138 + pub async fn resolve_dpop_claims( 139 + state: &AppState, 140 + parts: &Parts, 141 + access_token: &str, 142 + ) -> Result<Claims, AppError> { 143 + let client_key = parts 144 + .headers 145 + .get("x-client-key") 146 + .and_then(|v| v.to_str().ok()) 147 + .ok_or_else(|| AppError::Auth("DPoP auth requires X-Client-Key header".into()))?; 148 + 149 + let dpop_proof = parts 150 + .headers 151 + .get("dpop") 152 + .and_then(|v| v.to_str().ok()) 153 + .ok_or_else(|| AppError::Auth("DPoP auth requires DPoP header".into()))?; 154 + 155 + let encryption_key = state 156 + .config 157 + .token_encryption_key 158 + .as_ref() 159 + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; 160 + 161 + // Resolve the API client 162 + let client = 163 + crate::oauth::client_auth::resolve_client_by_key(&state.db, state.db_backend, client_key) 164 + .await?; 165 + 166 + // Look up the session by token 167 + let session = crate::oauth::sessions::get_dpop_session_by_token_hash( 168 + &state.db, 169 + state.db_backend, 170 + encryption_key, 171 + &client.id, 172 + access_token, 173 + ) 174 + .await?; 175 + 176 + // Check token expiry 177 + if let Some(ref expires_at) = session.token_expires_at 178 + && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at) 179 + && exp < chrono::Utc::now() 180 + { 181 + return Err(AppError::Auth("token_expired".into())); 182 + } 183 + 184 + // Get the DPoP key thumbprint for proof validation 185 + let thumbprint = crate::oauth::keys::get_dpop_key_thumbprint( 186 + &state.db, 187 + state.db_backend, 188 + &session.dpop_key_id, 189 + ) 190 + .await?; 191 + 192 + // Build the request URL for htu validation 193 + let scheme = if state.config.public_url.starts_with("https") { 194 + "https" 195 + } else { 196 + "http" 197 + }; 198 + let host = parts 199 + .headers 200 + .get("host") 201 + .and_then(|v| v.to_str().ok()) 202 + .unwrap_or("localhost"); 203 + let request_url = format!("{}://{}{}", scheme, host, parts.uri.path()); 204 + let method = parts.method.as_str(); 205 + 206 + // Validate the DPoP proof 207 + crate::oauth::dpop_proof::validate_dpop_proof( 208 + dpop_proof, 209 + method, 210 + &request_url, 211 + access_token, 212 + &thumbprint, 213 + )?; 214 + 215 + Ok(Claims { 216 + did: session.user_did, 217 + client_key: Some(client_key.to_string()), 218 + }) 219 + } 220 + 221 + /// XRPC-specific claims extractor. 222 + /// 223 + /// Only accepts DPoP auth (`Authorization: DPoP <token>` + `DPoP` proof + `X-Client-Key`). 224 + /// Cookie auth, Bearer API keys, and service JWTs are rejected on XRPC routes. 225 + /// Wraps `Option<Claims>` — `None` means anonymous (client-key-only) access. 226 + #[derive(Debug, Clone)] 227 + pub struct XrpcClaims(pub Option<Claims>); 228 + 229 + impl FromRequestParts<AppState> for XrpcClaims { 230 + type Rejection = AppError; 231 + 232 + async fn from_request_parts( 233 + parts: &mut Parts, 234 + state: &AppState, 235 + ) -> Result<Self, Self::Rejection> { 236 + let header = parts 237 + .headers 238 + .get("authorization") 239 + .and_then(|v| v.to_str().ok()); 240 + 241 + match header { 242 + Some(h) if h.starts_with("DPoP ") => { 243 + let token = &h[5..]; 244 + let claims = resolve_dpop_claims(state, parts, token).await?; 245 + Ok(XrpcClaims(Some(claims))) 246 + } 247 + Some(h) if h.starts_with("Bearer ") => { 248 + Err(AppError::Auth( 249 + "XRPC routes do not accept Bearer auth. Use DPoP auth or omit the Authorization header for anonymous access.".into(), 250 + )) 251 + } 252 + Some(_) => { 253 + Err(AppError::Auth("invalid Authorization scheme".into())) 254 + } 255 + None => { 256 + // No auth header — anonymous access (client-key only) 257 + Ok(XrpcClaims(None)) 258 + } 259 + } 260 + } 261 + }
+1
src/auth/mod.rs
··· 6 6 7 7 pub use client_registry::OAuthClientRegistry; 8 8 pub use middleware::Claims; 9 + pub use middleware::XrpcClaims; 9 10 pub use routes::parse_scope_string; 10 11 pub use service_auth::ServiceAuth; 11 12
+1
src/lib.rs
··· 12 12 pub mod labeler; 13 13 pub mod lexicon; 14 14 pub mod lua; 15 + pub mod oauth; 15 16 pub mod plugin; 16 17 pub mod profile; 17 18 pub mod rate_limit;
+252
src/oauth/client_auth.rs
··· 1 + use sha2::{Digest, Sha256}; 2 + 3 + use crate::db::{DatabaseBackend, adapt_sql}; 4 + use crate::error::AppError; 5 + 6 + /// Resolved API client identity for DPoP operations. 7 + pub struct ResolvedClient { 8 + pub id: String, 9 + pub client_key: String, 10 + pub client_type: String, 11 + pub scopes: String, 12 + pub allowed_origins: Option<Vec<String>>, 13 + } 14 + 15 + /// Authenticate a confidential client using client_key + client_secret. 16 + pub async fn authenticate_confidential( 17 + pool: &sqlx::AnyPool, 18 + backend: DatabaseBackend, 19 + client_key: &str, 20 + client_secret: &str, 21 + ) -> Result<ResolvedClient, AppError> { 22 + let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); 23 + 24 + let sql = adapt_sql( 25 + "SELECT id, client_key, client_type, scopes, allowed_origins, client_secret_hash FROM api_clients WHERE client_key = ? AND is_active = 1", 26 + backend, 27 + ); 28 + 29 + let row: Option<(String, String, String, String, Option<String>, String)> = 30 + sqlx::query_as(&sql) 31 + .bind(client_key) 32 + .fetch_optional(pool) 33 + .await 34 + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; 35 + 36 + let (id, key, client_type, scopes, origins_json, stored_hash) = 37 + row.ok_or_else(|| AppError::Auth("invalid client credentials".into()))?; 38 + 39 + if stored_hash != secret_hash { 40 + return Err(AppError::Auth("invalid client credentials".into())); 41 + } 42 + 43 + if client_type != "confidential" { 44 + return Err(AppError::Auth( 45 + "this endpoint requires confidential client authentication".into(), 46 + )); 47 + } 48 + 49 + let allowed_origins = 50 + origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default()); 51 + 52 + Ok(ResolvedClient { 53 + id, 54 + client_key: key, 55 + client_type, 56 + scopes, 57 + allowed_origins, 58 + }) 59 + } 60 + 61 + /// Authenticate a public client using client_key + origin validation. 62 + /// Returns the client but does NOT verify PKCE — that's done at session registration. 63 + pub async fn authenticate_public( 64 + pool: &sqlx::AnyPool, 65 + backend: DatabaseBackend, 66 + client_key: &str, 67 + origin: Option<&str>, 68 + ) -> Result<ResolvedClient, AppError> { 69 + let sql = adapt_sql( 70 + "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1", 71 + backend, 72 + ); 73 + 74 + let row: Option<(String, String, String, String, Option<String>)> = sqlx::query_as(&sql) 75 + .bind(client_key) 76 + .fetch_optional(pool) 77 + .await 78 + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; 79 + 80 + let (id, key, client_type, scopes, origins_json) = 81 + row.ok_or_else(|| AppError::Auth("unknown client".into()))?; 82 + 83 + if client_type != "public" { 84 + return Err(AppError::Auth( 85 + "this client is not registered as a public client".into(), 86 + )); 87 + } 88 + 89 + // Validate origin if the client has allowed_origins configured 90 + if let Some(ref origins_str) = origins_json { 91 + let allowed: Vec<String> = serde_json::from_str(origins_str).unwrap_or_default(); 92 + if !allowed.is_empty() { 93 + match origin { 94 + Some(o) if allowed.iter().any(|a| a == o) => {} 95 + Some(o) => { 96 + tracing::warn!(client_key, origin = o, "Origin mismatch for public client"); 97 + return Err(AppError::Auth("origin not allowed for this client".into())); 98 + } 99 + None => { 100 + tracing::warn!(client_key, "No Origin header for public client"); 101 + return Err(AppError::Auth( 102 + "Origin header required for public clients".into(), 103 + )); 104 + } 105 + } 106 + } 107 + } 108 + 109 + let allowed_origins = 110 + origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default()); 111 + 112 + Ok(ResolvedClient { 113 + id, 114 + client_key: key, 115 + client_type, 116 + scopes, 117 + allowed_origins, 118 + }) 119 + } 120 + 121 + /// Resolve an API client by client_key only (no secret verification). 122 + /// Used when the caller has already been authenticated by other means (e.g. DPoP proof). 123 + pub async fn resolve_client_by_key( 124 + pool: &sqlx::AnyPool, 125 + backend: DatabaseBackend, 126 + client_key: &str, 127 + ) -> Result<ResolvedClient, AppError> { 128 + let sql = adapt_sql( 129 + "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1", 130 + backend, 131 + ); 132 + 133 + let row: Option<(String, String, String, String, Option<String>)> = sqlx::query_as(&sql) 134 + .bind(client_key) 135 + .fetch_optional(pool) 136 + .await 137 + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; 138 + 139 + let (id, key, client_type, scopes, origins_json) = 140 + row.ok_or_else(|| AppError::Auth("unknown client".into()))?; 141 + 142 + let allowed_origins = 143 + origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default()); 144 + 145 + Ok(ResolvedClient { 146 + id, 147 + client_key: key, 148 + client_type, 149 + scopes, 150 + allowed_origins, 151 + }) 152 + } 153 + 154 + /// Validate that token scopes are allowed by the client's registered scopes. 155 + /// 156 + /// Rules: 157 + /// - `atproto` must be present in token scopes (always implicitly allowed) 158 + /// - Every non-`atproto` scope in the token must appear in the client's registered scopes 159 + pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), AppError> { 160 + let token_set: std::collections::HashSet<&str> = token_scopes.split_whitespace().collect(); 161 + let client_set: std::collections::HashSet<&str> = client_scopes.split_whitespace().collect(); 162 + 163 + if !token_set.contains("atproto") { 164 + return Err(AppError::BadRequest( 165 + "token must include the 'atproto' scope".into(), 166 + )); 167 + } 168 + 169 + for scope in &token_set { 170 + if *scope == "atproto" { 171 + continue; // always allowed 172 + } 173 + if !client_set.contains(scope) { 174 + return Err(AppError::BadRequest(format!( 175 + "scope '{}' is not allowed for this client", 176 + scope 177 + ))); 178 + } 179 + } 180 + 181 + Ok(()) 182 + } 183 + 184 + /// Verify a PKCE challenge against a verifier. 185 + pub fn verify_pkce(challenge: &str, verifier: &str) -> bool { 186 + use base64::Engine; 187 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 188 + let hash = Sha256::digest(verifier.as_bytes()); 189 + let computed = URL_SAFE_NO_PAD.encode(hash); 190 + computed == challenge 191 + } 192 + 193 + #[cfg(test)] 194 + mod tests { 195 + use super::*; 196 + 197 + #[test] 198 + fn validate_scopes_requires_atproto() { 199 + let result = validate_scopes("transition:generic", "atproto transition:generic"); 200 + assert!(result.is_err()); 201 + } 202 + 203 + #[test] 204 + fn validate_scopes_atproto_only_always_passes() { 205 + let result = validate_scopes("atproto", "com.example.whatever"); 206 + assert!(result.is_ok()); 207 + } 208 + 209 + #[test] 210 + fn validate_scopes_subset_passes() { 211 + let result = validate_scopes( 212 + "atproto com.example.basic", 213 + "atproto com.example.basic com.example.advanced", 214 + ); 215 + assert!(result.is_ok()); 216 + } 217 + 218 + #[test] 219 + fn validate_scopes_excess_scope_fails() { 220 + let result = validate_scopes( 221 + "atproto com.example.basic com.example.advanced", 222 + "atproto com.example.basic", 223 + ); 224 + assert!(result.is_err()); 225 + } 226 + 227 + #[test] 228 + fn validate_scopes_transition_generic_requires_registration() { 229 + let result = validate_scopes("atproto transition:generic", "atproto"); 230 + assert!(result.is_err()); 231 + 232 + let result = validate_scopes("atproto transition:generic", "atproto transition:generic"); 233 + assert!(result.is_ok()); 234 + } 235 + 236 + #[test] 237 + fn verify_pkce_valid() { 238 + use base64::Engine; 239 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 240 + 241 + let verifier = "test-verifier-string-12345678901234567890"; 242 + let hash = sha2::Sha256::digest(verifier.as_bytes()); 243 + let challenge = URL_SAFE_NO_PAD.encode(hash); 244 + 245 + assert!(verify_pkce(&challenge, verifier)); 246 + } 247 + 248 + #[test] 249 + fn verify_pkce_invalid() { 250 + assert!(!verify_pkce("wrong-challenge", "some-verifier")); 251 + } 252 + }
+219
src/oauth/dpop_proof.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; 4 + use serde::Deserialize; 5 + use sha2::{Digest, Sha256}; 6 + 7 + use crate::error::AppError; 8 + 9 + #[derive(Debug, Deserialize)] 10 + struct DpopHeader { 11 + alg: String, 12 + typ: String, 13 + jwk: serde_json::Value, 14 + } 15 + 16 + #[derive(Debug, Deserialize)] 17 + #[allow(dead_code)] 18 + struct DpopPayload { 19 + htm: String, 20 + htu: String, 21 + iat: u64, 22 + ath: Option<String>, 23 + jti: String, 24 + } 25 + 26 + /// Validate a DPoP proof JWT. 27 + /// 28 + /// Checks: 29 + /// - `typ` is `dpop+jwt` 30 + /// - `alg` is `ES256` 31 + /// - `htm` matches the request method 32 + /// - `htu` matches the request URL (scheme + host + path, no query/fragment) 33 + /// - `iat` is within 5 minutes of now 34 + /// - `ath` matches SHA256(access_token) if provided 35 + /// - Signature is valid against the embedded JWK 36 + /// - JWK thumbprint matches the expected thumbprint 37 + pub fn validate_dpop_proof( 38 + proof_jwt: &str, 39 + expected_method: &str, 40 + expected_url: &str, 41 + access_token: &str, 42 + expected_thumbprint: &str, 43 + ) -> Result<(), AppError> { 44 + let parts: Vec<&str> = proof_jwt.split('.').collect(); 45 + if parts.len() != 3 { 46 + return Err(AppError::Auth("invalid DPoP proof format".into())); 47 + } 48 + 49 + // Decode header 50 + let header_bytes = URL_SAFE_NO_PAD 51 + .decode(parts[0]) 52 + .map_err(|_| AppError::Auth("invalid DPoP proof header encoding".into()))?; 53 + let header: DpopHeader = serde_json::from_slice(&header_bytes) 54 + .map_err(|_| AppError::Auth("invalid DPoP proof header".into()))?; 55 + 56 + // Check typ and alg 57 + if header.typ != "dpop+jwt" { 58 + return Err(AppError::Auth("DPoP proof typ must be dpop+jwt".into())); 59 + } 60 + if header.alg != "ES256" { 61 + return Err(AppError::Auth("DPoP proof alg must be ES256".into())); 62 + } 63 + 64 + // Decode payload 65 + let payload_bytes = URL_SAFE_NO_PAD 66 + .decode(parts[1]) 67 + .map_err(|_| AppError::Auth("invalid DPoP proof payload encoding".into()))?; 68 + let payload: DpopPayload = serde_json::from_slice(&payload_bytes) 69 + .map_err(|_| AppError::Auth("invalid DPoP proof payload".into()))?; 70 + 71 + // Check htm 72 + if !payload.htm.eq_ignore_ascii_case(expected_method) { 73 + return Err(AppError::Auth("DPoP proof htm mismatch".into())); 74 + } 75 + 76 + // Check htu (strip query and fragment from expected URL for comparison) 77 + let expected_htu = strip_query_fragment(expected_url); 78 + if payload.htu != expected_htu { 79 + return Err(AppError::Auth("DPoP proof htu mismatch".into())); 80 + } 81 + 82 + // Check iat (within 5 minutes) 83 + let now = std::time::SystemTime::now() 84 + .duration_since(std::time::UNIX_EPOCH) 85 + .unwrap() 86 + .as_secs(); 87 + if now.abs_diff(payload.iat) > 300 { 88 + return Err(AppError::Auth( 89 + "DPoP proof expired or too far in the future".into(), 90 + )); 91 + } 92 + 93 + // Check ath (access token hash) — required per RFC 9449 section 4.2 94 + let expected_ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())); 95 + let ath = payload 96 + .ath 97 + .as_ref() 98 + .ok_or_else(|| AppError::Auth("DPoP proof missing required ath claim".into()))?; 99 + if *ath != expected_ath { 100 + return Err(AppError::Auth("DPoP proof ath mismatch".into())); 101 + } 102 + 103 + // Verify JWK thumbprint matches expected 104 + let proof_thumbprint = super::keys::compute_jwk_thumbprint(&header.jwk)?; 105 + if proof_thumbprint != expected_thumbprint { 106 + return Err(AppError::Auth( 107 + "DPoP proof key does not match session".into(), 108 + )); 109 + } 110 + 111 + // Verify signature 112 + let message = format!("{}.{}", parts[0], parts[1]); 113 + let sig_bytes = URL_SAFE_NO_PAD 114 + .decode(parts[2]) 115 + .map_err(|_| AppError::Auth("invalid DPoP proof signature encoding".into()))?; 116 + 117 + verify_es256_jwk(&message, &sig_bytes, &header.jwk)?; 118 + 119 + Ok(()) 120 + } 121 + 122 + /// Verify an ES256 signature using a JWK public key. 123 + fn verify_es256_jwk( 124 + message: &str, 125 + sig_bytes: &[u8], 126 + jwk: &serde_json::Value, 127 + ) -> Result<(), AppError> { 128 + let x_b64 = jwk["x"] 129 + .as_str() 130 + .ok_or_else(|| AppError::Auth("DPoP JWK missing x".into()))?; 131 + let y_b64 = jwk["y"] 132 + .as_str() 133 + .ok_or_else(|| AppError::Auth("DPoP JWK missing y".into()))?; 134 + 135 + let x_bytes = URL_SAFE_NO_PAD 136 + .decode(x_b64) 137 + .map_err(|_| AppError::Auth("invalid DPoP JWK x".into()))?; 138 + let y_bytes = URL_SAFE_NO_PAD 139 + .decode(y_b64) 140 + .map_err(|_| AppError::Auth("invalid DPoP JWK y".into()))?; 141 + 142 + // Build SEC1 uncompressed point: 0x04 || x || y 143 + let mut sec1 = Vec::with_capacity(1 + 32 + 32); 144 + sec1.push(0x04); 145 + sec1.extend_from_slice(&x_bytes); 146 + sec1.extend_from_slice(&y_bytes); 147 + 148 + let verifying_key = VerifyingKey::from_sec1_bytes(&sec1) 149 + .map_err(|_| AppError::Auth("invalid DPoP public key".into()))?; 150 + 151 + let signature = Signature::from_bytes(sig_bytes.into()) 152 + .map_err(|_| AppError::Auth("invalid DPoP signature format".into()))?; 153 + 154 + verifying_key 155 + .verify(message.as_bytes(), &signature) 156 + .map_err(|_| AppError::Auth("DPoP proof signature verification failed".into()))?; 157 + 158 + Ok(()) 159 + } 160 + 161 + /// Strip query string and fragment from a URL (per RFC 9449 section 4.2). 162 + fn strip_query_fragment(url: &str) -> &str { 163 + let end = url 164 + .find('#') 165 + .unwrap_or(url.len()) 166 + .min(url.find('?').unwrap_or(url.len())); 167 + &url[..end] 168 + } 169 + 170 + #[cfg(test)] 171 + mod tests { 172 + use super::*; 173 + 174 + #[test] 175 + fn strip_query_fragment_works() { 176 + assert_eq!( 177 + strip_query_fragment("https://example.com/path"), 178 + "https://example.com/path" 179 + ); 180 + assert_eq!( 181 + strip_query_fragment("https://example.com/path?query=1"), 182 + "https://example.com/path" 183 + ); 184 + assert_eq!( 185 + strip_query_fragment("https://example.com/path#frag"), 186 + "https://example.com/path" 187 + ); 188 + assert_eq!( 189 + strip_query_fragment("https://example.com/path?q=1#f"), 190 + "https://example.com/path" 191 + ); 192 + } 193 + 194 + #[test] 195 + fn rejects_invalid_format() { 196 + let result = validate_dpop_proof( 197 + "not.a.valid.jwt.too-many", 198 + "GET", 199 + "https://example.com", 200 + "token", 201 + "thumb", 202 + ); 203 + assert!(result.is_err()); 204 + } 205 + 206 + #[test] 207 + fn rejects_non_dpop_typ() { 208 + // Build a JWT with typ: "JWT" instead of "dpop+jwt" 209 + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256","typ":"JWT","jwk":{}}"#); 210 + let payload = URL_SAFE_NO_PAD 211 + .encode(r#"{"htm":"GET","htu":"https://example.com","iat":0,"jti":"x"}"#); 212 + let fake_sig = URL_SAFE_NO_PAD.encode(b"fakesig"); 213 + let jwt = format!("{}.{}.{}", header, payload, fake_sig); 214 + 215 + let result = validate_dpop_proof(&jwt, "GET", "https://example.com", "token", "thumb"); 216 + assert!(result.is_err()); 217 + assert!(result.unwrap_err().to_string().contains("dpop+jwt")); 218 + } 219 + }
+260
src/oauth/keys.rs
··· 1 + use p256::ecdsa::SigningKey; 2 + use rand::RngCore; 3 + use serde::Serialize; 4 + use sha2::{Digest, Sha256}; 5 + 6 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 7 + use crate::error::AppError; 8 + use crate::plugin::encryption::{decrypt, encrypt}; 9 + 10 + /// A generated ES256 DPoP keypair with its JWK representation. 11 + #[derive(Debug, Clone, Serialize)] 12 + pub struct DpopKeypair { 13 + /// The private key as a JWK (returned to the app, also stored encrypted). 14 + pub private_jwk: serde_json::Value, 15 + /// The public key as a JWK. 16 + pub public_jwk: serde_json::Value, 17 + /// The JWK thumbprint (RFC 7638) using SHA-256. 18 + pub thumbprint: String, 19 + } 20 + 21 + /// Generate a new ES256 (P-256) DPoP keypair. 22 + pub fn generate_dpop_keypair() -> Result<DpopKeypair, AppError> { 23 + let mut rng_bytes = [0u8; 32]; 24 + rand::rng().fill_bytes(&mut rng_bytes); 25 + 26 + let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) 27 + .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?; 28 + 29 + let verifying_key = signing_key.verifying_key(); 30 + let public_point = verifying_key.to_encoded_point(false); 31 + 32 + let x_bytes = public_point 33 + .x() 34 + .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?; 35 + let y_bytes = public_point 36 + .y() 37 + .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?; 38 + 39 + use base64::Engine; 40 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 41 + 42 + let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes); 43 + let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes); 44 + let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes); 45 + 46 + let public_jwk = serde_json::json!({ 47 + "kty": "EC", 48 + "crv": "P-256", 49 + "x": x_b64, 50 + "y": y_b64, 51 + }); 52 + 53 + let private_jwk = serde_json::json!({ 54 + "kty": "EC", 55 + "crv": "P-256", 56 + "x": x_b64, 57 + "y": y_b64, 58 + "d": d_b64, 59 + }); 60 + 61 + let thumbprint = compute_jwk_thumbprint(&public_jwk)?; 62 + 63 + Ok(DpopKeypair { 64 + private_jwk, 65 + public_jwk, 66 + thumbprint, 67 + }) 68 + } 69 + 70 + /// Compute the JWK Thumbprint (RFC 7638) using SHA-256. 71 + /// 72 + /// For EC keys, the canonical JSON is: {"crv":"...","kty":"EC","x":"...","y":"..."} 73 + /// (alphabetical order of required members). 74 + pub fn compute_jwk_thumbprint(jwk: &serde_json::Value) -> Result<String, AppError> { 75 + let kty = jwk["kty"] 76 + .as_str() 77 + .ok_or_else(|| AppError::Internal("JWK missing kty".into()))?; 78 + let crv = jwk["crv"] 79 + .as_str() 80 + .ok_or_else(|| AppError::Internal("JWK missing crv".into()))?; 81 + let x = jwk["x"] 82 + .as_str() 83 + .ok_or_else(|| AppError::Internal("JWK missing x".into()))?; 84 + let y = jwk["y"] 85 + .as_str() 86 + .ok_or_else(|| AppError::Internal("JWK missing y".into()))?; 87 + 88 + // RFC 7638: lexicographic order of required members 89 + let canonical = format!( 90 + r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#, 91 + crv, kty, x, y 92 + ); 93 + 94 + let hash = Sha256::digest(canonical.as_bytes()); 95 + use base64::Engine; 96 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 97 + Ok(URL_SAFE_NO_PAD.encode(hash)) 98 + } 99 + 100 + /// Store a DPoP key in the database with the private key encrypted. 101 + #[allow(clippy::too_many_arguments)] 102 + pub async fn store_dpop_key( 103 + pool: &sqlx::AnyPool, 104 + backend: DatabaseBackend, 105 + encryption_key: &[u8; 32], 106 + id: &str, 107 + provision_id: &str, 108 + api_client_id: &str, 109 + keypair: &DpopKeypair, 110 + pkce_challenge: Option<&str>, 111 + ) -> Result<(), AppError> { 112 + let private_jwk_bytes = serde_json::to_vec(&keypair.private_jwk) 113 + .map_err(|e| AppError::Internal(format!("failed to serialize JWK: {e}")))?; 114 + 115 + let encrypted = encrypt(encryption_key, &private_jwk_bytes) 116 + .map_err(|e| AppError::Internal(format!("failed to encrypt DPoP key: {e}")))?; 117 + 118 + let now = now_rfc3339(); 119 + let sql = adapt_sql( 120 + "INSERT INTO dpop_keys (id, provision_id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 121 + backend, 122 + ); 123 + 124 + sqlx::query(&sql) 125 + .bind(id) 126 + .bind(provision_id) 127 + .bind(api_client_id) 128 + .bind(&encrypted) 129 + .bind(&keypair.thumbprint) 130 + .bind(pkce_challenge) 131 + .bind(&now) 132 + .execute(pool) 133 + .await 134 + .map_err(|e| AppError::Internal(format!("failed to store DPoP key: {e}")))?; 135 + 136 + Ok(()) 137 + } 138 + 139 + /// Retrieve and decrypt a DPoP key by provision_id. 140 + pub async fn get_dpop_key( 141 + pool: &sqlx::AnyPool, 142 + backend: DatabaseBackend, 143 + encryption_key: &[u8; 32], 144 + provision_id: &str, 145 + ) -> Result<(String, String, serde_json::Value, String, Option<String>), AppError> { 146 + // Returns: (id, api_client_id, private_jwk, thumbprint, pkce_challenge) 147 + let sql = adapt_sql( 148 + "SELECT id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge FROM dpop_keys WHERE provision_id = ?", 149 + backend, 150 + ); 151 + 152 + #[allow(clippy::type_complexity)] 153 + let row: Option<(String, String, Vec<u8>, String, Option<String>)> = sqlx::query_as(&sql) 154 + .bind(provision_id) 155 + .fetch_optional(pool) 156 + .await 157 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; 158 + 159 + let (id, api_client_id, encrypted, thumbprint, pkce_challenge) = 160 + row.ok_or_else(|| AppError::NotFound("DPoP key not found".into()))?; 161 + 162 + let decrypted = decrypt(encryption_key, &encrypted) 163 + .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; 164 + 165 + let private_jwk: serde_json::Value = serde_json::from_slice(&decrypted) 166 + .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; 167 + 168 + Ok((id, api_client_id, private_jwk, thumbprint, pkce_challenge)) 169 + } 170 + 171 + /// Retrieve the JWK thumbprint for a DPoP key by its database ID. 172 + pub async fn get_dpop_key_thumbprint( 173 + pool: &sqlx::AnyPool, 174 + backend: DatabaseBackend, 175 + key_id: &str, 176 + ) -> Result<String, AppError> { 177 + let sql = adapt_sql("SELECT jwk_thumbprint FROM dpop_keys WHERE id = ?", backend); 178 + 179 + let row: Option<(String,)> = sqlx::query_as(&sql) 180 + .bind(key_id) 181 + .fetch_optional(pool) 182 + .await 183 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; 184 + 185 + row.map(|(t,)| t) 186 + .ok_or_else(|| AppError::NotFound("DPoP key not found".into())) 187 + } 188 + 189 + /// Delete a DPoP key and its associated session. 190 + pub async fn delete_dpop_key( 191 + pool: &sqlx::AnyPool, 192 + backend: DatabaseBackend, 193 + dpop_key_id: &str, 194 + ) -> Result<(), AppError> { 195 + // Session is deleted by CASCADE, but be explicit for clarity 196 + let session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE dpop_key_id = ?", backend); 197 + let _ = sqlx::query(&session_sql) 198 + .bind(dpop_key_id) 199 + .execute(pool) 200 + .await; 201 + 202 + let key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); 203 + sqlx::query(&key_sql) 204 + .bind(dpop_key_id) 205 + .execute(pool) 206 + .await 207 + .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?; 208 + 209 + Ok(()) 210 + } 211 + 212 + #[cfg(test)] 213 + mod tests { 214 + use super::*; 215 + 216 + #[test] 217 + fn generate_keypair_produces_valid_jwk() { 218 + let keypair = generate_dpop_keypair().unwrap(); 219 + 220 + // Private JWK has d parameter 221 + assert!(keypair.private_jwk["d"].is_string()); 222 + assert_eq!(keypair.private_jwk["kty"], "EC"); 223 + assert_eq!(keypair.private_jwk["crv"], "P-256"); 224 + 225 + // Public JWK has no d parameter 226 + assert!(keypair.public_jwk["d"].is_null()); 227 + assert_eq!(keypair.public_jwk["kty"], "EC"); 228 + assert_eq!(keypair.public_jwk["crv"], "P-256"); 229 + 230 + // Thumbprint is a base64url string 231 + assert!(!keypair.thumbprint.is_empty()); 232 + assert!(!keypair.thumbprint.contains('=')); 233 + } 234 + 235 + #[test] 236 + fn generate_keypair_produces_unique_keys() { 237 + let kp1 = generate_dpop_keypair().unwrap(); 238 + let kp2 = generate_dpop_keypair().unwrap(); 239 + assert_ne!(kp1.private_jwk["d"], kp2.private_jwk["d"]); 240 + assert_ne!(kp1.thumbprint, kp2.thumbprint); 241 + } 242 + 243 + #[test] 244 + fn thumbprint_is_deterministic() { 245 + let keypair = generate_dpop_keypair().unwrap(); 246 + let t1 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap(); 247 + let t2 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap(); 248 + assert_eq!(t1, t2); 249 + assert_eq!(t1, keypair.thumbprint); 250 + } 251 + 252 + #[test] 253 + fn thumbprint_differs_for_different_keys() { 254 + let kp1 = generate_dpop_keypair().unwrap(); 255 + let kp2 = generate_dpop_keypair().unwrap(); 256 + let t1 = compute_jwk_thumbprint(&kp1.public_jwk).unwrap(); 257 + let t2 = compute_jwk_thumbprint(&kp2.public_jwk).unwrap(); 258 + assert_ne!(t1, t2); 259 + } 260 + }
+6
src/oauth/mod.rs
··· 1 + pub mod client_auth; 2 + pub mod dpop_proof; 3 + pub mod keys; 4 + pub mod pds_write; 5 + pub mod routes; 6 + pub mod sessions;
+289
src/oauth/pds_write.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use p256::ecdsa::{SigningKey, signature::Signer}; 4 + use sha2::{Digest, Sha256}; 5 + 6 + use crate::db::DatabaseBackend; 7 + use crate::error::AppError; 8 + use crate::plugin::encryption::decrypt; 9 + 10 + /// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session. 11 + #[allow(clippy::too_many_arguments)] 12 + pub async fn dpop_pds_post( 13 + http: &reqwest::Client, 14 + pool: &sqlx::AnyPool, 15 + backend: DatabaseBackend, 16 + encryption_key: &[u8; 32], 17 + plc_url: &str, 18 + api_client_id: &str, 19 + user_did: &str, 20 + xrpc_method: &str, 21 + body: &serde_json::Value, 22 + ) -> Result<reqwest::Response, AppError> { 23 + let session = 24 + super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) 25 + .await?; 26 + 27 + let pds_url = match session.pds_url { 28 + Some(ref url) => url.clone(), 29 + None => resolve_pds_from_did(http, plc_url, user_did).await?, 30 + }; 31 + 32 + let target_url = format!("{}/xrpc/{}", pds_url.trim_end_matches('/'), xrpc_method); 33 + 34 + // Decrypt the DPoP private key 35 + let key_sql = crate::db::adapt_sql( 36 + "SELECT private_key_enc FROM dpop_keys WHERE id = ?", 37 + backend, 38 + ); 39 + let row: Option<(Vec<u8>,)> = sqlx::query_as(&key_sql) 40 + .bind(&session.dpop_key_id) 41 + .fetch_optional(pool) 42 + .await 43 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; 44 + 45 + let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?; 46 + 47 + let key_bytes = decrypt(encryption_key, &encrypted_key) 48 + .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; 49 + 50 + let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes) 51 + .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; 52 + 53 + let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?; 54 + 55 + let resp = http 56 + .post(&target_url) 57 + .header("Authorization", format!("DPoP {}", session.access_token)) 58 + .header("DPoP", proof) 59 + .header("Content-Type", "application/json") 60 + .json(body) 61 + .send() 62 + .await 63 + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; 64 + 65 + Ok(resp) 66 + } 67 + 68 + /// Make an authenticated blob upload to a PDS using a DPoP session. 69 + #[allow(clippy::too_many_arguments)] 70 + pub async fn dpop_pds_post_blob( 71 + http: &reqwest::Client, 72 + pool: &sqlx::AnyPool, 73 + backend: DatabaseBackend, 74 + encryption_key: &[u8; 32], 75 + plc_url: &str, 76 + api_client_id: &str, 77 + user_did: &str, 78 + content_type: &str, 79 + blob: bytes::Bytes, 80 + ) -> Result<reqwest::Response, AppError> { 81 + let session = 82 + super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) 83 + .await?; 84 + 85 + let pds_url = match session.pds_url { 86 + Some(ref url) => url.clone(), 87 + None => resolve_pds_from_did(http, plc_url, user_did).await?, 88 + }; 89 + 90 + let target_url = format!( 91 + "{}/xrpc/com.atproto.repo.uploadBlob", 92 + pds_url.trim_end_matches('/') 93 + ); 94 + 95 + // Decrypt the DPoP private key 96 + let key_sql = crate::db::adapt_sql( 97 + "SELECT private_key_enc FROM dpop_keys WHERE id = ?", 98 + backend, 99 + ); 100 + let row: Option<(Vec<u8>,)> = sqlx::query_as(&key_sql) 101 + .bind(&session.dpop_key_id) 102 + .fetch_optional(pool) 103 + .await 104 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; 105 + 106 + let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?; 107 + 108 + let key_bytes = decrypt(encryption_key, &encrypted_key) 109 + .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; 110 + 111 + let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes) 112 + .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; 113 + 114 + let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?; 115 + 116 + let resp = http 117 + .post(&target_url) 118 + .header("Authorization", format!("DPoP {}", session.access_token)) 119 + .header("DPoP", proof) 120 + .header("Content-Type", content_type) 121 + .body(blob) 122 + .send() 123 + .await 124 + .map_err(|e| AppError::Internal(format!("PDS uploadBlob request failed: {e}")))?; 125 + 126 + Ok(resp) 127 + } 128 + 129 + /// Generate a DPoP proof JWT for a PDS request. 130 + pub fn generate_dpop_proof( 131 + private_jwk: &serde_json::Value, 132 + method: &str, 133 + url: &str, 134 + access_token: &str, 135 + ) -> Result<String, AppError> { 136 + let d_b64 = private_jwk["d"] 137 + .as_str() 138 + .ok_or_else(|| AppError::Internal("DPoP key missing d parameter".into()))?; 139 + let x_b64 = private_jwk["x"] 140 + .as_str() 141 + .ok_or_else(|| AppError::Internal("DPoP key missing x parameter".into()))?; 142 + let y_b64 = private_jwk["y"] 143 + .as_str() 144 + .ok_or_else(|| AppError::Internal("DPoP key missing y parameter".into()))?; 145 + 146 + let d_bytes = URL_SAFE_NO_PAD 147 + .decode(d_b64) 148 + .map_err(|_| AppError::Internal("invalid DPoP key d parameter".into()))?; 149 + 150 + let signing_key = SigningKey::from_bytes((&d_bytes[..]).into()) 151 + .map_err(|e| AppError::Internal(format!("invalid DPoP signing key: {e}")))?; 152 + 153 + let public_jwk = serde_json::json!({ 154 + "kty": "EC", 155 + "crv": "P-256", 156 + "x": x_b64, 157 + "y": y_b64, 158 + }); 159 + 160 + let now = std::time::SystemTime::now() 161 + .duration_since(std::time::UNIX_EPOCH) 162 + .unwrap() 163 + .as_secs(); 164 + 165 + let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())); 166 + 167 + let header = serde_json::json!({ 168 + "alg": "ES256", 169 + "typ": "dpop+jwt", 170 + "jwk": public_jwk, 171 + }); 172 + 173 + let payload = serde_json::json!({ 174 + "htm": method, 175 + "htu": url, 176 + "iat": now, 177 + "ath": ath, 178 + "jti": format!("{:x}", rand::random::<u64>()), 179 + }); 180 + 181 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); 182 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); 183 + 184 + let message = format!("{}.{}", header_b64, payload_b64); 185 + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); 186 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 187 + 188 + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) 189 + } 190 + 191 + /// Resolve a user's PDS URL from their DID document. 192 + async fn resolve_pds_from_did( 193 + http: &reqwest::Client, 194 + plc_url: &str, 195 + did: &str, 196 + ) -> Result<String, AppError> { 197 + let url = if did.starts_with("did:plc:") { 198 + format!("{}/{}", plc_url.trim_end_matches('/'), did) 199 + } else if did.starts_with("did:web:") { 200 + let host = did.strip_prefix("did:web:").unwrap(); 201 + format!("https://{}/.well-known/did.json", host) 202 + } else { 203 + return Err(AppError::Internal(format!("unsupported DID method: {did}"))); 204 + }; 205 + 206 + let resp = http 207 + .get(&url) 208 + .send() 209 + .await 210 + .map_err(|e| AppError::Internal(format!("failed to resolve DID: {e}")))?; 211 + 212 + let doc: serde_json::Value = resp 213 + .json() 214 + .await 215 + .map_err(|e| AppError::Internal(format!("failed to parse DID document: {e}")))?; 216 + 217 + let services = doc["service"] 218 + .as_array() 219 + .ok_or_else(|| AppError::Internal("DID document missing service array".into()))?; 220 + 221 + for service in services { 222 + let id = service["id"].as_str().unwrap_or(""); 223 + if (id == "#atproto_pds" || id.ends_with("#atproto_pds")) 224 + && let Some(endpoint) = service["serviceEndpoint"].as_str() 225 + { 226 + return Ok(endpoint.to_string()); 227 + } 228 + } 229 + 230 + Err(AppError::Internal(format!( 231 + "no #atproto_pds service found in DID document for {did}" 232 + ))) 233 + } 234 + 235 + #[cfg(test)] 236 + mod tests { 237 + use super::*; 238 + 239 + #[test] 240 + fn generate_dpop_proof_produces_valid_jwt() { 241 + let keypair = super::super::keys::generate_dpop_keypair().unwrap(); 242 + 243 + let proof = generate_dpop_proof( 244 + &keypair.private_jwk, 245 + "POST", 246 + "https://pds.example.com/xrpc/com.atproto.repo.createRecord", 247 + "test-access-token", 248 + ) 249 + .unwrap(); 250 + 251 + let parts: Vec<&str> = proof.split('.').collect(); 252 + assert_eq!(parts.len(), 3); 253 + 254 + let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap(); 255 + let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); 256 + assert_eq!(header["alg"], "ES256"); 257 + assert_eq!(header["typ"], "dpop+jwt"); 258 + assert_eq!(header["jwk"]["kty"], "EC"); 259 + 260 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 261 + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); 262 + assert_eq!(payload["htm"], "POST"); 263 + assert_eq!( 264 + payload["htu"], 265 + "https://pds.example.com/xrpc/com.atproto.repo.createRecord" 266 + ); 267 + assert!(payload["iat"].is_number()); 268 + assert!(payload["ath"].is_string()); 269 + assert!(payload["jti"].is_string()); 270 + } 271 + 272 + #[test] 273 + fn generated_proof_validates_against_own_key() { 274 + let keypair = super::super::keys::generate_dpop_keypair().unwrap(); 275 + let url = "https://pds.example.com/xrpc/test.method"; 276 + let token = "my-access-token"; 277 + 278 + let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token).unwrap(); 279 + 280 + let result = super::super::dpop_proof::validate_dpop_proof( 281 + &proof, 282 + "POST", 283 + url, 284 + token, 285 + &keypair.thumbprint, 286 + ); 287 + assert!(result.is_ok(), "validation failed: {:?}", result.err()); 288 + } 289 + }
+404
src/oauth/routes.rs
··· 1 + use axum::extract::{FromRequest, Path, State}; 2 + use axum::http::StatusCode; 3 + use axum::routing::{delete, post}; 4 + use axum::{Json, Router}; 5 + use serde::{Deserialize, Serialize}; 6 + use uuid::Uuid; 7 + 8 + use crate::AppState; 9 + use crate::error::AppError; 10 + use crate::event_log::{EventLog, Severity, log_event}; 11 + 12 + use super::client_auth; 13 + use super::keys; 14 + use super::sessions; 15 + 16 + pub fn routes() -> Router<AppState> { 17 + Router::new() 18 + .route("/dpop-keys", post(provision_dpop_key)) 19 + .route("/sessions", post(register_session)) 20 + .route("/sessions/{did}", delete(delete_session)) 21 + } 22 + 23 + // --- Request / response types --- 24 + 25 + #[derive(Deserialize)] 26 + struct ProvisionKeyBody { 27 + pkce_challenge: Option<String>, 28 + } 29 + 30 + #[derive(Serialize)] 31 + struct ProvisionKeyResponse { 32 + provision_id: String, 33 + dpop_key: serde_json::Value, 34 + } 35 + 36 + #[derive(Deserialize)] 37 + struct RegisterSessionBody { 38 + provision_id: String, 39 + pkce_verifier: Option<String>, 40 + did: String, 41 + access_token: String, 42 + refresh_token: Option<String>, 43 + expires_at: Option<String>, 44 + scopes: String, 45 + pds_url: Option<String>, 46 + issuer: Option<String>, 47 + } 48 + 49 + #[derive(Serialize)] 50 + struct RegisterSessionResponse { 51 + session_id: String, 52 + did: String, 53 + } 54 + 55 + // --- Handlers --- 56 + 57 + /// POST /oauth/dpop-keys — provision a new DPoP keypair. 58 + /// 59 + /// Client credentials come from `X-Client-Key` and `X-Client-Secret` headers. 60 + async fn provision_dpop_key( 61 + State(state): State<AppState>, 62 + req: axum::extract::Request, 63 + ) -> Result<(StatusCode, Json<ProvisionKeyResponse>), AppError> { 64 + let client_key = req 65 + .headers() 66 + .get("x-client-key") 67 + .and_then(|v| v.to_str().ok()) 68 + .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))? 69 + .to_string(); 70 + 71 + let client_secret = req 72 + .headers() 73 + .get("x-client-secret") 74 + .and_then(|v| v.to_str().ok()) 75 + .map(|s| s.to_string()); 76 + 77 + let origin = req 78 + .headers() 79 + .get("origin") 80 + .and_then(|v| v.to_str().ok()) 81 + .map(|s| s.to_string()); 82 + 83 + let body: ProvisionKeyBody = Json::<ProvisionKeyBody>::from_request(req, &state) 84 + .await 85 + .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? 86 + .0; 87 + 88 + let encryption_key = state 89 + .config 90 + .token_encryption_key 91 + .as_ref() 92 + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; 93 + 94 + // Authenticate the client 95 + let client = if let Some(ref secret) = client_secret { 96 + client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) 97 + .await? 98 + } else { 99 + // Public client — must provide PKCE challenge 100 + if body.pkce_challenge.is_none() { 101 + return Err(AppError::BadRequest( 102 + "public clients must provide pkce_challenge".into(), 103 + )); 104 + } 105 + client_auth::authenticate_public( 106 + &state.db, 107 + state.db_backend, 108 + &client_key, 109 + origin.as_deref(), 110 + ) 111 + .await? 112 + }; 113 + 114 + // Generate keypair 115 + let keypair = keys::generate_dpop_keypair()?; 116 + let id = Uuid::new_v4().to_string(); 117 + let provision_id = format!("hvp_{}", hex::encode(rand::random::<[u8; 16]>())); 118 + 119 + // Store encrypted key 120 + keys::store_dpop_key( 121 + &state.db, 122 + state.db_backend, 123 + encryption_key, 124 + &id, 125 + &provision_id, 126 + &client.id, 127 + &keypair, 128 + body.pkce_challenge.as_deref(), 129 + ) 130 + .await?; 131 + 132 + log_event( 133 + &state.db, 134 + EventLog { 135 + event_type: "dpop_key.provisioned".to_string(), 136 + severity: Severity::Info, 137 + actor_did: None, 138 + subject: Some(provision_id.clone()), 139 + detail: serde_json::json!({ 140 + "client_key": client.client_key, 141 + "thumbprint": keypair.thumbprint, 142 + }), 143 + }, 144 + state.db_backend, 145 + ) 146 + .await; 147 + 148 + Ok(( 149 + StatusCode::CREATED, 150 + Json(ProvisionKeyResponse { 151 + provision_id, 152 + dpop_key: keypair.private_jwk, 153 + }), 154 + )) 155 + } 156 + 157 + /// POST /oauth/sessions — register a token set after OAuth callback. 158 + /// 159 + /// Client credentials come from `X-Client-Key` and `X-Client-Secret` headers. 160 + async fn register_session( 161 + State(state): State<AppState>, 162 + req: axum::extract::Request, 163 + ) -> Result<(StatusCode, Json<RegisterSessionResponse>), AppError> { 164 + let client_key = req 165 + .headers() 166 + .get("x-client-key") 167 + .and_then(|v| v.to_str().ok()) 168 + .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))? 169 + .to_string(); 170 + 171 + let client_secret = req 172 + .headers() 173 + .get("x-client-secret") 174 + .and_then(|v| v.to_str().ok()) 175 + .map(|s| s.to_string()); 176 + 177 + let body: RegisterSessionBody = Json::<RegisterSessionBody>::from_request(req, &state) 178 + .await 179 + .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? 180 + .0; 181 + 182 + let encryption_key = state 183 + .config 184 + .token_encryption_key 185 + .as_ref() 186 + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; 187 + 188 + // Look up the DPoP key by provision_id 189 + let (dpop_key_id, dpop_client_id, _private_jwk, _thumbprint, pkce_challenge) = 190 + keys::get_dpop_key( 191 + &state.db, 192 + state.db_backend, 193 + encryption_key, 194 + &body.provision_id, 195 + ) 196 + .await?; 197 + 198 + // Authenticate the client and verify it matches the key's client 199 + let client = if let Some(ref secret) = client_secret { 200 + client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) 201 + .await? 202 + } else { 203 + // Public client — verify PKCE 204 + let verifier = body.pkce_verifier.as_deref().ok_or_else(|| { 205 + AppError::BadRequest("public clients must provide pkce_verifier".into()) 206 + })?; 207 + 208 + let challenge = pkce_challenge.as_deref().ok_or_else(|| { 209 + AppError::BadRequest("no PKCE challenge found for this provision".into()) 210 + })?; 211 + 212 + if !client_auth::verify_pkce(challenge, verifier) { 213 + return Err(AppError::Auth("PKCE verification failed".into())); 214 + } 215 + 216 + client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await? 217 + }; 218 + 219 + // Verify client_key matches the key's owning client 220 + if client.id != dpop_client_id { 221 + return Err(AppError::Auth( 222 + "provision_id does not belong to this client".into(), 223 + )); 224 + } 225 + 226 + // Validate scopes 227 + client_auth::validate_scopes(&body.scopes, &client.scopes)?; 228 + 229 + // Clean up any existing session's DPoP key before upserting 230 + // (the ON CONFLICT upsert would orphan the old key otherwise) 231 + { 232 + let lookup_sql = crate::db::adapt_sql( 233 + "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", 234 + state.db_backend, 235 + ); 236 + if let Ok(Some((old_key_id,))) = sqlx::query_as::<_, (String,)>(&lookup_sql) 237 + .bind(&client.id) 238 + .bind(&body.did) 239 + .fetch_optional(&state.db) 240 + .await 241 + && old_key_id != dpop_key_id 242 + { 243 + let del_sql = 244 + crate::db::adapt_sql("DELETE FROM dpop_keys WHERE id = ?", state.db_backend); 245 + let _ = sqlx::query(&del_sql) 246 + .bind(&old_key_id) 247 + .execute(&state.db) 248 + .await; 249 + } 250 + } 251 + 252 + // Store the session 253 + let session_id = Uuid::new_v4().to_string(); 254 + sessions::store_dpop_session( 255 + &state.db, 256 + state.db_backend, 257 + encryption_key, 258 + &session_id, 259 + &client.id, 260 + &dpop_key_id, 261 + &body.did, 262 + &body.access_token, 263 + body.refresh_token.as_deref(), 264 + body.expires_at.as_deref(), 265 + &body.scopes, 266 + body.pds_url.as_deref(), 267 + body.issuer.as_deref(), 268 + ) 269 + .await?; 270 + 271 + log_event( 272 + &state.db, 273 + EventLog { 274 + event_type: "dpop_session.created".to_string(), 275 + severity: Severity::Info, 276 + actor_did: Some(body.did.clone()), 277 + subject: Some(client.client_key.clone()), 278 + detail: serde_json::json!({ 279 + "scopes": body.scopes, 280 + }), 281 + }, 282 + state.db_backend, 283 + ) 284 + .await; 285 + 286 + Ok(( 287 + StatusCode::CREATED, 288 + Json(RegisterSessionResponse { 289 + session_id, 290 + did: body.did, 291 + }), 292 + )) 293 + } 294 + 295 + /// DELETE /oauth/sessions/:did — logout / revoke a session. 296 + /// 297 + /// Confidential clients authenticate with `X-Client-Key` + `X-Client-Secret`. 298 + /// Public clients authenticate with `X-Client-Key` + `Authorization: DPoP <token>` + `DPoP` proof. 299 + async fn delete_session( 300 + State(state): State<AppState>, 301 + Path(did): Path<String>, 302 + req: axum::extract::Request, 303 + ) -> Result<StatusCode, AppError> { 304 + let client_key = req 305 + .headers() 306 + .get("x-client-key") 307 + .and_then(|v| v.to_str().ok()) 308 + .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))? 309 + .to_string(); 310 + 311 + let client_secret = req 312 + .headers() 313 + .get("x-client-secret") 314 + .and_then(|v| v.to_str().ok()) 315 + .map(|s| s.to_string()); 316 + 317 + let client = if let Some(ref secret) = client_secret { 318 + client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) 319 + .await? 320 + } else { 321 + let resolved = 322 + client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await?; 323 + 324 + // Public clients must prove they hold the DPoP key + token 325 + if resolved.client_type == "public" { 326 + let auth_header = req 327 + .headers() 328 + .get("authorization") 329 + .and_then(|v| v.to_str().ok()) 330 + .ok_or_else(|| { 331 + AppError::Auth("public clients must provide Authorization: DPoP <token>".into()) 332 + })?; 333 + let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { 334 + AppError::Auth("public clients must use DPoP authorization scheme".into()) 335 + })?; 336 + let dpop_proof = req 337 + .headers() 338 + .get("dpop") 339 + .and_then(|v| v.to_str().ok()) 340 + .ok_or_else(|| { 341 + AppError::Auth("public clients must provide DPoP proof header".into()) 342 + })?; 343 + 344 + let encryption_key = 345 + state.config.token_encryption_key.as_ref().ok_or_else(|| { 346 + AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()) 347 + })?; 348 + 349 + // Look up the session to get the DPoP key thumbprint 350 + let session = sessions::get_dpop_session_by_token_hash( 351 + &state.db, 352 + state.db_backend, 353 + encryption_key, 354 + &resolved.id, 355 + access_token, 356 + ) 357 + .await?; 358 + 359 + let thumbprint = 360 + keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id) 361 + .await?; 362 + 363 + // Build request URL for htu validation 364 + let scheme = if state.config.public_url.starts_with("https") { 365 + "https" 366 + } else { 367 + "http" 368 + }; 369 + let host = req 370 + .headers() 371 + .get("host") 372 + .and_then(|v| v.to_str().ok()) 373 + .unwrap_or("localhost"); 374 + let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did); 375 + 376 + crate::oauth::dpop_proof::validate_dpop_proof( 377 + dpop_proof, 378 + "DELETE", 379 + &request_url, 380 + access_token, 381 + &thumbprint, 382 + )?; 383 + } 384 + 385 + resolved 386 + }; 387 + 388 + sessions::delete_dpop_session(&state.db, state.db_backend, &client.id, &did).await?; 389 + 390 + log_event( 391 + &state.db, 392 + EventLog { 393 + event_type: "dpop_session.deleted".to_string(), 394 + severity: Severity::Info, 395 + actor_did: Some(did), 396 + subject: Some(client.client_key), 397 + detail: serde_json::json!({}), 398 + }, 399 + state.db_backend, 400 + ) 401 + .await; 402 + 403 + Ok(StatusCode::NO_CONTENT) 404 + }
+274
src/oauth/sessions.rs
··· 1 + use sha2::{Digest, Sha256}; 2 + 3 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 4 + use crate::error::AppError; 5 + use crate::plugin::encryption::{decrypt, encrypt}; 6 + 7 + /// Compute a hex-encoded SHA-256 hash of a token for indexed lookup. 8 + fn token_hash(token: &str) -> String { 9 + hex::encode(Sha256::digest(token.as_bytes())) 10 + } 11 + 12 + /// Stored DPoP session data (decrypted). 13 + pub struct DpopSession { 14 + pub id: String, 15 + pub api_client_id: String, 16 + pub dpop_key_id: String, 17 + pub user_did: String, 18 + pub access_token: String, 19 + pub refresh_token: Option<String>, 20 + pub token_expires_at: Option<String>, 21 + pub scopes: String, 22 + pub pds_url: Option<String>, 23 + pub issuer: Option<String>, 24 + } 25 + 26 + /// Store or update a DPoP session. 27 + /// 28 + /// Uses ON CONFLICT to upsert — if a session already exists for this 29 + /// (api_client_id, user_did), it updates the token data. 30 + #[allow(clippy::too_many_arguments)] 31 + pub async fn store_dpop_session( 32 + pool: &sqlx::AnyPool, 33 + backend: DatabaseBackend, 34 + encryption_key: &[u8; 32], 35 + id: &str, 36 + api_client_id: &str, 37 + dpop_key_id: &str, 38 + user_did: &str, 39 + access_token: &str, 40 + refresh_token: Option<&str>, 41 + token_expires_at: Option<&str>, 42 + scopes: &str, 43 + pds_url: Option<&str>, 44 + issuer: Option<&str>, 45 + ) -> Result<(), AppError> { 46 + let access_enc = encrypt(encryption_key, access_token.as_bytes()) 47 + .map_err(|e| AppError::Internal(format!("failed to encrypt access token: {e}")))?; 48 + 49 + let access_hash = token_hash(access_token); 50 + 51 + let refresh_enc = refresh_token 52 + .map(|t| { 53 + encrypt(encryption_key, t.as_bytes()) 54 + .map_err(|e| AppError::Internal(format!("failed to encrypt refresh token: {e}"))) 55 + }) 56 + .transpose()?; 57 + 58 + let now = now_rfc3339(); 59 + let sql = adapt_sql( 60 + 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) 61 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 62 + ON CONFLICT (api_client_id, user_did) DO UPDATE SET 63 + dpop_key_id = EXCLUDED.dpop_key_id, 64 + access_token_enc = EXCLUDED.access_token_enc, 65 + access_token_hash = EXCLUDED.access_token_hash, 66 + refresh_token_enc = EXCLUDED.refresh_token_enc, 67 + token_expires_at = EXCLUDED.token_expires_at, 68 + scopes = EXCLUDED.scopes, 69 + pds_url = EXCLUDED.pds_url, 70 + issuer = EXCLUDED.issuer, 71 + updated_at = EXCLUDED.updated_at"#, 72 + backend, 73 + ); 74 + 75 + sqlx::query(&sql) 76 + .bind(id) 77 + .bind(api_client_id) 78 + .bind(dpop_key_id) 79 + .bind(user_did) 80 + .bind(&access_enc) 81 + .bind(&access_hash) 82 + .bind(&refresh_enc) 83 + .bind(token_expires_at) 84 + .bind(scopes) 85 + .bind(pds_url) 86 + .bind(issuer) 87 + .bind(&now) 88 + .bind(&now) 89 + .execute(pool) 90 + .await 91 + .map_err(|e| AppError::Internal(format!("failed to store DPoP session: {e}")))?; 92 + 93 + Ok(()) 94 + } 95 + 96 + /// Look up a DPoP session by api_client_id and user_did, decrypting tokens. 97 + pub async fn get_dpop_session( 98 + pool: &sqlx::AnyPool, 99 + backend: DatabaseBackend, 100 + encryption_key: &[u8; 32], 101 + api_client_id: &str, 102 + user_did: &str, 103 + ) -> Result<DpopSession, AppError> { 104 + let sql = adapt_sql( 105 + "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 = ?", 106 + backend, 107 + ); 108 + 109 + #[allow(clippy::type_complexity)] 110 + let row: Option<( 111 + String, 112 + String, 113 + Vec<u8>, 114 + Option<Vec<u8>>, 115 + Option<String>, 116 + String, 117 + Option<String>, 118 + Option<String>, 119 + )> = sqlx::query_as(&sql) 120 + .bind(api_client_id) 121 + .bind(user_did) 122 + .fetch_optional(pool) 123 + .await 124 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; 125 + 126 + let (id, dpop_key_id, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) = 127 + row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; 128 + 129 + let access_token = String::from_utf8( 130 + decrypt(encryption_key, &access_enc) 131 + .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?, 132 + ) 133 + .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?; 134 + 135 + let refresh_token = refresh_enc 136 + .map(|enc| { 137 + let bytes = decrypt(encryption_key, &enc) 138 + .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?; 139 + String::from_utf8(bytes) 140 + .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}"))) 141 + }) 142 + .transpose()?; 143 + 144 + Ok(DpopSession { 145 + id, 146 + api_client_id: api_client_id.to_string(), 147 + dpop_key_id, 148 + user_did: user_did.to_string(), 149 + access_token, 150 + refresh_token, 151 + token_expires_at, 152 + scopes, 153 + pds_url, 154 + issuer, 155 + }) 156 + } 157 + 158 + /// Look up a DPoP session by api_client_id and access token. 159 + /// Uses the `access_token_hash` column for indexed lookup instead of 160 + /// decrypting every session. 161 + pub async fn get_dpop_session_by_token_hash( 162 + pool: &sqlx::AnyPool, 163 + backend: DatabaseBackend, 164 + encryption_key: &[u8; 32], 165 + api_client_id: &str, 166 + access_token: &str, 167 + ) -> Result<DpopSession, AppError> { 168 + let hash = token_hash(access_token); 169 + let sql = adapt_sql( 170 + "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 = ?", 171 + backend, 172 + ); 173 + 174 + #[allow(clippy::type_complexity)] 175 + let row: Option<( 176 + String, 177 + String, 178 + String, 179 + Vec<u8>, 180 + Option<Vec<u8>>, 181 + Option<String>, 182 + String, 183 + Option<String>, 184 + Option<String>, 185 + )> = sqlx::query_as(&sql) 186 + .bind(api_client_id) 187 + .bind(&hash) 188 + .fetch_optional(pool) 189 + .await 190 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; 191 + 192 + let ( 193 + id, 194 + dpop_key_id, 195 + user_did, 196 + access_enc, 197 + refresh_enc, 198 + token_expires_at, 199 + scopes, 200 + pds_url, 201 + issuer, 202 + ) = row.ok_or_else(|| AppError::Auth("no matching DPoP session".into()))?; 203 + 204 + let access_token_dec = String::from_utf8( 205 + decrypt(encryption_key, &access_enc) 206 + .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?, 207 + ) 208 + .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?; 209 + 210 + let refresh_token = refresh_enc 211 + .map(|enc| { 212 + let bytes = decrypt(encryption_key, &enc) 213 + .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?; 214 + String::from_utf8(bytes) 215 + .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}"))) 216 + }) 217 + .transpose()?; 218 + 219 + Ok(DpopSession { 220 + id, 221 + api_client_id: api_client_id.to_string(), 222 + dpop_key_id, 223 + user_did, 224 + access_token: access_token_dec, 225 + refresh_token, 226 + token_expires_at, 227 + scopes, 228 + pds_url, 229 + issuer, 230 + }) 231 + } 232 + 233 + /// Delete a DPoP session by api_client_id and user_did. 234 + pub async fn delete_dpop_session( 235 + pool: &sqlx::AnyPool, 236 + backend: DatabaseBackend, 237 + api_client_id: &str, 238 + user_did: &str, 239 + ) -> Result<String, AppError> { 240 + // Look up the dpop_key_id before deleting so we can clean up the key too 241 + let lookup_sql = adapt_sql( 242 + "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", 243 + backend, 244 + ); 245 + 246 + let row: Option<(String,)> = sqlx::query_as(&lookup_sql) 247 + .bind(api_client_id) 248 + .bind(user_did) 249 + .fetch_optional(pool) 250 + .await 251 + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; 252 + 253 + let (dpop_key_id,) = row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; 254 + 255 + let del_session_sql = adapt_sql( 256 + "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", 257 + backend, 258 + ); 259 + sqlx::query(&del_session_sql) 260 + .bind(api_client_id) 261 + .bind(user_did) 262 + .execute(pool) 263 + .await 264 + .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?; 265 + 266 + let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); 267 + sqlx::query(&del_key_sql) 268 + .bind(&dpop_key_id) 269 + .execute(pool) 270 + .await 271 + .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?; 272 + 273 + Ok(dpop_key_id) 274 + }
+1 -1
src/repo/mod.rs
··· 3 3 mod upload_blob; 4 4 5 5 pub(crate) use pds::{forward_pds_response, pds_post_json_raw}; 6 - pub(crate) use session::get_oauth_session; 6 + pub(crate) use session::{get_dpop_client_id, get_oauth_session}; 7 7 pub use upload_blob::upload_blob;
+22
src/repo/session.rs
··· 2 2 3 3 use crate::AppState; 4 4 use crate::HappyViewOAuthSession; 5 + use crate::db::adapt_sql; 5 6 use crate::error::AppError; 7 + 8 + /// Resolve an API client ID from a client_key. 9 + /// Used by the procedure handler to route DPoP PDS writes. 10 + pub(crate) async fn get_dpop_client_id( 11 + state: &AppState, 12 + client_key: &str, 13 + ) -> Result<String, AppError> { 14 + let sql = adapt_sql( 15 + "SELECT id FROM api_clients WHERE client_key = ? AND is_active = 1", 16 + state.db_backend, 17 + ); 18 + 19 + let row: Option<(String,)> = sqlx::query_as(&sql) 20 + .bind(client_key) 21 + .fetch_optional(&state.db) 22 + .await 23 + .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?; 24 + 25 + row.map(|(id,)| id) 26 + .ok_or_else(|| AppError::Auth("unknown API client".into())) 27 + } 6 28 7 29 /// Resume an OAuth session for the given DID via atrium. 8 30 /// The returned `OAuthSession` handles DPoP and token refresh internally.
+32 -5
src/repo/upload_blob.rs
··· 4 4 use axum::response::Response; 5 5 6 6 use crate::AppState; 7 - use crate::auth::Claims; 7 + use crate::auth::XrpcClaims; 8 8 use crate::error::AppError; 9 9 use crate::rate_limit::CheckResult; 10 10 ··· 13 13 14 14 pub async fn upload_blob( 15 15 State(state): State<AppState>, 16 - claims: Claims, 16 + xrpc_claims: XrpcClaims, 17 17 headers: HeaderMap, 18 18 body: Bytes, 19 19 ) -> Result<Response, AppError> { 20 + let claims = xrpc_claims 21 + .0 22 + .ok_or_else(|| AppError::Auth("uploadBlob requires DPoP authentication".into()))?; 20 23 let check = if let Some(client_key) = claims.client_key() { 21 24 let cost = state 22 25 .rate_limiter ··· 39 42 }); 40 43 } 41 44 42 - let session = get_oauth_session(&state, claims.did()).await?; 43 - 44 45 let content_type = headers 45 46 .get("content-type") 46 47 .and_then(|v| v.to_str().ok()) 47 48 .unwrap_or("application/octet-stream"); 48 49 49 - let mut response = pds_post_blob(&state, &session, content_type, body).await?; 50 + let mut response = if let Some(client_key) = claims.client_key() { 51 + let encryption_key = state 52 + .config 53 + .token_encryption_key 54 + .as_ref() 55 + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; 56 + 57 + let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?; 58 + 59 + let resp = crate::oauth::pds_write::dpop_pds_post_blob( 60 + &state.http, 61 + &state.db, 62 + state.db_backend, 63 + encryption_key, 64 + &state.config.plc_url, 65 + &api_client_id, 66 + claims.did(), 67 + content_type, 68 + body, 69 + ) 70 + .await?; 71 + 72 + crate::repo::forward_pds_response(resp).await? 73 + } else { 74 + let session = get_oauth_session(&state, claims.did()).await?; 75 + pds_post_blob(&state, &session, content_type, body).await? 76 + }; 50 77 51 78 if let Some(CheckResult::Allowed { 52 79 remaining,
+11 -3
src/server.rs
··· 12 12 13 13 use crate::AppState; 14 14 use crate::admin; 15 - use crate::auth::Claims; 15 + use crate::auth::XrpcClaims; 16 16 use crate::domain_middleware::resolve_domain; 17 17 use crate::error::AppError; 18 18 use crate::profile; ··· 64 64 let domain_routes = Router::new() 65 65 .nest("/auth", crate::auth::routes::routes()) 66 66 .nest("/external-auth", crate::external_auth::routes()) 67 + .nest("/oauth", crate::oauth::routes::routes()) 67 68 // https://atproto.com/specs/oauth#types-of-clients 68 69 .route("/oauth-client-metadata.json", get(client_metadata)) 69 70 .route("/xrpc/app.bsky.actor.getProfile", get(get_profile)) ··· 89 90 .layer( 90 91 CorsLayer::new() 91 92 .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) 92 - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) 93 + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) 93 94 .allow_headers([ 94 95 header::CONTENT_TYPE, 95 96 header::AUTHORIZATION, 96 97 header::COOKIE, 97 98 axum::http::HeaderName::from_static("x-client-key"), 98 99 axum::http::HeaderName::from_static("x-client-secret"), 100 + axum::http::HeaderName::from_static("dpop"), 99 101 ]) 100 102 .allow_credentials(true), 101 103 ) ··· 208 210 Json(metadata) 209 211 } 210 212 211 - async fn get_profile(State(state): State<AppState>, claims: Claims) -> Result<Response, AppError> { 213 + async fn get_profile( 214 + State(state): State<AppState>, 215 + xrpc_claims: XrpcClaims, 216 + ) -> Result<Response, AppError> { 217 + let claims = xrpc_claims 218 + .0 219 + .ok_or_else(|| AppError::Auth("getProfile requires DPoP authentication".into()))?; 212 220 let check = if let Some(client_key) = claims.client_key() { 213 221 let cost = state 214 222 .rate_limiter
+10 -6
src/xrpc/mod.rs
··· 3 3 4 4 use axum::Json; 5 5 use axum::body::Body; 6 - use axum::extract::{FromRequestParts, Path, RawQuery, State}; 6 + use axum::extract::{Path, RawQuery, State}; 7 7 use axum::http::StatusCode; 8 8 use axum::http::request::Parts; 9 9 use axum::response::Response; ··· 11 11 use std::collections::HashMap; 12 12 13 13 use crate::AppState; 14 - use crate::auth::Claims; 14 + use crate::auth::{Claims, XrpcClaims}; 15 15 use crate::error::AppError; 16 16 use crate::lexicon::LexiconType; 17 17 use crate::rate_limit::CheckResult; ··· 253 253 State(state): State<AppState>, 254 254 Path(method): Path<String>, 255 255 RawQuery(raw_query): RawQuery, 256 - mut parts: Parts, 256 + xrpc_claims: XrpcClaims, 257 + parts: Parts, 257 258 ) -> Result<Response, AppError> { 258 259 let raw_query = raw_query.unwrap_or_default(); 259 260 let mut params = parse_query_params(&raw_query); 260 - let claims = Claims::from_request_parts(&mut parts, &state).await.ok(); 261 + let claims = xrpc_claims.0; 261 262 262 263 let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, &params)?; 263 264 ··· 336 337 State(state): State<AppState>, 337 338 Path(method): Path<String>, 338 339 RawQuery(raw_query): RawQuery, 339 - mut parts: Parts, 340 + xrpc_claims: XrpcClaims, 341 + parts: Parts, 340 342 Json(body): Json<serde_json::Value>, 341 343 ) -> Result<Response, AppError> { 342 344 let raw_query = raw_query.unwrap_or_default(); 343 345 let mut params = parse_query_params(&raw_query); 344 - let claims = Claims::from_request_parts(&mut parts, &state).await?; 346 + let claims = xrpc_claims 347 + .0 348 + .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; 345 349 346 350 let rate_key = resolve_client_key(&state, Some(&claims), &parts, &params)?; 347 351
+148
src/xrpc/procedure.rs
··· 29 29 AppError::BadRequest(format!("{method} has no target_collection configured")) 30 30 })?; 31 31 32 + // If the user authenticated via a DPoP session (has a client_key from DPoP auth), 33 + // use the DPoP PDS write path. Otherwise, fall back to the atrium OAuth session. 34 + if let Some(client_key) = claims.client_key() { 35 + let encryption_key = state 36 + .config 37 + .token_encryption_key 38 + .as_ref() 39 + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; 40 + 41 + let api_client_id = repo::get_dpop_client_id(state, client_key).await?; 42 + 43 + return handle_dpop_procedure( 44 + state, 45 + claims, 46 + input, 47 + collection, 48 + &lexicon.action, 49 + &api_client_id, 50 + encryption_key, 51 + ) 52 + .await; 53 + } 54 + 32 55 let session = repo::get_oauth_session(state, claims.did()).await?; 33 56 34 57 match &lexicon.action { ··· 263 286 repo::forward_pds_response(resp).await 264 287 } 265 288 } 289 + 290 + async fn handle_dpop_procedure( 291 + state: &AppState, 292 + claims: &Claims, 293 + input: &Value, 294 + collection: &str, 295 + action: &ProcedureAction, 296 + api_client_id: &str, 297 + encryption_key: &[u8; 32], 298 + ) -> Result<Response, AppError> { 299 + let (xrpc_method, pds_body) = match action { 300 + ProcedureAction::Create => { 301 + let mut record = input.clone(); 302 + if let Some(obj) = record.as_object_mut() { 303 + obj.insert("$type".to_string(), json!(collection)); 304 + obj.remove("shouldPublish"); 305 + } 306 + ( 307 + "com.atproto.repo.createRecord", 308 + json!({ 309 + "repo": claims.did(), 310 + "collection": collection, 311 + "record": record, 312 + }), 313 + ) 314 + } 315 + ProcedureAction::Update => { 316 + let uri = input 317 + .get("uri") 318 + .and_then(|v| v.as_str()) 319 + .ok_or_else(|| AppError::BadRequest("missing uri field".into()))?; 320 + let rkey = uri 321 + .split('/') 322 + .next_back() 323 + .ok_or_else(|| AppError::Internal("invalid AT URI".into()))?; 324 + let mut record = input.clone(); 325 + if let Some(obj) = record.as_object_mut() { 326 + obj.insert("$type".to_string(), json!(collection)); 327 + obj.remove("uri"); 328 + obj.remove("shouldPublish"); 329 + } 330 + ( 331 + "com.atproto.repo.putRecord", 332 + json!({ 333 + "repo": claims.did(), 334 + "collection": collection, 335 + "rkey": rkey, 336 + "record": record, 337 + }), 338 + ) 339 + } 340 + ProcedureAction::Delete => { 341 + let uri = input 342 + .get("uri") 343 + .and_then(|v| v.as_str()) 344 + .ok_or_else(|| AppError::BadRequest("missing uri field".into()))?; 345 + let rkey = uri 346 + .split('/') 347 + .next_back() 348 + .ok_or_else(|| AppError::Internal("invalid AT URI".into()))?; 349 + ( 350 + "com.atproto.repo.deleteRecord", 351 + json!({ 352 + "repo": claims.did(), 353 + "collection": collection, 354 + "rkey": rkey, 355 + }), 356 + ) 357 + } 358 + ProcedureAction::Upsert => { 359 + let has_uri = input.get("uri").and_then(|v| v.as_str()).is_some(); 360 + if has_uri { 361 + let uri = input["uri"].as_str().unwrap(); 362 + let rkey = uri 363 + .split('/') 364 + .next_back() 365 + .ok_or_else(|| AppError::Internal("invalid AT URI".into()))?; 366 + let mut record = input.clone(); 367 + if let Some(obj) = record.as_object_mut() { 368 + obj.insert("$type".to_string(), json!(collection)); 369 + obj.remove("uri"); 370 + obj.remove("shouldPublish"); 371 + } 372 + ( 373 + "com.atproto.repo.putRecord", 374 + json!({ 375 + "repo": claims.did(), 376 + "collection": collection, 377 + "rkey": rkey, 378 + "record": record, 379 + }), 380 + ) 381 + } else { 382 + let mut record = input.clone(); 383 + if let Some(obj) = record.as_object_mut() { 384 + obj.insert("$type".to_string(), json!(collection)); 385 + obj.remove("shouldPublish"); 386 + } 387 + ( 388 + "com.atproto.repo.createRecord", 389 + json!({ 390 + "repo": claims.did(), 391 + "collection": collection, 392 + "record": record, 393 + }), 394 + ) 395 + } 396 + } 397 + }; 398 + 399 + let resp = crate::oauth::pds_write::dpop_pds_post( 400 + &state.http, 401 + &state.db, 402 + state.db_backend, 403 + encryption_key, 404 + &state.config.plc_url, 405 + api_client_id, 406 + claims.did(), 407 + xrpc_method, 408 + &pds_body, 409 + ) 410 + .await?; 411 + 412 + repo::forward_pds_response(resp).await 413 + }
+61
tests/common/app.rs
··· 165 165 } 166 166 } 167 167 168 + pub async fn new_with_encryption() -> Self { 169 + let mut app = Self::new().await; 170 + // Set a test encryption key (32 bytes) 171 + app.state.config.token_encryption_key = Some([0x42u8; 32]); 172 + // Rebuild the router with the updated state 173 + app.router = server::router(app.state.clone()); 174 + app 175 + } 176 + 177 + /// Create an API client in the database for testing. 178 + /// Returns (client_key, client_secret, api_client_id). 179 + pub async fn create_api_client( 180 + &self, 181 + client_type: &str, 182 + allowed_origins: Option<Vec<String>>, 183 + ) -> (String, String, String) { 184 + use happyview::db::{adapt_sql, now_rfc3339}; 185 + use rand::RngCore; 186 + use sha2::{Digest, Sha256}; 187 + 188 + let mut key_bytes = [0u8; 16]; 189 + rand::rng().fill_bytes(&mut key_bytes); 190 + let client_key = format!("hvc_{}", hex::encode(key_bytes)); 191 + 192 + let mut secret_bytes = [0u8; 32]; 193 + rand::rng().fill_bytes(&mut secret_bytes); 194 + let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); 195 + let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); 196 + 197 + let id = uuid::Uuid::new_v4().to_string(); 198 + let now = now_rfc3339(); 199 + let origins_json = allowed_origins 200 + .as_ref() 201 + .map(|o| serde_json::to_string(o).unwrap_or_else(|_| "[]".to_string())); 202 + 203 + let sql = adapt_sql( 204 + "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, ?, ?, ?)", 205 + self.state.db_backend, 206 + ); 207 + 208 + sqlx::query(&sql) 209 + .bind(&id) 210 + .bind(&client_key) 211 + .bind(&secret_hash) 212 + .bind("test-client") 213 + .bind(format!("https://test.example.com/oauth/{}", &id[..8])) 214 + .bind("https://test.example.com") 215 + .bind("[]") 216 + .bind("atproto") 217 + .bind(client_type) 218 + .bind(&origins_json) 219 + .bind(&self.admin_did) 220 + .bind(&now) 221 + .bind(&now) 222 + .execute(&self.state.db) 223 + .await 224 + .expect("failed to create test API client"); 225 + 226 + (client_key, client_secret, id) 227 + } 228 + 168 229 /// Build a Cookie header that authenticates as the admin user. 169 230 pub fn admin_cookie(&self) -> (axum::http::HeaderName, axum::http::HeaderValue) { 170 231 crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key)
+396
tests/dpop_auth.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use happyview::oauth::pds_write::generate_dpop_proof; 6 + use http_body_util::BodyExt; 7 + use serde_json::json; 8 + use serial_test::serial; 9 + use tower::ServiceExt; 10 + 11 + /// Helper to make a POST request with JSON body and extra headers 12 + fn post_json_with_headers( 13 + uri: &str, 14 + body: &serde_json::Value, 15 + headers: Vec<(&str, &str)>, 16 + ) -> Request<Body> { 17 + let mut builder = Request::builder() 18 + .method("POST") 19 + .uri(uri) 20 + .header("content-type", "application/json"); 21 + for (name, value) in headers { 22 + builder = builder.header(name, value); 23 + } 24 + builder 25 + .body(Body::from(serde_json::to_vec(body).unwrap())) 26 + .unwrap() 27 + } 28 + 29 + /// Helper to make a DELETE request with headers 30 + fn delete_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request<Body> { 31 + let mut builder = Request::builder().method("DELETE").uri(uri); 32 + for (name, value) in headers { 33 + builder = builder.header(name, value); 34 + } 35 + builder.body(Body::empty()).unwrap() 36 + } 37 + 38 + async fn response_json(resp: axum::http::Response<Body>) -> serde_json::Value { 39 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 40 + serde_json::from_slice(&body).unwrap_or(json!(null)) 41 + } 42 + 43 + #[tokio::test] 44 + #[serial] 45 + async fn test_provision_dpop_key_confidential_client() { 46 + let app = common::app::TestApp::new_with_encryption().await; 47 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 48 + 49 + let req = post_json_with_headers( 50 + "/oauth/dpop-keys", 51 + &json!({}), 52 + vec![ 53 + ("x-client-key", &client_key), 54 + ("x-client-secret", &client_secret), 55 + ], 56 + ); 57 + 58 + let resp = app.router.clone().oneshot(req).await.unwrap(); 59 + assert_eq!(resp.status(), StatusCode::CREATED); 60 + 61 + let body = response_json(resp).await; 62 + assert!(body["provision_id"].is_string()); 63 + assert!(body["dpop_key"]["d"].is_string()); 64 + assert_eq!(body["dpop_key"]["kty"], "EC"); 65 + assert_eq!(body["dpop_key"]["crv"], "P-256"); 66 + } 67 + 68 + #[tokio::test] 69 + #[serial] 70 + async fn test_provision_dpop_key_public_client_requires_pkce() { 71 + let app = common::app::TestApp::new_with_encryption().await; 72 + let (client_key, _secret, _id) = app 73 + .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) 74 + .await; 75 + 76 + // Without PKCE challenge should fail 77 + let req = post_json_with_headers( 78 + "/oauth/dpop-keys", 79 + &json!({}), 80 + vec![ 81 + ("x-client-key", &client_key), 82 + ("origin", "http://localhost:3000"), 83 + ], 84 + ); 85 + 86 + let resp = app.router.clone().oneshot(req).await.unwrap(); 87 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 88 + } 89 + 90 + #[tokio::test] 91 + #[serial] 92 + async fn test_provision_dpop_key_public_client_with_pkce() { 93 + let app = common::app::TestApp::new_with_encryption().await; 94 + let (client_key, _secret, _id) = app 95 + .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) 96 + .await; 97 + 98 + use base64::Engine; 99 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 100 + use sha2::{Digest, Sha256}; 101 + 102 + let verifier = "test-verifier-string-for-pkce-challenge-1234"; 103 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 104 + 105 + let req = post_json_with_headers( 106 + "/oauth/dpop-keys", 107 + &json!({ 108 + "pkce_challenge": challenge, 109 + }), 110 + vec![ 111 + ("x-client-key", &client_key), 112 + ("origin", "http://localhost:3000"), 113 + ], 114 + ); 115 + 116 + let resp = app.router.clone().oneshot(req).await.unwrap(); 117 + assert_eq!(resp.status(), StatusCode::CREATED); 118 + 119 + let body = response_json(resp).await; 120 + assert!(body["provision_id"].is_string()); 121 + } 122 + 123 + #[tokio::test] 124 + #[serial] 125 + async fn test_register_session_validates_scopes() { 126 + let app = common::app::TestApp::new_with_encryption().await; 127 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 128 + 129 + // Provision a key first 130 + let key_req = post_json_with_headers( 131 + "/oauth/dpop-keys", 132 + &json!({}), 133 + vec![ 134 + ("x-client-key", &client_key), 135 + ("x-client-secret", &client_secret), 136 + ], 137 + ); 138 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 139 + let key_body = response_json(key_resp).await; 140 + let provision_id = key_body["provision_id"].as_str().unwrap(); 141 + 142 + // Try to register with a scope the client doesn't have 143 + let req = post_json_with_headers( 144 + "/oauth/sessions", 145 + &json!({ 146 + "provision_id": provision_id, 147 + "did": "did:plc:test123", 148 + "access_token": "test-token", 149 + "scopes": "atproto com.unauthorized.scope", 150 + }), 151 + vec![ 152 + ("x-client-key", &client_key), 153 + ("x-client-secret", &client_secret), 154 + ], 155 + ); 156 + 157 + let resp = app.router.clone().oneshot(req).await.unwrap(); 158 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 159 + } 160 + 161 + #[tokio::test] 162 + #[serial] 163 + async fn test_register_session_requires_atproto_scope() { 164 + let app = common::app::TestApp::new_with_encryption().await; 165 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 166 + 167 + let key_req = post_json_with_headers( 168 + "/oauth/dpop-keys", 169 + &json!({}), 170 + vec![ 171 + ("x-client-key", &client_key), 172 + ("x-client-secret", &client_secret), 173 + ], 174 + ); 175 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 176 + let key_body = response_json(key_resp).await; 177 + let provision_id = key_body["provision_id"].as_str().unwrap(); 178 + 179 + let req = post_json_with_headers( 180 + "/oauth/sessions", 181 + &json!({ 182 + "provision_id": provision_id, 183 + "did": "did:plc:test123", 184 + "access_token": "test-token", 185 + "scopes": "transition:generic", 186 + }), 187 + vec![ 188 + ("x-client-key", &client_key), 189 + ("x-client-secret", &client_secret), 190 + ], 191 + ); 192 + 193 + let resp = app.router.clone().oneshot(req).await.unwrap(); 194 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 195 + } 196 + 197 + #[tokio::test] 198 + #[serial] 199 + async fn test_full_flow_provision_register_delete() { 200 + let app = common::app::TestApp::new_with_encryption().await; 201 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 202 + 203 + // 1. Provision key 204 + let key_req = post_json_with_headers( 205 + "/oauth/dpop-keys", 206 + &json!({}), 207 + vec![ 208 + ("x-client-key", &client_key), 209 + ("x-client-secret", &client_secret), 210 + ], 211 + ); 212 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 213 + assert_eq!(key_resp.status(), StatusCode::CREATED); 214 + let key_body = response_json(key_resp).await; 215 + let provision_id = key_body["provision_id"].as_str().unwrap(); 216 + 217 + // 2. Register session 218 + let session_req = post_json_with_headers( 219 + "/oauth/sessions", 220 + &json!({ 221 + "provision_id": provision_id, 222 + "did": "did:plc:testuser", 223 + "access_token": "test-access-token-123", 224 + "refresh_token": "test-refresh-token-456", 225 + "scopes": "atproto", 226 + "pds_url": "https://pds.example.com", 227 + }), 228 + vec![ 229 + ("x-client-key", &client_key), 230 + ("x-client-secret", &client_secret), 231 + ], 232 + ); 233 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 234 + assert_eq!(session_resp.status(), StatusCode::CREATED); 235 + let session_body = response_json(session_resp).await; 236 + assert_eq!(session_body["did"], "did:plc:testuser"); 237 + 238 + // 3. Delete session 239 + let delete_req = delete_with_headers( 240 + "/oauth/sessions/did:plc:testuser", 241 + vec![ 242 + ("x-client-key", &client_key), 243 + ("x-client-secret", &client_secret), 244 + ], 245 + ); 246 + let delete_resp = app.router.clone().oneshot(delete_req).await.unwrap(); 247 + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); 248 + 249 + // 4. Verify session is gone (try to delete again) 250 + let delete_req2 = delete_with_headers( 251 + "/oauth/sessions/did:plc:testuser", 252 + vec![ 253 + ("x-client-key", &client_key), 254 + ("x-client-secret", &client_secret), 255 + ], 256 + ); 257 + let delete_resp2 = app.router.clone().oneshot(delete_req2).await.unwrap(); 258 + assert_eq!(delete_resp2.status(), StatusCode::NOT_FOUND); 259 + } 260 + 261 + #[tokio::test] 262 + #[serial] 263 + async fn test_xrpc_rejects_bearer_auth() { 264 + let app = common::app::TestApp::new_with_encryption().await; 265 + 266 + // Bearer auth should be explicitly rejected on XRPC routes 267 + let req = Request::builder() 268 + .method("GET") 269 + .uri("/xrpc/com.example.test.getStuff") 270 + .header("x-client-key", "hvc_fake") 271 + .header("authorization", "Bearer hv_some-api-key") 272 + .body(Body::empty()) 273 + .unwrap(); 274 + 275 + let resp = app.router.clone().oneshot(req).await.unwrap(); 276 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 277 + let body = response_json(resp).await; 278 + let msg = body["message"].as_str().unwrap_or_default(); 279 + assert!( 280 + msg.contains("XRPC routes do not accept Bearer auth"), 281 + "expected Bearer rejection message, got: {msg}" 282 + ); 283 + } 284 + 285 + #[tokio::test] 286 + #[serial] 287 + async fn test_xrpc_allows_anonymous_queries() { 288 + let app = common::app::TestApp::new_with_encryption().await; 289 + 290 + // Anonymous access (no auth header) should pass through to lexicon lookup. 291 + // Since no lexicon is registered, we expect a proxy attempt or 502, not 401. 292 + let req = Request::builder() 293 + .method("GET") 294 + .uri("/xrpc/com.example.test.getStuff") 295 + .header("x-client-key", "hvc_fake") 296 + .body(Body::empty()) 297 + .unwrap(); 298 + 299 + let resp = app.router.clone().oneshot(req).await.unwrap(); 300 + // Not a 401 — anonymous access was allowed, the error is from the handler (no lexicon) 301 + assert_ne!( 302 + resp.status(), 303 + StatusCode::UNAUTHORIZED, 304 + "anonymous XRPC queries should not require auth" 305 + ); 306 + } 307 + 308 + #[tokio::test] 309 + #[serial] 310 + async fn test_xrpc_procedure_requires_dpop_auth() { 311 + let app = common::app::TestApp::new_with_encryption().await; 312 + 313 + // POST to an XRPC procedure without DPoP auth should be rejected 314 + let req = Request::builder() 315 + .method("POST") 316 + .uri("/xrpc/com.example.test.createStuff") 317 + .header("x-client-key", "hvc_fake") 318 + .header("content-type", "application/json") 319 + .body(Body::from("{}")) 320 + .unwrap(); 321 + 322 + let resp = app.router.clone().oneshot(req).await.unwrap(); 323 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 324 + let body = response_json(resp).await; 325 + let msg = body["message"].as_str().unwrap_or_default(); 326 + assert!( 327 + msg.contains("DPoP authentication"), 328 + "expected DPoP requirement message, got: {msg}" 329 + ); 330 + } 331 + 332 + #[tokio::test] 333 + #[serial] 334 + async fn test_xrpc_dpop_auth_accepted() { 335 + let app = common::app::TestApp::new_with_encryption().await; 336 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 337 + 338 + // 1. Provision key 339 + let key_req = post_json_with_headers( 340 + "/oauth/dpop-keys", 341 + &json!({}), 342 + vec![ 343 + ("x-client-key", &client_key), 344 + ("x-client-secret", &client_secret), 345 + ], 346 + ); 347 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 348 + assert_eq!(key_resp.status(), StatusCode::CREATED); 349 + let key_body = response_json(key_resp).await; 350 + let provision_id = key_body["provision_id"].as_str().unwrap(); 351 + let dpop_key = &key_body["dpop_key"]; 352 + 353 + // 2. Register session 354 + let access_token = "test-xrpc-access-token"; 355 + let session_req = post_json_with_headers( 356 + "/oauth/sessions", 357 + &json!({ 358 + "provision_id": provision_id, 359 + "did": "did:plc:xrpcuser", 360 + "access_token": access_token, 361 + "scopes": "atproto", 362 + "pds_url": "https://pds.example.com", 363 + }), 364 + vec![ 365 + ("x-client-key", &client_key), 366 + ("x-client-secret", &client_secret), 367 + ], 368 + ); 369 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 370 + assert_eq!(session_resp.status(), StatusCode::CREATED); 371 + 372 + // 3. Generate a DPoP proof for an XRPC GET request 373 + let request_url = "http://127.0.0.1:0/xrpc/com.example.test.getStuff"; 374 + let proof = generate_dpop_proof(dpop_key, "GET", request_url, access_token) 375 + .expect("failed to generate DPoP proof"); 376 + 377 + // 4. Make an XRPC request with DPoP auth 378 + let xrpc_req = Request::builder() 379 + .method("GET") 380 + .uri("/xrpc/com.example.test.getStuff") 381 + .header("host", "127.0.0.1:0") 382 + .header("x-client-key", &client_key) 383 + .header("authorization", format!("DPoP {}", access_token)) 384 + .header("dpop", &proof) 385 + .body(Body::empty()) 386 + .unwrap(); 387 + 388 + let xrpc_resp = app.router.clone().oneshot(xrpc_req).await.unwrap(); 389 + // Auth should succeed — any non-401 status means DPoP auth was accepted. 390 + // We expect a 502 (proxy attempt for unknown lexicon) or similar, not 401. 391 + assert_ne!( 392 + xrpc_resp.status(), 393 + StatusCode::UNAUTHORIZED, 394 + "DPoP-authenticated XRPC request should not get 401" 395 + ); 396 + }