From 28f7fa6430c1e29705c64d3c4f69c1f9b9a37eb9 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 26 May 2026 13:22:40 -0500 Subject: [PATCH] fix: use dpop thumbprints to enable multi-device auth Signed-off-by: Trezy Signed-off-by: Trezy --- ...20260526000000_dpop_session_per_device.sql | 2 + .../20260526000001_drop_access_token_hash.sql | 2 + ...20260526000000_dpop_session_per_device.sql | 2 + .../20260526000001_drop_access_token_hash.sql | 2 + src/auth/middleware.rs | 41 +- src/delegation/unlink_account.rs | 6 +- src/lua/execute.rs | 5 + src/oauth/dpop_proof.rs | 72 +++ src/oauth/keys.rs | 23 + src/oauth/pds_write.rs | 20 +- src/oauth/routes.rs | 472 ++++++++++++------ src/oauth/sessions.rs | 244 +++++++-- src/repo/pds.rs | 3 + src/repo/upload_blob.rs | 4 + src/xrpc/procedure.rs | 78 +-- tests/dpop_auth.rs | 446 ++++++++++++++++- 16 files changed, 1179 insertions(+), 243 deletions(-) create mode 100644 migrations/postgres/20260526000000_dpop_session_per_device.sql create mode 100644 migrations/postgres/20260526000001_drop_access_token_hash.sql create mode 100644 migrations/sqlite/20260526000000_dpop_session_per_device.sql create mode 100644 migrations/sqlite/20260526000001_drop_access_token_hash.sql diff --git a/migrations/postgres/20260526000000_dpop_session_per_device.sql b/migrations/postgres/20260526000000_dpop_session_per_device.sql new file mode 100644 index 0000000..c865644 --- /dev/null +++ b/migrations/postgres/20260526000000_dpop_session_per_device.sql @@ -0,0 +1,2 @@ +DROP INDEX idx_dpop_sessions_client_user; +CREATE UNIQUE INDEX idx_dpop_sessions_client_user_key ON dpop_sessions(api_client_id, user_did, dpop_key_id); diff --git a/migrations/postgres/20260526000001_drop_access_token_hash.sql b/migrations/postgres/20260526000001_drop_access_token_hash.sql new file mode 100644 index 0000000..1dc42ac --- /dev/null +++ b/migrations/postgres/20260526000001_drop_access_token_hash.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_dpop_sessions_token_hash; +ALTER TABLE dpop_sessions DROP COLUMN access_token_hash; diff --git a/migrations/sqlite/20260526000000_dpop_session_per_device.sql b/migrations/sqlite/20260526000000_dpop_session_per_device.sql new file mode 100644 index 0000000..c865644 --- /dev/null +++ b/migrations/sqlite/20260526000000_dpop_session_per_device.sql @@ -0,0 +1,2 @@ +DROP INDEX idx_dpop_sessions_client_user; +CREATE UNIQUE INDEX idx_dpop_sessions_client_user_key ON dpop_sessions(api_client_id, user_did, dpop_key_id); diff --git a/migrations/sqlite/20260526000001_drop_access_token_hash.sql b/migrations/sqlite/20260526000001_drop_access_token_hash.sql new file mode 100644 index 0000000..1dc42ac --- /dev/null +++ b/migrations/sqlite/20260526000001_drop_access_token_hash.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_dpop_sessions_token_hash; +ALTER TABLE dpop_sessions DROP COLUMN access_token_hash; diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index f4cc387..4715311 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -17,6 +17,8 @@ pub struct Claims { did: String, /// The API client key (e.g. "hvc_...") if the user authenticated via an API client. client_key: Option, + /// The DPoP key ID identifying the specific device session. + dpop_key_id: Option, } /// Separator used to encode `did` and `client_key` in a single cookie value. @@ -34,11 +36,17 @@ impl Claims { self.client_key.as_deref() } + /// The DPoP key ID, if the user authenticated via a DPoP session. + pub fn dpop_key_id(&self) -> Option<&str> { + self.dpop_key_id.as_deref() + } + /// Create claims for an internal call (e.g. Lua xrpc lib) with no client key. pub fn internal(did: String) -> Self { Self { did, client_key: None, + dpop_key_id: None, } } @@ -68,7 +76,11 @@ impl FromRequestParts for Claims { } else { (value, None) }; - return Ok(Claims { did, client_key }); + return Ok(Claims { + did, + client_key, + dpop_key_id: None, + }); } // Path 2: Authorization header @@ -90,6 +102,7 @@ impl FromRequestParts for Claims { return Ok(Claims { did, client_key: None, + dpop_key_id: None, }); } @@ -98,6 +111,7 @@ impl FromRequestParts for Claims { return Ok(Claims { did: service_auth.did, client_key: None, + dpop_key_id: None, }); } @@ -163,13 +177,23 @@ pub async fn resolve_dpop_claims( crate::oauth::client_auth::resolve_client_by_key(&state.db, state.db_backend, client_key) .await?; - // Look up the session by token - let session = crate::oauth::sessions::get_dpop_session_by_token_hash( + // Extract JWK thumbprint from the DPoP proof and resolve the key ID + let thumbprint = crate::oauth::dpop_proof::extract_proof_thumbprint(dpop_proof)?; + let dpop_key_id = crate::oauth::keys::get_dpop_key_id_by_thumbprint( + &state.db, + state.db_backend, + &client.id, + &thumbprint, + ) + .await?; + + // Look up the session by key ID (stable across token rotations) + let session = crate::oauth::sessions::get_dpop_session_by_key_id( &state.db, state.db_backend, encryption_key, &client.id, - access_token, + &dpop_key_id, ) .await?; @@ -181,14 +205,6 @@ pub async fn resolve_dpop_claims( return Err(AppError::Auth("token_expired".into())); } - // Get the DPoP key thumbprint for proof validation - let thumbprint = crate::oauth::keys::get_dpop_key_thumbprint( - &state.db, - state.db_backend, - &session.dpop_key_id, - ) - .await?; - // Build the request URL for htu validation let scheme = if state.config.public_url.starts_with("https") { "https" @@ -215,6 +231,7 @@ pub async fn resolve_dpop_claims( Ok(Claims { did: session.user_did, client_key: Some(client_key.to_string()), + dpop_key_id: Some(dpop_key_id), }) } diff --git a/src/delegation/unlink_account.rs b/src/delegation/unlink_account.rs index 1a1e6e6..1feb4c8 100644 --- a/src/delegation/unlink_account.rs +++ b/src/delegation/unlink_account.rs @@ -54,9 +54,9 @@ pub async fn unlink_account( // Delete delegated account (CASCADE deletes all delegates) db::delete_delegated_account(&state.db, state.db_backend, account_did).await?; - // Delete the DPoP session for the target account using the stored api_client_id + // Delete all DPoP sessions for the target account using the stored api_client_id if let Some(api_client_id) = stored_api_client_id - && let Err(e) = crate::oauth::sessions::delete_dpop_session( + && let Err(e) = crate::oauth::sessions::delete_all_dpop_sessions( &state.db, state.db_backend, &api_client_id, @@ -64,7 +64,7 @@ pub async fn unlink_account( ) .await { - tracing::warn!(account_did, %e, "failed to clean up DPoP session on unlink"); + tracing::warn!(account_did, %e, "failed to clean up DPoP sessions on unlink"); } log_event( diff --git a/src/lua/execute.rs b/src/lua/execute.rs index a4f7977..5f54131 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -125,8 +125,13 @@ pub async fn execute_procedure_script( return Err(e); } }; + let dpop_key_id = claims + .dpop_key_id() + .ok_or_else(|| AppError::Internal("DPoP key ID not available in claims".into()))? + .to_string(); repo::PdsAuth::Dpop { api_client_id, + dpop_key_id, encryption_key: *encryption_key, } } else { diff --git a/src/oauth/dpop_proof.rs b/src/oauth/dpop_proof.rs index 6ff4422..376680d 100644 --- a/src/oauth/dpop_proof.rs +++ b/src/oauth/dpop_proof.rs @@ -23,6 +23,23 @@ struct DpopPayload { jti: String, } +/// Extract the JWK thumbprint from a DPoP proof JWT header without full validation. +pub fn extract_proof_thumbprint(proof_jwt: &str) -> Result { + let header_b64 = proof_jwt + .split('.') + .next() + .ok_or_else(|| AppError::Auth("invalid DPoP proof format".into()))?; + + let header_bytes = URL_SAFE_NO_PAD + .decode(header_b64) + .map_err(|_| AppError::Auth("invalid DPoP proof header encoding".into()))?; + + let header: DpopHeader = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid DPoP proof header".into()))?; + + super::keys::compute_jwk_thumbprint(&header.jwk) +} + /// Validate a DPoP proof JWT. /// /// Checks: @@ -216,4 +233,59 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("dpop+jwt")); } + + #[test] + fn extract_proof_thumbprint_from_real_proof() { + let keypair = crate::oauth::keys::generate_dpop_keypair().unwrap(); + + let proof = crate::oauth::pds_write::generate_dpop_proof( + &keypair.private_jwk, + "POST", + "https://pds.example.com/xrpc/test", + "token", + None, + ) + .unwrap(); + + let thumbprint = extract_proof_thumbprint(&proof).unwrap(); + assert_eq!(thumbprint, keypair.thumbprint); + } + + #[test] + fn extract_proof_thumbprint_rejects_garbage() { + assert!(extract_proof_thumbprint("not-a-jwt").is_err()); + } + + #[test] + fn extract_proof_thumbprint_rejects_bad_base64() { + assert!(extract_proof_thumbprint("!!!.payload.sig").is_err()); + } + + #[test] + fn extract_proof_thumbprint_different_keys_differ() { + let kp1 = crate::oauth::keys::generate_dpop_keypair().unwrap(); + let kp2 = crate::oauth::keys::generate_dpop_keypair().unwrap(); + + let proof1 = crate::oauth::pds_write::generate_dpop_proof( + &kp1.private_jwk, + "GET", + "https://example.com", + "t", + None, + ) + .unwrap(); + + let proof2 = crate::oauth::pds_write::generate_dpop_proof( + &kp2.private_jwk, + "GET", + "https://example.com", + "t", + None, + ) + .unwrap(); + + let t1 = extract_proof_thumbprint(&proof1).unwrap(); + let t2 = extract_proof_thumbprint(&proof2).unwrap(); + assert_ne!(t1, t2); + } } diff --git a/src/oauth/keys.rs b/src/oauth/keys.rs index 7990efd..96559e0 100644 --- a/src/oauth/keys.rs +++ b/src/oauth/keys.rs @@ -186,6 +186,29 @@ pub async fn get_dpop_key_thumbprint( .ok_or_else(|| AppError::NotFound("DPoP key not found".into())) } +/// Look up a DPoP key ID by api_client_id and JWK thumbprint. +pub async fn get_dpop_key_id_by_thumbprint( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + api_client_id: &str, + thumbprint: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id FROM dpop_keys WHERE api_client_id = ? AND jwk_thumbprint = ?", + backend, + ); + + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(api_client_id) + .bind(thumbprint) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; + + row.map(|(id,)| id) + .ok_or_else(|| AppError::Auth("no DPoP key matching proof thumbprint".into())) +} + /// Delete a DPoP key and its associated session. pub async fn delete_dpop_key( pool: &sqlx::AnyPool, diff --git a/src/oauth/pds_write.rs b/src/oauth/pds_write.rs index 3da90b4..c31fb36 100644 --- a/src/oauth/pds_write.rs +++ b/src/oauth/pds_write.rs @@ -20,6 +20,7 @@ struct DpopCredentials { } /// Resolve DPoP credentials: session, PDS URL, and decrypted private key. +#[allow(clippy::too_many_arguments)] async fn resolve_credentials( http: &reqwest::Client, pool: &sqlx::AnyPool, @@ -28,10 +29,17 @@ async fn resolve_credentials( plc_url: &str, api_client_id: &str, user_did: &str, + dpop_key_id: &str, ) -> Result { - let session = - super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) - .await?; + let session = super::sessions::get_dpop_session( + pool, + backend, + encryption_key, + api_client_id, + user_did, + dpop_key_id, + ) + .await?; let pds_url = match session.pds_url { Some(ref url) => url.clone(), @@ -172,6 +180,7 @@ async fn retry_after_refresh( encryption_key, &creds.session.api_client_id, &creds.session.user_did, + &creds.session.dpop_key_id, ) .await?; @@ -195,6 +204,7 @@ async fn retry_after_refresh( backend, &creds.session.api_client_id, &creds.session.user_did, + &creds.session.dpop_key_id, ) .await { @@ -258,6 +268,7 @@ pub async fn dpop_pds_post( plc_url: &str, api_client_id: &str, user_did: &str, + dpop_key_id: &str, xrpc_method: &str, body: &serde_json::Value, ) -> Result { @@ -269,6 +280,7 @@ pub async fn dpop_pds_post( plc_url, api_client_id, user_did, + dpop_key_id, ) .await?; @@ -310,6 +322,7 @@ pub async fn dpop_pds_post_blob( plc_url: &str, api_client_id: &str, user_did: &str, + dpop_key_id: &str, content_type: &str, blob: bytes::Bytes, ) -> Result { @@ -321,6 +334,7 @@ pub async fn dpop_pds_post_blob( plc_url, api_client_id, user_did, + dpop_key_id, ) .await?; diff --git a/src/oauth/routes.rs b/src/oauth/routes.rs index 4f85a4e..5ec26ca 100644 --- a/src/oauth/routes.rs +++ b/src/oauth/routes.rs @@ -18,6 +18,11 @@ pub fn routes() -> Router { .route("/dpop-keys", post(provision_dpop_key)) .route("/sessions", post(register_session)) .route("/sessions/{did}", get(get_session).delete(delete_session)) + .route("/sessions/{did}/devices", get(list_device_sessions)) + .route( + "/sessions/{did}/devices/{session_id}", + axum::routing::delete(delete_device_session), + ) } // --- Request / response types --- @@ -59,6 +64,15 @@ struct GetSessionResponse { scopes: Vec, } +#[derive(Serialize)] +struct DeviceSessionInfo { + id: String, + dpop_key_id: String, + scopes: Vec, + created_at: String, + updated_at: String, +} + // --- Handlers --- /// POST /oauth/dpop-keys — provision a new DPoP keypair. @@ -233,29 +247,6 @@ async fn register_session( // Validate scopes client_auth::validate_scopes(&body.scopes, &client.scopes, &state.lexicons).await?; - // Clean up any existing session's DPoP key before upserting - // (the ON CONFLICT upsert would orphan the old key otherwise) - { - let lookup_sql = crate::db::adapt_sql( - "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", - state.db_backend, - ); - if let Ok(Some((old_key_id,))) = sqlx::query_as::<_, (String,)>(&lookup_sql) - .bind(&client.id) - .bind(&body.did) - .fetch_optional(&state.db) - .await - && old_key_id != dpop_key_id - { - let del_sql = - crate::db::adapt_sql("DELETE FROM dpop_keys WHERE id = ?", state.db_backend); - let _ = sqlx::query(&del_sql) - .bind(&old_key_id) - .execute(&state.db) - .await; - } - } - // Store the session let session_id = Uuid::new_v4().to_string(); sessions::store_dpop_session( @@ -330,78 +321,90 @@ async fn get_session( .as_ref() .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; - let client = if let Some(ref secret) = client_secret { - client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) - .await? + let session = if let Some(ref secret) = client_secret { + let c = client_auth::authenticate_confidential( + &state.db, + state.db_backend, + &client_key, + secret, + ) + .await?; + // Confidential clients: look up by (client, user) — no DPoP proof needed + sessions::get_dpop_session_for_user( + &state.db, + state.db_backend, + encryption_key, + &c.id, + &did, + ) + .await? } else { let resolved = client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await?; - if resolved.client_type == "public" { - let auth_header = req - .headers() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - AppError::Auth("public clients must provide Authorization: DPoP ".into()) - })?; - let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { - AppError::Auth("public clients must use DPoP authorization scheme".into()) - })?; - let dpop_proof = req - .headers() - .get("dpop") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - AppError::Auth("public clients must provide DPoP proof header".into()) - })?; - - let session = sessions::get_dpop_session_by_token_hash( - &state.db, - state.db_backend, - encryption_key, - &resolved.id, - access_token, - ) - .await?; + if resolved.client_type != "public" { + return Err(AppError::Auth( + "non-public clients must provide X-Client-Secret".into(), + )); + } - let thumbprint = - keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id) - .await?; + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + AppError::Auth("public clients must provide Authorization: DPoP ".into()) + })?; + let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { + AppError::Auth("public clients must use DPoP authorization scheme".into()) + })?; + let dpop_proof = req + .headers() + .get("dpop") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + AppError::Auth("public clients must provide DPoP proof header".into()) + })?; - let scheme = if state.config.public_url.starts_with("https") { - "https" - } else { - "http" - }; - let host = req - .headers() - .get("host") - .and_then(|v| v.to_str().ok()) - .unwrap_or("localhost"); - let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did); - - crate::oauth::dpop_proof::validate_dpop_proof( - dpop_proof, - "GET", - &request_url, - access_token, - &thumbprint, - )?; - } + let thumbprint = crate::oauth::dpop_proof::extract_proof_thumbprint(dpop_proof)?; + let dpop_key_id = keys::get_dpop_key_id_by_thumbprint( + &state.db, + state.db_backend, + &resolved.id, + &thumbprint, + ) + .await?; - resolved + let scheme = if state.config.public_url.starts_with("https") { + "https" + } else { + "http" + }; + let host = req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did); + + crate::oauth::dpop_proof::validate_dpop_proof( + dpop_proof, + "GET", + &request_url, + access_token, + &thumbprint, + )?; + + sessions::get_dpop_session_by_key_id( + &state.db, + state.db_backend, + encryption_key, + &resolved.id, + &dpop_key_id, + ) + .await? }; - let session = sessions::get_dpop_session( - &state.db, - state.db_backend, - encryption_key, - &client.id, - &did, - ) - .await?; - let scopes: Vec = session .scopes .split_whitespace() @@ -433,87 +436,203 @@ async fn delete_session( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - let client = if let Some(ref secret) = client_secret { - client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret) - .await? + if let Some(ref secret) = client_secret { + let client = client_auth::authenticate_confidential( + &state.db, + state.db_backend, + &client_key, + secret, + ) + .await?; + // Confidential clients: delete all sessions for this user+client + sessions::delete_all_dpop_sessions(&state.db, state.db_backend, &client.id, &did).await?; + + log_event( + &state.db, + EventLog { + event_type: "dpop_session.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(did), + subject: Some(client.client_key), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; } else { let resolved = client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await?; - // Public clients must prove they hold the DPoP key + token - if resolved.client_type == "public" { - let auth_header = req + if resolved.client_type != "public" { + return Err(AppError::Auth( + "non-public clients must provide X-Client-Secret".into(), + )); + } + + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + AppError::Auth("public clients must provide Authorization: DPoP ".into()) + })?; + let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { + AppError::Auth("public clients must use DPoP authorization scheme".into()) + })?; + let dpop_proof = req + .headers() + .get("dpop") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + AppError::Auth("public clients must provide DPoP proof header".into()) + })?; + + let thumbprint = crate::oauth::dpop_proof::extract_proof_thumbprint(dpop_proof)?; + let dpop_key_id = keys::get_dpop_key_id_by_thumbprint( + &state.db, + state.db_backend, + &resolved.id, + &thumbprint, + ) + .await?; + + let scheme = if state.config.public_url.starts_with("https") { + "https" + } else { + "http" + }; + let host = req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did); + + crate::oauth::dpop_proof::validate_dpop_proof( + dpop_proof, + "DELETE", + &request_url, + access_token, + &thumbprint, + )?; + + sessions::delete_dpop_session( + &state.db, + state.db_backend, + &resolved.id, + &did, + &dpop_key_id, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "dpop_session.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(did), + subject: Some(resolved.client_key), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + } + + Ok(StatusCode::NO_CONTENT) +} + +/// Extracted headers for session endpoint authentication. +struct SessionAuthHeaders { + client_key: String, + client_secret: Option, + auth_header: Option, + dpop_proof: Option, + host: String, +} + +impl SessionAuthHeaders { + fn from_request(req: &axum::extract::Request) -> Self { + Self { + client_key: req + .headers() + .get("x-client-key") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + client_secret: req + .headers() + .get("x-client-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()), + auth_header: req .headers() .get("authorization") .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - AppError::Auth("public clients must provide Authorization: DPoP ".into()) - })?; - let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { - AppError::Auth("public clients must use DPoP authorization scheme".into()) - })?; - let dpop_proof = req + .map(|s| s.to_string()), + dpop_proof: req .headers() .get("dpop") .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - AppError::Auth("public clients must provide DPoP proof header".into()) - })?; - - let encryption_key = - state.config.token_encryption_key.as_ref().ok_or_else(|| { - AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()) - })?; - - // Look up the session to get the DPoP key thumbprint - let session = sessions::get_dpop_session_by_token_hash( - &state.db, - state.db_backend, - encryption_key, - &resolved.id, - access_token, - ) - .await?; - - let thumbprint = - keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id) - .await?; - - // Build request URL for htu validation - let scheme = if state.config.public_url.starts_with("https") { - "https" - } else { - "http" - }; - let host = req + .map(|s| s.to_string()), + host: req .headers() .get("host") .and_then(|v| v.to_str().ok()) - .unwrap_or("localhost"); - let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did); - - crate::oauth::dpop_proof::validate_dpop_proof( - dpop_proof, - "DELETE", - &request_url, - access_token, - &thumbprint, - )?; + .unwrap_or("localhost") + .to_string(), } + } +} - resolved - }; +/// GET /oauth/sessions/:did/devices — list all device sessions for a user. +async fn list_device_sessions( + State(state): State, + Path(did): Path, + req: axum::extract::Request, +) -> Result>, AppError> { + let request_path = req.uri().path().to_string(); + let headers = SessionAuthHeaders::from_request(&req); + let client = resolve_session_client(&state, &headers, &request_path, "GET").await?; + + let sessions = + sessions::list_dpop_sessions(&state.db, state.db_backend, &client.id, &did).await?; + + let result: Vec = sessions + .into_iter() + .map(|s| DeviceSessionInfo { + id: s.id, + dpop_key_id: s.dpop_key_id, + scopes: s.scopes.split_whitespace().map(String::from).collect(), + created_at: s.created_at, + updated_at: s.updated_at, + }) + .collect(); - sessions::delete_dpop_session(&state.db, state.db_backend, &client.id, &did).await?; + Ok(Json(result)) +} + +/// DELETE /oauth/sessions/:did/devices/:session_id — revoke a specific device session. +async fn delete_device_session( + State(state): State, + Path((did, session_id)): Path<(String, String)>, + req: axum::extract::Request, +) -> Result { + let request_path = req.uri().path().to_string(); + let headers = SessionAuthHeaders::from_request(&req); + let client = resolve_session_client(&state, &headers, &request_path, "DELETE").await?; + + sessions::delete_dpop_session_by_id(&state.db, state.db_backend, &session_id, &client.id, &did) + .await?; log_event( &state.db, EventLog { - event_type: "dpop_session.deleted".to_string(), + event_type: "dpop_session.device_deleted".to_string(), severity: Severity::Info, actor_did: Some(did), subject: Some(client.client_key), - detail: serde_json::json!({}), + detail: serde_json::json!({ "session_id": session_id }), }, state.db_backend, ) @@ -521,3 +640,68 @@ async fn delete_session( Ok(StatusCode::NO_CONTENT) } + +/// Shared client authentication for session endpoints. +async fn resolve_session_client( + state: &AppState, + headers: &SessionAuthHeaders, + request_path: &str, + method: &str, +) -> Result { + if headers.client_key.is_empty() { + return Err(AppError::Auth("X-Client-Key header required".into())); + } + + if let Some(ref secret) = headers.client_secret { + return client_auth::authenticate_confidential( + &state.db, + state.db_backend, + &headers.client_key, + secret, + ) + .await; + } + + let resolved = + client_auth::resolve_client_by_key(&state.db, state.db_backend, &headers.client_key) + .await?; + + if resolved.client_type != "public" { + return Err(AppError::Auth( + "non-public clients must provide X-Client-Secret".into(), + )); + } + + let auth_header = headers.auth_header.as_deref().ok_or_else(|| { + AppError::Auth("public clients must provide Authorization: DPoP ".into()) + })?; + let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| { + AppError::Auth("public clients must use DPoP authorization scheme".into()) + })?; + let dpop_proof = headers + .dpop_proof + .as_deref() + .ok_or_else(|| AppError::Auth("public clients must provide DPoP proof header".into()))?; + + let thumbprint = crate::oauth::dpop_proof::extract_proof_thumbprint(dpop_proof)?; + let _dpop_key_id = + keys::get_dpop_key_id_by_thumbprint(&state.db, state.db_backend, &resolved.id, &thumbprint) + .await?; + + let scheme = if state.config.public_url.starts_with("https") { + "https" + } else { + "http" + }; + let request_url = format!("{}://{}{}", scheme, headers.host, request_path); + + crate::oauth::dpop_proof::validate_dpop_proof( + dpop_proof, + method, + &request_url, + access_token, + &thumbprint, + )?; + + Ok(resolved) +} diff --git a/src/oauth/sessions.rs b/src/oauth/sessions.rs index 9258d29..7f5b788 100644 --- a/src/oauth/sessions.rs +++ b/src/oauth/sessions.rs @@ -1,14 +1,7 @@ -use sha2::{Digest, Sha256}; - use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::plugin::encryption::{decrypt, encrypt}; -/// Compute a hex-encoded SHA-256 hash of a token for indexed lookup. -fn token_hash(token: &str) -> String { - hex::encode(Sha256::digest(token.as_bytes())) -} - /// Stored DPoP session data (decrypted). pub struct DpopSession { pub id: String, @@ -23,10 +16,19 @@ pub struct DpopSession { pub issuer: Option, } +/// Session metadata returned by list_dpop_sessions (no decrypted tokens). +pub struct DpopSessionInfo { + pub id: String, + pub dpop_key_id: String, + pub scopes: String, + pub created_at: String, + pub updated_at: String, +} + /// Store or update a DPoP session. /// /// Uses ON CONFLICT to upsert — if a session already exists for this -/// (api_client_id, user_did), it updates the token data. +/// (api_client_id, user_did, dpop_key_id), it updates the token data. #[allow(clippy::too_many_arguments)] pub async fn store_dpop_session( pool: &sqlx::AnyPool, @@ -46,8 +48,6 @@ pub async fn store_dpop_session( let access_enc = encrypt(encryption_key, access_token.as_bytes()) .map_err(|e| AppError::Internal(format!("failed to encrypt access token: {e}")))?; - let access_hash = token_hash(access_token); - let refresh_enc = refresh_token .map(|t| { encrypt(encryption_key, t.as_bytes()) @@ -57,12 +57,10 @@ pub async fn store_dpop_session( let now = now_rfc3339(); let sql = adapt_sql( - r#"INSERT INTO dpop_sessions (id, api_client_id, dpop_key_id, user_did, access_token_enc, access_token_hash, refresh_token_enc, token_expires_at, scopes, pds_url, issuer, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (api_client_id, user_did) DO UPDATE SET - dpop_key_id = EXCLUDED.dpop_key_id, + r#"INSERT INTO dpop_sessions (id, api_client_id, dpop_key_id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (api_client_id, user_did, dpop_key_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, - access_token_hash = EXCLUDED.access_token_hash, refresh_token_enc = EXCLUDED.refresh_token_enc, token_expires_at = EXCLUDED.token_expires_at, scopes = EXCLUDED.scopes, @@ -78,7 +76,6 @@ pub async fn store_dpop_session( .bind(dpop_key_id) .bind(user_did) .bind(&access_enc) - .bind(&access_hash) .bind(&refresh_enc) .bind(token_expires_at) .bind(scopes) @@ -93,22 +90,22 @@ pub async fn store_dpop_session( Ok(()) } -/// Look up a DPoP session by api_client_id and user_did, decrypting tokens. +/// Look up a DPoP session by api_client_id, user_did, and dpop_key_id, decrypting tokens. pub async fn get_dpop_session( pool: &sqlx::AnyPool, backend: DatabaseBackend, encryption_key: &[u8; 32], api_client_id: &str, user_did: &str, + dpop_key_id: &str, ) -> Result { let sql = adapt_sql( - "SELECT id, dpop_key_id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + "SELECT id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND user_did = ? AND dpop_key_id = ?", backend, ); #[allow(clippy::type_complexity)] let row: Option<( - String, String, Vec, Option>, @@ -119,11 +116,12 @@ pub async fn get_dpop_session( )> = sqlx::query_as(&sql) .bind(api_client_id) .bind(user_did) + .bind(dpop_key_id) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; - let (id, dpop_key_id, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) = + let (id, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) = row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; let access_token = String::from_utf8( @@ -144,7 +142,7 @@ pub async fn get_dpop_session( Ok(DpopSession { id, api_client_id: api_client_id.to_string(), - dpop_key_id, + dpop_key_id: dpop_key_id.to_string(), user_did: user_did.to_string(), access_token, refresh_token, @@ -155,25 +153,22 @@ pub async fn get_dpop_session( }) } -/// Look up a DPoP session by api_client_id and access token. -/// Uses the `access_token_hash` column for indexed lookup instead of -/// decrypting every session. -pub async fn get_dpop_session_by_token_hash( +/// Look up a DPoP session by api_client_id and dpop_key_id, decrypting tokens. +/// Used by the auth middleware where the key ID is derived from the DPoP proof thumbprint. +pub async fn get_dpop_session_by_key_id( pool: &sqlx::AnyPool, backend: DatabaseBackend, encryption_key: &[u8; 32], api_client_id: &str, - access_token: &str, + dpop_key_id: &str, ) -> Result { - let hash = token_hash(access_token); let sql = adapt_sql( - "SELECT id, dpop_key_id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND access_token_hash = ?", + "SELECT id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND dpop_key_id = ?", backend, ); #[allow(clippy::type_complexity)] let row: Option<( - String, String, String, Vec, @@ -184,24 +179,179 @@ pub async fn get_dpop_session_by_token_hash( Option, )> = sqlx::query_as(&sql) .bind(api_client_id) - .bind(&hash) + .bind(dpop_key_id) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; - let ( + let (id, user_did, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) = + row.ok_or_else(|| AppError::Auth("no matching DPoP session".into()))?; + + let access_token = String::from_utf8( + decrypt(encryption_key, &access_enc) + .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?, + ) + .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?; + + let refresh_token = refresh_enc + .map(|enc| { + let bytes = decrypt(encryption_key, &enc) + .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?; + String::from_utf8(bytes) + .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}"))) + }) + .transpose()?; + + Ok(DpopSession { id, - dpop_key_id, + api_client_id: api_client_id.to_string(), + dpop_key_id: dpop_key_id.to_string(), user_did, - access_enc, - refresh_enc, + access_token, + refresh_token, token_expires_at, scopes, pds_url, issuer, - ) = row.ok_or_else(|| AppError::Auth("no matching DPoP session".into()))?; + }) +} + +/// Delete a DPoP session by api_client_id, user_did, and dpop_key_id (device-specific). +pub async fn delete_dpop_session( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + api_client_id: &str, + user_did: &str, + dpop_key_id: &str, +) -> Result { + let del_session_sql = adapt_sql( + "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ? AND dpop_key_id = ?", + backend, + ); + sqlx::query(&del_session_sql) + .bind(api_client_id) + .bind(user_did) + .bind(dpop_key_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?; + + let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + sqlx::query(&del_key_sql) + .bind(dpop_key_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?; + + Ok(dpop_key_id.to_string()) +} + +/// Delete all DPoP sessions for a user+client pair (e.g. on account unlink). +pub async fn delete_all_dpop_sessions( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + api_client_id: &str, + user_did: &str, +) -> Result<(), AppError> { + let key_ids_sql = adapt_sql( + "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + backend, + ); + let key_ids: Vec<(String,)> = sqlx::query_as(&key_ids_sql) + .bind(api_client_id) + .bind(user_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list DPoP sessions: {e}")))?; + + let del_sessions_sql = adapt_sql( + "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + backend, + ); + sqlx::query(&del_sessions_sql) + .bind(api_client_id) + .bind(user_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete DPoP sessions: {e}")))?; + + let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + for (key_id,) in key_ids { + let _ = sqlx::query(&del_key_sql).bind(&key_id).execute(pool).await; + } + + Ok(()) +} + +/// List all DPoP sessions for a user+client pair (metadata only, no decrypted tokens). +pub async fn list_dpop_sessions( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + api_client_id: &str, + user_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, dpop_key_id, scopes, created_at, updated_at FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + backend, + ); - let access_token_dec = String::from_utf8( + let rows: Vec<(String, String, String, String, String)> = sqlx::query_as(&sql) + .bind(api_client_id) + .bind(user_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list DPoP sessions: {e}")))?; + + Ok(rows + .into_iter() + .map( + |(id, dpop_key_id, scopes, created_at, updated_at)| DpopSessionInfo { + id, + dpop_key_id, + scopes, + created_at, + updated_at, + }, + ) + .collect()) +} + +/// Look up a DPoP session by api_client_id and user_did only (without dpop_key_id). +/// Used when the caller doesn't know the specific device key — delegation writes +/// and confidential client session lookups. Returns the first matching session. +pub async fn get_dpop_session_for_user( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + api_client_id: &str, + user_did: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id, dpop_key_id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND user_did = ? LIMIT 1", + backend, + ); + + #[allow(clippy::type_complexity)] + let row: Option<( + String, + String, + Vec, + Option>, + Option, + String, + Option, + Option, + )> = sqlx::query_as(&sql) + .bind(api_client_id) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; + + let (id, dpop_key_id, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) = + row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; + + let access_token = String::from_utf8( decrypt(encryption_key, &access_enc) .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?, ) @@ -220,8 +370,8 @@ pub async fn get_dpop_session_by_token_hash( id, api_client_id: api_client_id.to_string(), dpop_key_id, - user_did, - access_token: access_token_dec, + user_did: user_did.to_string(), + access_token, refresh_token, token_expires_at, scopes, @@ -230,20 +380,20 @@ pub async fn get_dpop_session_by_token_hash( }) } -/// Delete a DPoP session by api_client_id and user_did. -pub async fn delete_dpop_session( +/// Delete a specific DPoP session by its ID, verifying it belongs to the given client and user. +pub async fn delete_dpop_session_by_id( pool: &sqlx::AnyPool, backend: DatabaseBackend, + session_id: &str, api_client_id: &str, user_did: &str, ) -> Result { - // Look up the dpop_key_id before deleting so we can clean up the key too let lookup_sql = adapt_sql( - "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + "SELECT dpop_key_id FROM dpop_sessions WHERE id = ? AND api_client_id = ? AND user_did = ?", backend, ); - let row: Option<(String,)> = sqlx::query_as(&lookup_sql) + .bind(session_id) .bind(api_client_id) .bind(user_did) .fetch_optional(pool) @@ -252,13 +402,9 @@ pub async fn delete_dpop_session( let (dpop_key_id,) = row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; - let del_session_sql = adapt_sql( - "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", - backend, - ); + let del_session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE id = ?", backend); sqlx::query(&del_session_sql) - .bind(api_client_id) - .bind(user_did) + .bind(session_id) .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?; diff --git a/src/repo/pds.rs b/src/repo/pds.rs index db7c7bd..8bba3c7 100644 --- a/src/repo/pds.rs +++ b/src/repo/pds.rs @@ -19,6 +19,7 @@ pub(crate) enum PdsAuth { OAuth(Arc), Dpop { api_client_id: String, + dpop_key_id: String, encryption_key: [u8; 32], }, } @@ -35,6 +36,7 @@ impl PdsAuth { PdsAuth::OAuth(session) => pds_post_json_raw(state, session, xrpc_method, body).await, PdsAuth::Dpop { api_client_id, + dpop_key_id, encryption_key, } => { crate::oauth::pds_write::dpop_pds_post( @@ -46,6 +48,7 @@ impl PdsAuth { &state.config.plc_url, api_client_id, user_did, + dpop_key_id, xrpc_method, body, ) diff --git a/src/repo/upload_blob.rs b/src/repo/upload_blob.rs index 452d1fd..4af810f 100644 --- a/src/repo/upload_blob.rs +++ b/src/repo/upload_blob.rs @@ -55,6 +55,9 @@ pub async fn upload_blob( .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?; + let dpop_key_id = claims + .dpop_key_id() + .ok_or_else(|| AppError::Internal("DPoP key ID not available in claims".into()))?; let resp = crate::oauth::pds_write::dpop_pds_post_blob( &state.http, @@ -65,6 +68,7 @@ pub async fn upload_blob( &state.config.plc_url, &api_client_id, claims.did(), + dpop_key_id, content_type, body, ) diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index ab1d618..55d5523 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -359,39 +359,56 @@ async fn handle_dpop_procedure( ) -> Result { // If delegating, verify the caller has write access and resolve the // api_client_id that owns the delegated session. - let (target_did, effective_api_client_id) = if let Some(did) = delegate_did { - let role = crate::delegation::db::get_delegate_role( - &state.db, - state.db_backend, - did, - claims.did(), - ) - .await? - .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + let (target_did, effective_api_client_id, effective_dpop_key_id) = + if let Some(did) = delegate_did { + let role = crate::delegation::db::get_delegate_role( + &state.db, + state.db_backend, + did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; - if !role.can_write() { - return Err(AppError::Forbidden( - "your role does not have write access to this account".into(), - )); - } + if !role.can_write() { + return Err(AppError::Forbidden( + "your role does not have write access to this account".into(), + )); + } - let stored_client_id = - crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) - .await? - .ok_or_else(|| { - AppError::Internal("delegated account missing api_client_id".into()) - })?; - - if api_client_id != stored_client_id { - return Err(AppError::Forbidden( - "delegation is scoped to a different application".into(), - )); - } + let stored_client_id = + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) + .await? + .ok_or_else(|| { + AppError::Internal("delegated account missing api_client_id".into()) + })?; - (did, stored_client_id) - } else { - (claims.did(), api_client_id.to_string()) - }; + if api_client_id != stored_client_id { + return Err(AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + + // TODO: delegated_accounts needs a dpop_key_id column to identify + // which session to use for PDS writes. For now, look up by + // (api_client_id, user_did) which works when there's one session. + let target_session = crate::oauth::sessions::get_dpop_session_for_user( + &state.db, + state.db_backend, + encryption_key, + &stored_client_id, + did, + ) + .await?; + + (did, stored_client_id, target_session.dpop_key_id) + } else { + let dpop_key_id = claims + .dpop_key_id() + .ok_or_else(|| AppError::Internal("DPoP key ID not available in claims".into()))? + .to_string(); + (claims.did(), api_client_id.to_string(), dpop_key_id) + }; // Strip delegateDid from input — it's a control field, not record data let mut input = input.clone(); @@ -512,6 +529,7 @@ async fn handle_dpop_procedure( &state.config.plc_url, &effective_api_client_id, target_did, + &effective_dpop_key_id, xrpc_method, &pds_body, ) diff --git a/tests/dpop_auth.rs b/tests/dpop_auth.rs index 3eda9a6..98aa721 100644 --- a/tests/dpop_auth.rs +++ b/tests/dpop_auth.rs @@ -27,6 +27,18 @@ fn post_json_with_headers( .unwrap() } +/// Helper to make a GET request with headers +fn get_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder() + .method("GET") + .uri(uri) + .header("host", "127.0.0.1"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder.body(Body::empty()).unwrap() +} + /// Helper to make a DELETE request with headers fn delete_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request { let mut builder = Request::builder() @@ -256,7 +268,7 @@ async fn test_full_flow_provision_register_delete() { let delete_resp = app.router.clone().oneshot(delete_req).await.unwrap(); assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); - // 4. Verify session is gone (try to delete again) + // 4. Verify session is gone (delete is idempotent for confidential clients) let delete_req2 = delete_with_headers( "/oauth/sessions/did:plc:testuser", vec![ @@ -265,7 +277,7 @@ async fn test_full_flow_provision_register_delete() { ], ); let delete_resp2 = app.router.clone().oneshot(delete_req2).await.unwrap(); - assert_eq!(delete_resp2.status(), StatusCode::NOT_FOUND); + assert_eq!(delete_resp2.status(), StatusCode::NO_CONTENT); } #[tokio::test] @@ -411,3 +423,433 @@ async fn test_xrpc_dpop_auth_accepted() { "DPoP-authenticated XRPC request should not get 401" ); } + +/// Helper: provision a DPoP key and register a session. Returns (provision_id, dpop_key, session_id). +async fn provision_and_register( + app: &common::app::TestApp, + client_key: &str, + client_secret: &str, + did: &str, + access_token: &str, +) -> (String, serde_json::Value, String) { + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": did, + "access_token": access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + let session_body = response_json(session_resp).await; + let session_id = session_body["session_id"].as_str().unwrap().to_string(); + + (provision_id, dpop_key, session_id) +} + +#[tokio::test] +#[serial] +async fn test_multi_device_sessions_coexist() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + let did = "did:plc:multidevice"; + + let (_prov1, _key1, session_id_1) = + provision_and_register(&app, &client_key, &client_secret, did, "token-device-1").await; + let (_prov2, _key2, session_id_2) = + provision_and_register(&app, &client_key, &client_secret, did, "token-device-2").await; + + assert_ne!(session_id_1, session_id_2); + + // Both sessions should appear in the device list + let list_req = get_with_headers( + &format!("/oauth/sessions/{}/devices", did), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let list_resp = app.router.clone().oneshot(list_req).await.unwrap(); + assert_eq!(list_resp.status(), StatusCode::OK); + let devices: Vec = + serde_json::from_value(response_json(list_resp).await).unwrap(); + assert_eq!(devices.len(), 2); + + let ids: Vec<&str> = devices.iter().map(|d| d["id"].as_str().unwrap()).collect(); + assert!(ids.contains(&session_id_1.as_str())); + assert!(ids.contains(&session_id_2.as_str())); +} + +#[tokio::test] +#[serial] +async fn test_list_device_sessions_empty() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + let list_req = get_with_headers( + "/oauth/sessions/did:plc:nobody/devices", + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let list_resp = app.router.clone().oneshot(list_req).await.unwrap(); + assert_eq!(list_resp.status(), StatusCode::OK); + let devices: Vec = + serde_json::from_value(response_json(list_resp).await).unwrap(); + assert!(devices.is_empty()); +} + +#[tokio::test] +#[serial] +async fn test_delete_device_session_by_id() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + let did = "did:plc:deletedevice"; + + let (_prov1, _key1, session_id_1) = + provision_and_register(&app, &client_key, &client_secret, did, "token-a").await; + let (_prov2, _key2, session_id_2) = + provision_and_register(&app, &client_key, &client_secret, did, "token-b").await; + + // Delete session 1 + let del_req = delete_with_headers( + &format!("/oauth/sessions/{}/devices/{}", did, session_id_1), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let del_resp = app.router.clone().oneshot(del_req).await.unwrap(); + assert_eq!(del_resp.status(), StatusCode::NO_CONTENT); + + // Only session 2 should remain + let list_req = get_with_headers( + &format!("/oauth/sessions/{}/devices", did), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let list_resp = app.router.clone().oneshot(list_req).await.unwrap(); + let devices: Vec = + serde_json::from_value(response_json(list_resp).await).unwrap(); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0]["id"].as_str().unwrap(), session_id_2); +} + +#[tokio::test] +#[serial] +async fn test_delete_device_session_not_found() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + let del_req = delete_with_headers( + "/oauth/sessions/did:plc:nobody/devices/nonexistent-id", + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let del_resp = app.router.clone().oneshot(del_req).await.unwrap(); + assert_eq!(del_resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn test_session_upsert_same_device() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + let did = "did:plc:upsertuser"; + + // Provision one key + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + + // Register session with token-v1 + let reg1 = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": did, + "access_token": "token-v1", + "scopes": "atproto", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let resp1 = app.router.clone().oneshot(reg1).await.unwrap(); + assert_eq!(resp1.status(), StatusCode::CREATED); + + // Re-register with same provision_id (same device key) but new token + let reg2 = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": did, + "access_token": "token-v2", + "scopes": "atproto", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let resp2 = app.router.clone().oneshot(reg2).await.unwrap(); + assert_eq!(resp2.status(), StatusCode::CREATED); + + // Should still be exactly one device session (upsert, not duplicate) + let list_req = get_with_headers( + &format!("/oauth/sessions/{}/devices", did), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let list_resp = app.router.clone().oneshot(list_req).await.unwrap(); + let devices: Vec = + serde_json::from_value(response_json(list_resp).await).unwrap(); + assert_eq!(devices.len(), 1); +} + +#[tokio::test] +#[serial] +async fn test_get_session_with_confidential_client() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + let did = "did:plc:getsession"; + + provision_and_register(&app, &client_key, &client_secret, did, "some-token").await; + + let get_req = get_with_headers( + &format!("/oauth/sessions/{}", did), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let get_resp = app.router.clone().oneshot(get_req).await.unwrap(); + assert_eq!(get_resp.status(), StatusCode::OK); + let body = response_json(get_resp).await; + assert_eq!(body["did"], did); + assert!(body["scopes"].is_array()); +} + +#[tokio::test] +#[serial] +async fn test_device_list_response_format() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + let did = "did:plc:formatcheck"; + + provision_and_register(&app, &client_key, &client_secret, did, "token-fmt").await; + + let list_req = get_with_headers( + &format!("/oauth/sessions/{}/devices", did), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let list_resp = app.router.clone().oneshot(list_req).await.unwrap(); + assert_eq!(list_resp.status(), StatusCode::OK); + let devices: Vec = + serde_json::from_value(response_json(list_resp).await).unwrap(); + assert_eq!(devices.len(), 1); + + let device = &devices[0]; + assert!(device["id"].is_string()); + assert!(device["dpop_key_id"].is_string()); + assert!(device["scopes"].is_array()); + assert!(device["created_at"].is_string()); + assert!(device["updated_at"].is_string()); +} + +#[tokio::test] +#[serial] +async fn test_public_client_dpop_get_session() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, _secret, _id) = app + .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + let verifier = "test-verifier-for-public-client-session"; + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + + // Provision key with PKCE + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({ "pkce_challenge": challenge }), + vec![ + ("x-client-key", &client_key), + ("origin", "http://localhost:3000"), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + let dpop_key = &key_body["dpop_key"]; + + let did = "did:plc:publicuser"; + let access_token = "public-client-access-token"; + + // Register session with PKCE verifier + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "pkce_verifier": verifier, + "did": did, + "access_token": access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![("x-client-key", &client_key)], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + // GET session with DPoP proof + let request_url = format!("http://127.0.0.1/oauth/sessions/{}", did); + let proof = generate_dpop_proof(dpop_key, "GET", &request_url, access_token, None) + .expect("failed to generate DPoP proof"); + + let get_req = get_with_headers( + &format!("/oauth/sessions/{}", did), + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &proof), + ], + ); + let get_resp = app.router.clone().oneshot(get_req).await.unwrap(); + assert_eq!(get_resp.status(), StatusCode::OK); + let body = response_json(get_resp).await; + assert_eq!(body["did"], did); +} + +#[tokio::test] +#[serial] +async fn test_public_client_dpop_delete_session() { + common::require_db!(); + let app = common::app::TestApp::new_with_encryption().await; + let (client_key, _secret, _id) = app + .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use sha2::{Digest, Sha256}; + + let verifier = "test-verifier-for-public-delete"; + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({ "pkce_challenge": challenge }), + vec![ + ("x-client-key", &client_key), + ("origin", "http://localhost:3000"), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap(); + let dpop_key = &key_body["dpop_key"]; + + let did = "did:plc:publicdelete"; + let access_token = "public-delete-token"; + + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "pkce_verifier": verifier, + "did": did, + "access_token": access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![("x-client-key", &client_key)], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + // DELETE session with DPoP proof + let request_url = format!("http://127.0.0.1/oauth/sessions/{}", did); + let proof = generate_dpop_proof(dpop_key, "DELETE", &request_url, access_token, None) + .expect("failed to generate DPoP proof"); + + let del_req = delete_with_headers( + &format!("/oauth/sessions/{}", did), + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &proof), + ], + ); + let del_resp = app.router.clone().oneshot(del_req).await.unwrap(); + assert_eq!(del_resp.status(), StatusCode::NO_CONTENT); + + // Verify session is gone — GET should fail + let request_url2 = format!("http://127.0.0.1/oauth/sessions/{}", did); + let proof2 = generate_dpop_proof(dpop_key, "GET", &request_url2, access_token, None) + .expect("failed to generate DPoP proof"); + + let get_req = get_with_headers( + &format!("/oauth/sessions/{}", did), + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &proof2), + ], + ); + let get_resp = app.router.clone().oneshot(get_req).await.unwrap(); + assert_ne!(get_resp.status(), StatusCode::OK); +} -- 2.51.2