···100100101101## Proxying procedures to the user's PDS
102102103103-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.
103103+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:
104104+105105+- **Cookie auth (dashboard)** — `atrium-oauth` attaches a DPoP proof and a DPoP-bound access token to the outbound request automatically.
106106+- **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).
107107+108108+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.
109109+110110+## DPoP key provisioning for third-party apps
111111+112112+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.
113113+114114+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.
115115+116116+### API clients: confidential vs public
117117+118118+API clients have a `client_type` field — either `confidential` (default) or `public`.
119119+120120+- **Confidential clients** authenticate with `X-Client-Key` + `X-Client-Secret` headers on every `/oauth/*` request.
121121+- **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.
122122+123123+### The full flow
124124+125125+#### 1. Provision a DPoP key
126126+127127+```
128128+POST /oauth/dpop-keys
129129+X-Client-Key: hvc_...
130130+X-Client-Secret: hvs_...
131131+Content-Type: application/json
132132+133133+{}
134134+```
135135+136136+For public clients, omit `X-Client-Secret` and include the PKCE challenge in the body:
137137+138138+```
139139+POST /oauth/dpop-keys
140140+X-Client-Key: hvc_...
141141+Origin: http://localhost:3000
142142+Content-Type: application/json
143143+144144+{ "pkce_challenge": "base64url..." }
145145+```
146146+147147+Response:
148148+149149+```json
150150+{
151151+ "provision_id": "hvp_...",
152152+ "dpop_key": { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "d": "..." }
153153+}
154154+```
155155+156156+The `dpop_key` is the private JWK. Use it to generate DPoP proofs during your OAuth flow with the user's PDS.
157157+158158+#### 2. Run OAuth with the user's PDS
159159+160160+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.
161161+162162+#### 3. Register the session
163163+164164+After the OAuth callback, register the token set with HappyView:
165165+166166+```
167167+POST /oauth/sessions
168168+X-Client-Key: hvc_...
169169+X-Client-Secret: hvs_...
170170+Content-Type: application/json
171171+172172+{
173173+ "provision_id": "hvp_...",
174174+ "did": "did:plc:user123",
175175+ "access_token": "...",
176176+ "refresh_token": "...",
177177+ "expires_at": "2026-04-17T00:00:00Z",
178178+ "scopes": "atproto transition:generic",
179179+ "pds_url": "https://bsky.social",
180180+ "issuer": "https://bsky.social"
181181+}
182182+```
183183+184184+For public clients, omit `X-Client-Secret` and include the PKCE verifier in the body:
185185+186186+```json
187187+{
188188+ "provision_id": "hvp_...",
189189+ "pkce_verifier": "...",
190190+ "did": "did:plc:user123",
191191+ ...
192192+}
193193+```
194194+195195+Response:
196196+197197+```json
198198+{
199199+ "session_id": "uuid",
200200+ "did": "did:plc:user123"
201201+}
202202+```
203203+204204+#### 4. Make XRPC requests
205205+206206+With a registered session, send XRPC requests using DPoP auth:
207207+208208+```sh
209209+curl -X POST 'https://happyview.example.com/xrpc/com.example.feed.createPost' \
210210+ -H 'X-Client-Key: hvc_...' \
211211+ -H 'Authorization: DPoP <access_token>' \
212212+ -H 'DPoP: <proof_jwt>' \
213213+ -H 'Content-Type: application/json' \
214214+ -d '{"text": "Hello world"}'
215215+```
216216+217217+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.
218218+219219+#### 5. Logout
220220+221221+Confidential clients authenticate with `X-Client-Key` + `X-Client-Secret`:
222222+223223+```
224224+DELETE /oauth/sessions/did:plc:user123
225225+X-Client-Key: hvc_...
226226+X-Client-Secret: hvs_...
227227+```
228228+229229+Public clients must provide a valid DPoP proof to prove they hold the key:
230230+231231+```
232232+DELETE /oauth/sessions/did:plc:user123
233233+X-Client-Key: hvc_...
234234+Authorization: DPoP <access_token>
235235+DPoP: <proof_jwt>
236236+```
237237+238238+This deletes the stored session and the associated DPoP key.
104239105105-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.
240240+### Security notes
241241+242242+- Private keys and tokens are encrypted at rest with AES-256-GCM using `TOKEN_ENCRYPTION_KEY`.
243243+- DPoP proofs are validated for method, URL, timestamp (5-minute window), access token binding, and JWK thumbprint.
244244+- Scopes requested must include `atproto` and must be a subset of the API client's registered scopes.
106245107246## Next steps
108247
···11+CREATE TABLE IF NOT EXISTS dpop_keys (
22+ id TEXT PRIMARY KEY,
33+ provision_id TEXT NOT NULL UNIQUE,
44+ api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE,
55+ private_key_enc BYTEA NOT NULL,
66+ jwk_thumbprint TEXT NOT NULL,
77+ pkce_challenge TEXT,
88+ created_at TEXT NOT NULL
99+);
1010+1111+CREATE INDEX idx_dpop_keys_api_client_id ON dpop_keys(api_client_id);
1212+CREATE INDEX idx_dpop_keys_provision_id ON dpop_keys(provision_id);
···11+CREATE TABLE IF NOT EXISTS dpop_sessions (
22+ id TEXT PRIMARY KEY,
33+ api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE,
44+ dpop_key_id TEXT NOT NULL REFERENCES dpop_keys(id) ON DELETE CASCADE,
55+ user_did TEXT NOT NULL,
66+ access_token_enc BYTEA NOT NULL,
77+ refresh_token_enc BYTEA,
88+ token_expires_at TEXT,
99+ scopes TEXT NOT NULL,
1010+ pds_url TEXT,
1111+ issuer TEXT,
1212+ created_at TEXT NOT NULL,
1313+ updated_at TEXT NOT NULL
1414+);
1515+1616+CREATE UNIQUE INDEX idx_dpop_sessions_client_user ON dpop_sessions(api_client_id, user_did);
1717+CREATE INDEX idx_dpop_sessions_dpop_key_id ON dpop_sessions(dpop_key_id);
···11+ALTER TABLE dpop_sessions ADD COLUMN access_token_hash TEXT;
22+CREATE INDEX IF NOT EXISTS idx_dpop_sessions_token_hash ON dpop_sessions (api_client_id, access_token_hash);
···11+CREATE TABLE IF NOT EXISTS dpop_keys (
22+ id TEXT PRIMARY KEY,
33+ provision_id TEXT NOT NULL UNIQUE,
44+ api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE,
55+ private_key_enc BLOB NOT NULL,
66+ jwk_thumbprint TEXT NOT NULL,
77+ pkce_challenge TEXT,
88+ created_at TEXT NOT NULL
99+);
1010+1111+CREATE INDEX idx_dpop_keys_api_client_id ON dpop_keys(api_client_id);
1212+CREATE INDEX idx_dpop_keys_provision_id ON dpop_keys(provision_id);
···11+CREATE TABLE IF NOT EXISTS dpop_sessions (
22+ id TEXT PRIMARY KEY,
33+ api_client_id TEXT NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE,
44+ dpop_key_id TEXT NOT NULL REFERENCES dpop_keys(id) ON DELETE CASCADE,
55+ user_did TEXT NOT NULL,
66+ access_token_enc BLOB NOT NULL,
77+ refresh_token_enc BLOB,
88+ token_expires_at TEXT,
99+ scopes TEXT NOT NULL,
1010+ pds_url TEXT,
1111+ issuer TEXT,
1212+ created_at TEXT NOT NULL,
1313+ updated_at TEXT NOT NULL
1414+);
1515+1616+CREATE UNIQUE INDEX idx_dpop_sessions_client_user ON dpop_sessions(api_client_id, user_did);
1717+CREATE INDEX idx_dpop_sessions_dpop_key_id ON dpop_sessions(dpop_key_id);
···11+ALTER TABLE dpop_sessions ADD COLUMN access_token_hash TEXT;
22+CREATE INDEX IF NOT EXISTS idx_dpop_sessions_token_hash ON dpop_sessions (api_client_id, access_token_hash);
···101101 });
102102 }
103103104104+ if let Some(token) = header.strip_prefix("DPoP ") {
105105+ return resolve_dpop_claims(state, parts, token).await;
106106+ }
107107+104108 Err(AppError::Auth("invalid Authorization scheme".into()))
105109 }
106110}
···124128 row.map(|(did,)| did)
125129 .ok_or_else(|| AppError::Auth("invalid API key".into()))
126130}
131131+132132+/// Resolve claims from a DPoP-authenticated request.
133133+///
134134+/// Expects:
135135+/// - `Authorization: DPoP <access_token>`
136136+/// - `DPoP: <proof_jwt>` header
137137+/// - `X-Client-Key: <client_key>` header
138138+pub async fn resolve_dpop_claims(
139139+ state: &AppState,
140140+ parts: &Parts,
141141+ access_token: &str,
142142+) -> Result<Claims, AppError> {
143143+ let client_key = parts
144144+ .headers
145145+ .get("x-client-key")
146146+ .and_then(|v| v.to_str().ok())
147147+ .ok_or_else(|| AppError::Auth("DPoP auth requires X-Client-Key header".into()))?;
148148+149149+ let dpop_proof = parts
150150+ .headers
151151+ .get("dpop")
152152+ .and_then(|v| v.to_str().ok())
153153+ .ok_or_else(|| AppError::Auth("DPoP auth requires DPoP header".into()))?;
154154+155155+ let encryption_key = state
156156+ .config
157157+ .token_encryption_key
158158+ .as_ref()
159159+ .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?;
160160+161161+ // Resolve the API client
162162+ let client =
163163+ crate::oauth::client_auth::resolve_client_by_key(&state.db, state.db_backend, client_key)
164164+ .await?;
165165+166166+ // Look up the session by token
167167+ let session = crate::oauth::sessions::get_dpop_session_by_token_hash(
168168+ &state.db,
169169+ state.db_backend,
170170+ encryption_key,
171171+ &client.id,
172172+ access_token,
173173+ )
174174+ .await?;
175175+176176+ // Check token expiry
177177+ if let Some(ref expires_at) = session.token_expires_at
178178+ && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at)
179179+ && exp < chrono::Utc::now()
180180+ {
181181+ return Err(AppError::Auth("token_expired".into()));
182182+ }
183183+184184+ // Get the DPoP key thumbprint for proof validation
185185+ let thumbprint = crate::oauth::keys::get_dpop_key_thumbprint(
186186+ &state.db,
187187+ state.db_backend,
188188+ &session.dpop_key_id,
189189+ )
190190+ .await?;
191191+192192+ // Build the request URL for htu validation
193193+ let scheme = if state.config.public_url.starts_with("https") {
194194+ "https"
195195+ } else {
196196+ "http"
197197+ };
198198+ let host = parts
199199+ .headers
200200+ .get("host")
201201+ .and_then(|v| v.to_str().ok())
202202+ .unwrap_or("localhost");
203203+ let request_url = format!("{}://{}{}", scheme, host, parts.uri.path());
204204+ let method = parts.method.as_str();
205205+206206+ // Validate the DPoP proof
207207+ crate::oauth::dpop_proof::validate_dpop_proof(
208208+ dpop_proof,
209209+ method,
210210+ &request_url,
211211+ access_token,
212212+ &thumbprint,
213213+ )?;
214214+215215+ Ok(Claims {
216216+ did: session.user_did,
217217+ client_key: Some(client_key.to_string()),
218218+ })
219219+}
220220+221221+/// XRPC-specific claims extractor.
222222+///
223223+/// Only accepts DPoP auth (`Authorization: DPoP <token>` + `DPoP` proof + `X-Client-Key`).
224224+/// Cookie auth, Bearer API keys, and service JWTs are rejected on XRPC routes.
225225+/// Wraps `Option<Claims>` — `None` means anonymous (client-key-only) access.
226226+#[derive(Debug, Clone)]
227227+pub struct XrpcClaims(pub Option<Claims>);
228228+229229+impl FromRequestParts<AppState> for XrpcClaims {
230230+ type Rejection = AppError;
231231+232232+ async fn from_request_parts(
233233+ parts: &mut Parts,
234234+ state: &AppState,
235235+ ) -> Result<Self, Self::Rejection> {
236236+ let header = parts
237237+ .headers
238238+ .get("authorization")
239239+ .and_then(|v| v.to_str().ok());
240240+241241+ match header {
242242+ Some(h) if h.starts_with("DPoP ") => {
243243+ let token = &h[5..];
244244+ let claims = resolve_dpop_claims(state, parts, token).await?;
245245+ Ok(XrpcClaims(Some(claims)))
246246+ }
247247+ Some(h) if h.starts_with("Bearer ") => {
248248+ Err(AppError::Auth(
249249+ "XRPC routes do not accept Bearer auth. Use DPoP auth or omit the Authorization header for anonymous access.".into(),
250250+ ))
251251+ }
252252+ Some(_) => {
253253+ Err(AppError::Auth("invalid Authorization scheme".into()))
254254+ }
255255+ None => {
256256+ // No auth header — anonymous access (client-key only)
257257+ Ok(XrpcClaims(None))
258258+ }
259259+ }
260260+ }
261261+}
+1
src/auth/mod.rs
···6677pub use client_registry::OAuthClientRegistry;
88pub use middleware::Claims;
99+pub use middleware::XrpcClaims;
910pub use routes::parse_scope_string;
1011pub use service_auth::ServiceAuth;
1112
+1
src/lib.rs
···1212pub mod labeler;
1313pub mod lexicon;
1414pub mod lua;
1515+pub mod oauth;
1516pub mod plugin;
1617pub mod profile;
1718pub mod rate_limit;
+252
src/oauth/client_auth.rs
···11+use sha2::{Digest, Sha256};
22+33+use crate::db::{DatabaseBackend, adapt_sql};
44+use crate::error::AppError;
55+66+/// Resolved API client identity for DPoP operations.
77+pub struct ResolvedClient {
88+ pub id: String,
99+ pub client_key: String,
1010+ pub client_type: String,
1111+ pub scopes: String,
1212+ pub allowed_origins: Option<Vec<String>>,
1313+}
1414+1515+/// Authenticate a confidential client using client_key + client_secret.
1616+pub async fn authenticate_confidential(
1717+ pool: &sqlx::AnyPool,
1818+ backend: DatabaseBackend,
1919+ client_key: &str,
2020+ client_secret: &str,
2121+) -> Result<ResolvedClient, AppError> {
2222+ let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes()));
2323+2424+ let sql = adapt_sql(
2525+ "SELECT id, client_key, client_type, scopes, allowed_origins, client_secret_hash FROM api_clients WHERE client_key = ? AND is_active = 1",
2626+ backend,
2727+ );
2828+2929+ let row: Option<(String, String, String, String, Option<String>, String)> =
3030+ sqlx::query_as(&sql)
3131+ .bind(client_key)
3232+ .fetch_optional(pool)
3333+ .await
3434+ .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?;
3535+3636+ let (id, key, client_type, scopes, origins_json, stored_hash) =
3737+ row.ok_or_else(|| AppError::Auth("invalid client credentials".into()))?;
3838+3939+ if stored_hash != secret_hash {
4040+ return Err(AppError::Auth("invalid client credentials".into()));
4141+ }
4242+4343+ if client_type != "confidential" {
4444+ return Err(AppError::Auth(
4545+ "this endpoint requires confidential client authentication".into(),
4646+ ));
4747+ }
4848+4949+ let allowed_origins =
5050+ origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default());
5151+5252+ Ok(ResolvedClient {
5353+ id,
5454+ client_key: key,
5555+ client_type,
5656+ scopes,
5757+ allowed_origins,
5858+ })
5959+}
6060+6161+/// Authenticate a public client using client_key + origin validation.
6262+/// Returns the client but does NOT verify PKCE — that's done at session registration.
6363+pub async fn authenticate_public(
6464+ pool: &sqlx::AnyPool,
6565+ backend: DatabaseBackend,
6666+ client_key: &str,
6767+ origin: Option<&str>,
6868+) -> Result<ResolvedClient, AppError> {
6969+ let sql = adapt_sql(
7070+ "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1",
7171+ backend,
7272+ );
7373+7474+ let row: Option<(String, String, String, String, Option<String>)> = sqlx::query_as(&sql)
7575+ .bind(client_key)
7676+ .fetch_optional(pool)
7777+ .await
7878+ .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?;
7979+8080+ let (id, key, client_type, scopes, origins_json) =
8181+ row.ok_or_else(|| AppError::Auth("unknown client".into()))?;
8282+8383+ if client_type != "public" {
8484+ return Err(AppError::Auth(
8585+ "this client is not registered as a public client".into(),
8686+ ));
8787+ }
8888+8989+ // Validate origin if the client has allowed_origins configured
9090+ if let Some(ref origins_str) = origins_json {
9191+ let allowed: Vec<String> = serde_json::from_str(origins_str).unwrap_or_default();
9292+ if !allowed.is_empty() {
9393+ match origin {
9494+ Some(o) if allowed.iter().any(|a| a == o) => {}
9595+ Some(o) => {
9696+ tracing::warn!(client_key, origin = o, "Origin mismatch for public client");
9797+ return Err(AppError::Auth("origin not allowed for this client".into()));
9898+ }
9999+ None => {
100100+ tracing::warn!(client_key, "No Origin header for public client");
101101+ return Err(AppError::Auth(
102102+ "Origin header required for public clients".into(),
103103+ ));
104104+ }
105105+ }
106106+ }
107107+ }
108108+109109+ let allowed_origins =
110110+ origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default());
111111+112112+ Ok(ResolvedClient {
113113+ id,
114114+ client_key: key,
115115+ client_type,
116116+ scopes,
117117+ allowed_origins,
118118+ })
119119+}
120120+121121+/// Resolve an API client by client_key only (no secret verification).
122122+/// Used when the caller has already been authenticated by other means (e.g. DPoP proof).
123123+pub async fn resolve_client_by_key(
124124+ pool: &sqlx::AnyPool,
125125+ backend: DatabaseBackend,
126126+ client_key: &str,
127127+) -> Result<ResolvedClient, AppError> {
128128+ let sql = adapt_sql(
129129+ "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1",
130130+ backend,
131131+ );
132132+133133+ let row: Option<(String, String, String, String, Option<String>)> = sqlx::query_as(&sql)
134134+ .bind(client_key)
135135+ .fetch_optional(pool)
136136+ .await
137137+ .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?;
138138+139139+ let (id, key, client_type, scopes, origins_json) =
140140+ row.ok_or_else(|| AppError::Auth("unknown client".into()))?;
141141+142142+ let allowed_origins =
143143+ origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default());
144144+145145+ Ok(ResolvedClient {
146146+ id,
147147+ client_key: key,
148148+ client_type,
149149+ scopes,
150150+ allowed_origins,
151151+ })
152152+}
153153+154154+/// Validate that token scopes are allowed by the client's registered scopes.
155155+///
156156+/// Rules:
157157+/// - `atproto` must be present in token scopes (always implicitly allowed)
158158+/// - Every non-`atproto` scope in the token must appear in the client's registered scopes
159159+pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), AppError> {
160160+ let token_set: std::collections::HashSet<&str> = token_scopes.split_whitespace().collect();
161161+ let client_set: std::collections::HashSet<&str> = client_scopes.split_whitespace().collect();
162162+163163+ if !token_set.contains("atproto") {
164164+ return Err(AppError::BadRequest(
165165+ "token must include the 'atproto' scope".into(),
166166+ ));
167167+ }
168168+169169+ for scope in &token_set {
170170+ if *scope == "atproto" {
171171+ continue; // always allowed
172172+ }
173173+ if !client_set.contains(scope) {
174174+ return Err(AppError::BadRequest(format!(
175175+ "scope '{}' is not allowed for this client",
176176+ scope
177177+ )));
178178+ }
179179+ }
180180+181181+ Ok(())
182182+}
183183+184184+/// Verify a PKCE challenge against a verifier.
185185+pub fn verify_pkce(challenge: &str, verifier: &str) -> bool {
186186+ use base64::Engine;
187187+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
188188+ let hash = Sha256::digest(verifier.as_bytes());
189189+ let computed = URL_SAFE_NO_PAD.encode(hash);
190190+ computed == challenge
191191+}
192192+193193+#[cfg(test)]
194194+mod tests {
195195+ use super::*;
196196+197197+ #[test]
198198+ fn validate_scopes_requires_atproto() {
199199+ let result = validate_scopes("transition:generic", "atproto transition:generic");
200200+ assert!(result.is_err());
201201+ }
202202+203203+ #[test]
204204+ fn validate_scopes_atproto_only_always_passes() {
205205+ let result = validate_scopes("atproto", "com.example.whatever");
206206+ assert!(result.is_ok());
207207+ }
208208+209209+ #[test]
210210+ fn validate_scopes_subset_passes() {
211211+ let result = validate_scopes(
212212+ "atproto com.example.basic",
213213+ "atproto com.example.basic com.example.advanced",
214214+ );
215215+ assert!(result.is_ok());
216216+ }
217217+218218+ #[test]
219219+ fn validate_scopes_excess_scope_fails() {
220220+ let result = validate_scopes(
221221+ "atproto com.example.basic com.example.advanced",
222222+ "atproto com.example.basic",
223223+ );
224224+ assert!(result.is_err());
225225+ }
226226+227227+ #[test]
228228+ fn validate_scopes_transition_generic_requires_registration() {
229229+ let result = validate_scopes("atproto transition:generic", "atproto");
230230+ assert!(result.is_err());
231231+232232+ let result = validate_scopes("atproto transition:generic", "atproto transition:generic");
233233+ assert!(result.is_ok());
234234+ }
235235+236236+ #[test]
237237+ fn verify_pkce_valid() {
238238+ use base64::Engine;
239239+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
240240+241241+ let verifier = "test-verifier-string-12345678901234567890";
242242+ let hash = sha2::Sha256::digest(verifier.as_bytes());
243243+ let challenge = URL_SAFE_NO_PAD.encode(hash);
244244+245245+ assert!(verify_pkce(&challenge, verifier));
246246+ }
247247+248248+ #[test]
249249+ fn verify_pkce_invalid() {
250250+ assert!(!verify_pkce("wrong-challenge", "some-verifier"));
251251+ }
252252+}
+219
src/oauth/dpop_proof.rs
···11+use base64::Engine;
22+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33+use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
44+use serde::Deserialize;
55+use sha2::{Digest, Sha256};
66+77+use crate::error::AppError;
88+99+#[derive(Debug, Deserialize)]
1010+struct DpopHeader {
1111+ alg: String,
1212+ typ: String,
1313+ jwk: serde_json::Value,
1414+}
1515+1616+#[derive(Debug, Deserialize)]
1717+#[allow(dead_code)]
1818+struct DpopPayload {
1919+ htm: String,
2020+ htu: String,
2121+ iat: u64,
2222+ ath: Option<String>,
2323+ jti: String,
2424+}
2525+2626+/// Validate a DPoP proof JWT.
2727+///
2828+/// Checks:
2929+/// - `typ` is `dpop+jwt`
3030+/// - `alg` is `ES256`
3131+/// - `htm` matches the request method
3232+/// - `htu` matches the request URL (scheme + host + path, no query/fragment)
3333+/// - `iat` is within 5 minutes of now
3434+/// - `ath` matches SHA256(access_token) if provided
3535+/// - Signature is valid against the embedded JWK
3636+/// - JWK thumbprint matches the expected thumbprint
3737+pub fn validate_dpop_proof(
3838+ proof_jwt: &str,
3939+ expected_method: &str,
4040+ expected_url: &str,
4141+ access_token: &str,
4242+ expected_thumbprint: &str,
4343+) -> Result<(), AppError> {
4444+ let parts: Vec<&str> = proof_jwt.split('.').collect();
4545+ if parts.len() != 3 {
4646+ return Err(AppError::Auth("invalid DPoP proof format".into()));
4747+ }
4848+4949+ // Decode header
5050+ let header_bytes = URL_SAFE_NO_PAD
5151+ .decode(parts[0])
5252+ .map_err(|_| AppError::Auth("invalid DPoP proof header encoding".into()))?;
5353+ let header: DpopHeader = serde_json::from_slice(&header_bytes)
5454+ .map_err(|_| AppError::Auth("invalid DPoP proof header".into()))?;
5555+5656+ // Check typ and alg
5757+ if header.typ != "dpop+jwt" {
5858+ return Err(AppError::Auth("DPoP proof typ must be dpop+jwt".into()));
5959+ }
6060+ if header.alg != "ES256" {
6161+ return Err(AppError::Auth("DPoP proof alg must be ES256".into()));
6262+ }
6363+6464+ // Decode payload
6565+ let payload_bytes = URL_SAFE_NO_PAD
6666+ .decode(parts[1])
6767+ .map_err(|_| AppError::Auth("invalid DPoP proof payload encoding".into()))?;
6868+ let payload: DpopPayload = serde_json::from_slice(&payload_bytes)
6969+ .map_err(|_| AppError::Auth("invalid DPoP proof payload".into()))?;
7070+7171+ // Check htm
7272+ if !payload.htm.eq_ignore_ascii_case(expected_method) {
7373+ return Err(AppError::Auth("DPoP proof htm mismatch".into()));
7474+ }
7575+7676+ // Check htu (strip query and fragment from expected URL for comparison)
7777+ let expected_htu = strip_query_fragment(expected_url);
7878+ if payload.htu != expected_htu {
7979+ return Err(AppError::Auth("DPoP proof htu mismatch".into()));
8080+ }
8181+8282+ // Check iat (within 5 minutes)
8383+ let now = std::time::SystemTime::now()
8484+ .duration_since(std::time::UNIX_EPOCH)
8585+ .unwrap()
8686+ .as_secs();
8787+ if now.abs_diff(payload.iat) > 300 {
8888+ return Err(AppError::Auth(
8989+ "DPoP proof expired or too far in the future".into(),
9090+ ));
9191+ }
9292+9393+ // Check ath (access token hash) — required per RFC 9449 section 4.2
9494+ let expected_ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes()));
9595+ let ath = payload
9696+ .ath
9797+ .as_ref()
9898+ .ok_or_else(|| AppError::Auth("DPoP proof missing required ath claim".into()))?;
9999+ if *ath != expected_ath {
100100+ return Err(AppError::Auth("DPoP proof ath mismatch".into()));
101101+ }
102102+103103+ // Verify JWK thumbprint matches expected
104104+ let proof_thumbprint = super::keys::compute_jwk_thumbprint(&header.jwk)?;
105105+ if proof_thumbprint != expected_thumbprint {
106106+ return Err(AppError::Auth(
107107+ "DPoP proof key does not match session".into(),
108108+ ));
109109+ }
110110+111111+ // Verify signature
112112+ let message = format!("{}.{}", parts[0], parts[1]);
113113+ let sig_bytes = URL_SAFE_NO_PAD
114114+ .decode(parts[2])
115115+ .map_err(|_| AppError::Auth("invalid DPoP proof signature encoding".into()))?;
116116+117117+ verify_es256_jwk(&message, &sig_bytes, &header.jwk)?;
118118+119119+ Ok(())
120120+}
121121+122122+/// Verify an ES256 signature using a JWK public key.
123123+fn verify_es256_jwk(
124124+ message: &str,
125125+ sig_bytes: &[u8],
126126+ jwk: &serde_json::Value,
127127+) -> Result<(), AppError> {
128128+ let x_b64 = jwk["x"]
129129+ .as_str()
130130+ .ok_or_else(|| AppError::Auth("DPoP JWK missing x".into()))?;
131131+ let y_b64 = jwk["y"]
132132+ .as_str()
133133+ .ok_or_else(|| AppError::Auth("DPoP JWK missing y".into()))?;
134134+135135+ let x_bytes = URL_SAFE_NO_PAD
136136+ .decode(x_b64)
137137+ .map_err(|_| AppError::Auth("invalid DPoP JWK x".into()))?;
138138+ let y_bytes = URL_SAFE_NO_PAD
139139+ .decode(y_b64)
140140+ .map_err(|_| AppError::Auth("invalid DPoP JWK y".into()))?;
141141+142142+ // Build SEC1 uncompressed point: 0x04 || x || y
143143+ let mut sec1 = Vec::with_capacity(1 + 32 + 32);
144144+ sec1.push(0x04);
145145+ sec1.extend_from_slice(&x_bytes);
146146+ sec1.extend_from_slice(&y_bytes);
147147+148148+ let verifying_key = VerifyingKey::from_sec1_bytes(&sec1)
149149+ .map_err(|_| AppError::Auth("invalid DPoP public key".into()))?;
150150+151151+ let signature = Signature::from_bytes(sig_bytes.into())
152152+ .map_err(|_| AppError::Auth("invalid DPoP signature format".into()))?;
153153+154154+ verifying_key
155155+ .verify(message.as_bytes(), &signature)
156156+ .map_err(|_| AppError::Auth("DPoP proof signature verification failed".into()))?;
157157+158158+ Ok(())
159159+}
160160+161161+/// Strip query string and fragment from a URL (per RFC 9449 section 4.2).
162162+fn strip_query_fragment(url: &str) -> &str {
163163+ let end = url
164164+ .find('#')
165165+ .unwrap_or(url.len())
166166+ .min(url.find('?').unwrap_or(url.len()));
167167+ &url[..end]
168168+}
169169+170170+#[cfg(test)]
171171+mod tests {
172172+ use super::*;
173173+174174+ #[test]
175175+ fn strip_query_fragment_works() {
176176+ assert_eq!(
177177+ strip_query_fragment("https://example.com/path"),
178178+ "https://example.com/path"
179179+ );
180180+ assert_eq!(
181181+ strip_query_fragment("https://example.com/path?query=1"),
182182+ "https://example.com/path"
183183+ );
184184+ assert_eq!(
185185+ strip_query_fragment("https://example.com/path#frag"),
186186+ "https://example.com/path"
187187+ );
188188+ assert_eq!(
189189+ strip_query_fragment("https://example.com/path?q=1#f"),
190190+ "https://example.com/path"
191191+ );
192192+ }
193193+194194+ #[test]
195195+ fn rejects_invalid_format() {
196196+ let result = validate_dpop_proof(
197197+ "not.a.valid.jwt.too-many",
198198+ "GET",
199199+ "https://example.com",
200200+ "token",
201201+ "thumb",
202202+ );
203203+ assert!(result.is_err());
204204+ }
205205+206206+ #[test]
207207+ fn rejects_non_dpop_typ() {
208208+ // Build a JWT with typ: "JWT" instead of "dpop+jwt"
209209+ let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256","typ":"JWT","jwk":{}}"#);
210210+ let payload = URL_SAFE_NO_PAD
211211+ .encode(r#"{"htm":"GET","htu":"https://example.com","iat":0,"jti":"x"}"#);
212212+ let fake_sig = URL_SAFE_NO_PAD.encode(b"fakesig");
213213+ let jwt = format!("{}.{}.{}", header, payload, fake_sig);
214214+215215+ let result = validate_dpop_proof(&jwt, "GET", "https://example.com", "token", "thumb");
216216+ assert!(result.is_err());
217217+ assert!(result.unwrap_err().to_string().contains("dpop+jwt"));
218218+ }
219219+}
+260
src/oauth/keys.rs
···11+use p256::ecdsa::SigningKey;
22+use rand::RngCore;
33+use serde::Serialize;
44+use sha2::{Digest, Sha256};
55+66+use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339};
77+use crate::error::AppError;
88+use crate::plugin::encryption::{decrypt, encrypt};
99+1010+/// A generated ES256 DPoP keypair with its JWK representation.
1111+#[derive(Debug, Clone, Serialize)]
1212+pub struct DpopKeypair {
1313+ /// The private key as a JWK (returned to the app, also stored encrypted).
1414+ pub private_jwk: serde_json::Value,
1515+ /// The public key as a JWK.
1616+ pub public_jwk: serde_json::Value,
1717+ /// The JWK thumbprint (RFC 7638) using SHA-256.
1818+ pub thumbprint: String,
1919+}
2020+2121+/// Generate a new ES256 (P-256) DPoP keypair.
2222+pub fn generate_dpop_keypair() -> Result<DpopKeypair, AppError> {
2323+ let mut rng_bytes = [0u8; 32];
2424+ rand::rng().fill_bytes(&mut rng_bytes);
2525+2626+ let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into())
2727+ .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?;
2828+2929+ let verifying_key = signing_key.verifying_key();
3030+ let public_point = verifying_key.to_encoded_point(false);
3131+3232+ let x_bytes = public_point
3333+ .x()
3434+ .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?;
3535+ let y_bytes = public_point
3636+ .y()
3737+ .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?;
3838+3939+ use base64::Engine;
4040+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4141+4242+ let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes);
4343+ let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes);
4444+ let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes);
4545+4646+ let public_jwk = serde_json::json!({
4747+ "kty": "EC",
4848+ "crv": "P-256",
4949+ "x": x_b64,
5050+ "y": y_b64,
5151+ });
5252+5353+ let private_jwk = serde_json::json!({
5454+ "kty": "EC",
5555+ "crv": "P-256",
5656+ "x": x_b64,
5757+ "y": y_b64,
5858+ "d": d_b64,
5959+ });
6060+6161+ let thumbprint = compute_jwk_thumbprint(&public_jwk)?;
6262+6363+ Ok(DpopKeypair {
6464+ private_jwk,
6565+ public_jwk,
6666+ thumbprint,
6767+ })
6868+}
6969+7070+/// Compute the JWK Thumbprint (RFC 7638) using SHA-256.
7171+///
7272+/// For EC keys, the canonical JSON is: {"crv":"...","kty":"EC","x":"...","y":"..."}
7373+/// (alphabetical order of required members).
7474+pub fn compute_jwk_thumbprint(jwk: &serde_json::Value) -> Result<String, AppError> {
7575+ let kty = jwk["kty"]
7676+ .as_str()
7777+ .ok_or_else(|| AppError::Internal("JWK missing kty".into()))?;
7878+ let crv = jwk["crv"]
7979+ .as_str()
8080+ .ok_or_else(|| AppError::Internal("JWK missing crv".into()))?;
8181+ let x = jwk["x"]
8282+ .as_str()
8383+ .ok_or_else(|| AppError::Internal("JWK missing x".into()))?;
8484+ let y = jwk["y"]
8585+ .as_str()
8686+ .ok_or_else(|| AppError::Internal("JWK missing y".into()))?;
8787+8888+ // RFC 7638: lexicographic order of required members
8989+ let canonical = format!(
9090+ r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#,
9191+ crv, kty, x, y
9292+ );
9393+9494+ let hash = Sha256::digest(canonical.as_bytes());
9595+ use base64::Engine;
9696+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9797+ Ok(URL_SAFE_NO_PAD.encode(hash))
9898+}
9999+100100+/// Store a DPoP key in the database with the private key encrypted.
101101+#[allow(clippy::too_many_arguments)]
102102+pub async fn store_dpop_key(
103103+ pool: &sqlx::AnyPool,
104104+ backend: DatabaseBackend,
105105+ encryption_key: &[u8; 32],
106106+ id: &str,
107107+ provision_id: &str,
108108+ api_client_id: &str,
109109+ keypair: &DpopKeypair,
110110+ pkce_challenge: Option<&str>,
111111+) -> Result<(), AppError> {
112112+ let private_jwk_bytes = serde_json::to_vec(&keypair.private_jwk)
113113+ .map_err(|e| AppError::Internal(format!("failed to serialize JWK: {e}")))?;
114114+115115+ let encrypted = encrypt(encryption_key, &private_jwk_bytes)
116116+ .map_err(|e| AppError::Internal(format!("failed to encrypt DPoP key: {e}")))?;
117117+118118+ let now = now_rfc3339();
119119+ let sql = adapt_sql(
120120+ "INSERT INTO dpop_keys (id, provision_id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
121121+ backend,
122122+ );
123123+124124+ sqlx::query(&sql)
125125+ .bind(id)
126126+ .bind(provision_id)
127127+ .bind(api_client_id)
128128+ .bind(&encrypted)
129129+ .bind(&keypair.thumbprint)
130130+ .bind(pkce_challenge)
131131+ .bind(&now)
132132+ .execute(pool)
133133+ .await
134134+ .map_err(|e| AppError::Internal(format!("failed to store DPoP key: {e}")))?;
135135+136136+ Ok(())
137137+}
138138+139139+/// Retrieve and decrypt a DPoP key by provision_id.
140140+pub async fn get_dpop_key(
141141+ pool: &sqlx::AnyPool,
142142+ backend: DatabaseBackend,
143143+ encryption_key: &[u8; 32],
144144+ provision_id: &str,
145145+) -> Result<(String, String, serde_json::Value, String, Option<String>), AppError> {
146146+ // Returns: (id, api_client_id, private_jwk, thumbprint, pkce_challenge)
147147+ let sql = adapt_sql(
148148+ "SELECT id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge FROM dpop_keys WHERE provision_id = ?",
149149+ backend,
150150+ );
151151+152152+ #[allow(clippy::type_complexity)]
153153+ let row: Option<(String, String, Vec<u8>, String, Option<String>)> = sqlx::query_as(&sql)
154154+ .bind(provision_id)
155155+ .fetch_optional(pool)
156156+ .await
157157+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
158158+159159+ let (id, api_client_id, encrypted, thumbprint, pkce_challenge) =
160160+ row.ok_or_else(|| AppError::NotFound("DPoP key not found".into()))?;
161161+162162+ let decrypted = decrypt(encryption_key, &encrypted)
163163+ .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?;
164164+165165+ let private_jwk: serde_json::Value = serde_json::from_slice(&decrypted)
166166+ .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?;
167167+168168+ Ok((id, api_client_id, private_jwk, thumbprint, pkce_challenge))
169169+}
170170+171171+/// Retrieve the JWK thumbprint for a DPoP key by its database ID.
172172+pub async fn get_dpop_key_thumbprint(
173173+ pool: &sqlx::AnyPool,
174174+ backend: DatabaseBackend,
175175+ key_id: &str,
176176+) -> Result<String, AppError> {
177177+ let sql = adapt_sql("SELECT jwk_thumbprint FROM dpop_keys WHERE id = ?", backend);
178178+179179+ let row: Option<(String,)> = sqlx::query_as(&sql)
180180+ .bind(key_id)
181181+ .fetch_optional(pool)
182182+ .await
183183+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
184184+185185+ row.map(|(t,)| t)
186186+ .ok_or_else(|| AppError::NotFound("DPoP key not found".into()))
187187+}
188188+189189+/// Delete a DPoP key and its associated session.
190190+pub async fn delete_dpop_key(
191191+ pool: &sqlx::AnyPool,
192192+ backend: DatabaseBackend,
193193+ dpop_key_id: &str,
194194+) -> Result<(), AppError> {
195195+ // Session is deleted by CASCADE, but be explicit for clarity
196196+ let session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE dpop_key_id = ?", backend);
197197+ let _ = sqlx::query(&session_sql)
198198+ .bind(dpop_key_id)
199199+ .execute(pool)
200200+ .await;
201201+202202+ let key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend);
203203+ sqlx::query(&key_sql)
204204+ .bind(dpop_key_id)
205205+ .execute(pool)
206206+ .await
207207+ .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?;
208208+209209+ Ok(())
210210+}
211211+212212+#[cfg(test)]
213213+mod tests {
214214+ use super::*;
215215+216216+ #[test]
217217+ fn generate_keypair_produces_valid_jwk() {
218218+ let keypair = generate_dpop_keypair().unwrap();
219219+220220+ // Private JWK has d parameter
221221+ assert!(keypair.private_jwk["d"].is_string());
222222+ assert_eq!(keypair.private_jwk["kty"], "EC");
223223+ assert_eq!(keypair.private_jwk["crv"], "P-256");
224224+225225+ // Public JWK has no d parameter
226226+ assert!(keypair.public_jwk["d"].is_null());
227227+ assert_eq!(keypair.public_jwk["kty"], "EC");
228228+ assert_eq!(keypair.public_jwk["crv"], "P-256");
229229+230230+ // Thumbprint is a base64url string
231231+ assert!(!keypair.thumbprint.is_empty());
232232+ assert!(!keypair.thumbprint.contains('='));
233233+ }
234234+235235+ #[test]
236236+ fn generate_keypair_produces_unique_keys() {
237237+ let kp1 = generate_dpop_keypair().unwrap();
238238+ let kp2 = generate_dpop_keypair().unwrap();
239239+ assert_ne!(kp1.private_jwk["d"], kp2.private_jwk["d"]);
240240+ assert_ne!(kp1.thumbprint, kp2.thumbprint);
241241+ }
242242+243243+ #[test]
244244+ fn thumbprint_is_deterministic() {
245245+ let keypair = generate_dpop_keypair().unwrap();
246246+ let t1 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap();
247247+ let t2 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap();
248248+ assert_eq!(t1, t2);
249249+ assert_eq!(t1, keypair.thumbprint);
250250+ }
251251+252252+ #[test]
253253+ fn thumbprint_differs_for_different_keys() {
254254+ let kp1 = generate_dpop_keypair().unwrap();
255255+ let kp2 = generate_dpop_keypair().unwrap();
256256+ let t1 = compute_jwk_thumbprint(&kp1.public_jwk).unwrap();
257257+ let t2 = compute_jwk_thumbprint(&kp2.public_jwk).unwrap();
258258+ assert_ne!(t1, t2);
259259+ }
260260+}
+6
src/oauth/mod.rs
···11+pub mod client_auth;
22+pub mod dpop_proof;
33+pub mod keys;
44+pub mod pds_write;
55+pub mod routes;
66+pub mod sessions;
+289
src/oauth/pds_write.rs
···11+use base64::Engine;
22+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33+use p256::ecdsa::{SigningKey, signature::Signer};
44+use sha2::{Digest, Sha256};
55+66+use crate::db::DatabaseBackend;
77+use crate::error::AppError;
88+use crate::plugin::encryption::decrypt;
99+1010+/// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session.
1111+#[allow(clippy::too_many_arguments)]
1212+pub async fn dpop_pds_post(
1313+ http: &reqwest::Client,
1414+ pool: &sqlx::AnyPool,
1515+ backend: DatabaseBackend,
1616+ encryption_key: &[u8; 32],
1717+ plc_url: &str,
1818+ api_client_id: &str,
1919+ user_did: &str,
2020+ xrpc_method: &str,
2121+ body: &serde_json::Value,
2222+) -> Result<reqwest::Response, AppError> {
2323+ let session =
2424+ super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did)
2525+ .await?;
2626+2727+ let pds_url = match session.pds_url {
2828+ Some(ref url) => url.clone(),
2929+ None => resolve_pds_from_did(http, plc_url, user_did).await?,
3030+ };
3131+3232+ let target_url = format!("{}/xrpc/{}", pds_url.trim_end_matches('/'), xrpc_method);
3333+3434+ // Decrypt the DPoP private key
3535+ let key_sql = crate::db::adapt_sql(
3636+ "SELECT private_key_enc FROM dpop_keys WHERE id = ?",
3737+ backend,
3838+ );
3939+ let row: Option<(Vec<u8>,)> = sqlx::query_as(&key_sql)
4040+ .bind(&session.dpop_key_id)
4141+ .fetch_optional(pool)
4242+ .await
4343+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
4444+4545+ let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?;
4646+4747+ let key_bytes = decrypt(encryption_key, &encrypted_key)
4848+ .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?;
4949+5050+ let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes)
5151+ .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?;
5252+5353+ let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?;
5454+5555+ let resp = http
5656+ .post(&target_url)
5757+ .header("Authorization", format!("DPoP {}", session.access_token))
5858+ .header("DPoP", proof)
5959+ .header("Content-Type", "application/json")
6060+ .json(body)
6161+ .send()
6262+ .await
6363+ .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?;
6464+6565+ Ok(resp)
6666+}
6767+6868+/// Make an authenticated blob upload to a PDS using a DPoP session.
6969+#[allow(clippy::too_many_arguments)]
7070+pub async fn dpop_pds_post_blob(
7171+ http: &reqwest::Client,
7272+ pool: &sqlx::AnyPool,
7373+ backend: DatabaseBackend,
7474+ encryption_key: &[u8; 32],
7575+ plc_url: &str,
7676+ api_client_id: &str,
7777+ user_did: &str,
7878+ content_type: &str,
7979+ blob: bytes::Bytes,
8080+) -> Result<reqwest::Response, AppError> {
8181+ let session =
8282+ super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did)
8383+ .await?;
8484+8585+ let pds_url = match session.pds_url {
8686+ Some(ref url) => url.clone(),
8787+ None => resolve_pds_from_did(http, plc_url, user_did).await?,
8888+ };
8989+9090+ let target_url = format!(
9191+ "{}/xrpc/com.atproto.repo.uploadBlob",
9292+ pds_url.trim_end_matches('/')
9393+ );
9494+9595+ // Decrypt the DPoP private key
9696+ let key_sql = crate::db::adapt_sql(
9797+ "SELECT private_key_enc FROM dpop_keys WHERE id = ?",
9898+ backend,
9999+ );
100100+ let row: Option<(Vec<u8>,)> = sqlx::query_as(&key_sql)
101101+ .bind(&session.dpop_key_id)
102102+ .fetch_optional(pool)
103103+ .await
104104+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
105105+106106+ let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?;
107107+108108+ let key_bytes = decrypt(encryption_key, &encrypted_key)
109109+ .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?;
110110+111111+ let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes)
112112+ .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?;
113113+114114+ let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?;
115115+116116+ let resp = http
117117+ .post(&target_url)
118118+ .header("Authorization", format!("DPoP {}", session.access_token))
119119+ .header("DPoP", proof)
120120+ .header("Content-Type", content_type)
121121+ .body(blob)
122122+ .send()
123123+ .await
124124+ .map_err(|e| AppError::Internal(format!("PDS uploadBlob request failed: {e}")))?;
125125+126126+ Ok(resp)
127127+}
128128+129129+/// Generate a DPoP proof JWT for a PDS request.
130130+pub fn generate_dpop_proof(
131131+ private_jwk: &serde_json::Value,
132132+ method: &str,
133133+ url: &str,
134134+ access_token: &str,
135135+) -> Result<String, AppError> {
136136+ let d_b64 = private_jwk["d"]
137137+ .as_str()
138138+ .ok_or_else(|| AppError::Internal("DPoP key missing d parameter".into()))?;
139139+ let x_b64 = private_jwk["x"]
140140+ .as_str()
141141+ .ok_or_else(|| AppError::Internal("DPoP key missing x parameter".into()))?;
142142+ let y_b64 = private_jwk["y"]
143143+ .as_str()
144144+ .ok_or_else(|| AppError::Internal("DPoP key missing y parameter".into()))?;
145145+146146+ let d_bytes = URL_SAFE_NO_PAD
147147+ .decode(d_b64)
148148+ .map_err(|_| AppError::Internal("invalid DPoP key d parameter".into()))?;
149149+150150+ let signing_key = SigningKey::from_bytes((&d_bytes[..]).into())
151151+ .map_err(|e| AppError::Internal(format!("invalid DPoP signing key: {e}")))?;
152152+153153+ let public_jwk = serde_json::json!({
154154+ "kty": "EC",
155155+ "crv": "P-256",
156156+ "x": x_b64,
157157+ "y": y_b64,
158158+ });
159159+160160+ let now = std::time::SystemTime::now()
161161+ .duration_since(std::time::UNIX_EPOCH)
162162+ .unwrap()
163163+ .as_secs();
164164+165165+ let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes()));
166166+167167+ let header = serde_json::json!({
168168+ "alg": "ES256",
169169+ "typ": "dpop+jwt",
170170+ "jwk": public_jwk,
171171+ });
172172+173173+ let payload = serde_json::json!({
174174+ "htm": method,
175175+ "htu": url,
176176+ "iat": now,
177177+ "ath": ath,
178178+ "jti": format!("{:x}", rand::random::<u64>()),
179179+ });
180180+181181+ let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
182182+ let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
183183+184184+ let message = format!("{}.{}", header_b64, payload_b64);
185185+ let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes());
186186+ let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
187187+188188+ Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
189189+}
190190+191191+/// Resolve a user's PDS URL from their DID document.
192192+async fn resolve_pds_from_did(
193193+ http: &reqwest::Client,
194194+ plc_url: &str,
195195+ did: &str,
196196+) -> Result<String, AppError> {
197197+ let url = if did.starts_with("did:plc:") {
198198+ format!("{}/{}", plc_url.trim_end_matches('/'), did)
199199+ } else if did.starts_with("did:web:") {
200200+ let host = did.strip_prefix("did:web:").unwrap();
201201+ format!("https://{}/.well-known/did.json", host)
202202+ } else {
203203+ return Err(AppError::Internal(format!("unsupported DID method: {did}")));
204204+ };
205205+206206+ let resp = http
207207+ .get(&url)
208208+ .send()
209209+ .await
210210+ .map_err(|e| AppError::Internal(format!("failed to resolve DID: {e}")))?;
211211+212212+ let doc: serde_json::Value = resp
213213+ .json()
214214+ .await
215215+ .map_err(|e| AppError::Internal(format!("failed to parse DID document: {e}")))?;
216216+217217+ let services = doc["service"]
218218+ .as_array()
219219+ .ok_or_else(|| AppError::Internal("DID document missing service array".into()))?;
220220+221221+ for service in services {
222222+ let id = service["id"].as_str().unwrap_or("");
223223+ if (id == "#atproto_pds" || id.ends_with("#atproto_pds"))
224224+ && let Some(endpoint) = service["serviceEndpoint"].as_str()
225225+ {
226226+ return Ok(endpoint.to_string());
227227+ }
228228+ }
229229+230230+ Err(AppError::Internal(format!(
231231+ "no #atproto_pds service found in DID document for {did}"
232232+ )))
233233+}
234234+235235+#[cfg(test)]
236236+mod tests {
237237+ use super::*;
238238+239239+ #[test]
240240+ fn generate_dpop_proof_produces_valid_jwt() {
241241+ let keypair = super::super::keys::generate_dpop_keypair().unwrap();
242242+243243+ let proof = generate_dpop_proof(
244244+ &keypair.private_jwk,
245245+ "POST",
246246+ "https://pds.example.com/xrpc/com.atproto.repo.createRecord",
247247+ "test-access-token",
248248+ )
249249+ .unwrap();
250250+251251+ let parts: Vec<&str> = proof.split('.').collect();
252252+ assert_eq!(parts.len(), 3);
253253+254254+ let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap();
255255+ let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
256256+ assert_eq!(header["alg"], "ES256");
257257+ assert_eq!(header["typ"], "dpop+jwt");
258258+ assert_eq!(header["jwk"]["kty"], "EC");
259259+260260+ let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
261261+ let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
262262+ assert_eq!(payload["htm"], "POST");
263263+ assert_eq!(
264264+ payload["htu"],
265265+ "https://pds.example.com/xrpc/com.atproto.repo.createRecord"
266266+ );
267267+ assert!(payload["iat"].is_number());
268268+ assert!(payload["ath"].is_string());
269269+ assert!(payload["jti"].is_string());
270270+ }
271271+272272+ #[test]
273273+ fn generated_proof_validates_against_own_key() {
274274+ let keypair = super::super::keys::generate_dpop_keypair().unwrap();
275275+ let url = "https://pds.example.com/xrpc/test.method";
276276+ let token = "my-access-token";
277277+278278+ let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token).unwrap();
279279+280280+ let result = super::super::dpop_proof::validate_dpop_proof(
281281+ &proof,
282282+ "POST",
283283+ url,
284284+ token,
285285+ &keypair.thumbprint,
286286+ );
287287+ assert!(result.is_ok(), "validation failed: {:?}", result.err());
288288+ }
289289+}
+404
src/oauth/routes.rs
···11+use axum::extract::{FromRequest, Path, State};
22+use axum::http::StatusCode;
33+use axum::routing::{delete, post};
44+use axum::{Json, Router};
55+use serde::{Deserialize, Serialize};
66+use uuid::Uuid;
77+88+use crate::AppState;
99+use crate::error::AppError;
1010+use crate::event_log::{EventLog, Severity, log_event};
1111+1212+use super::client_auth;
1313+use super::keys;
1414+use super::sessions;
1515+1616+pub fn routes() -> Router<AppState> {
1717+ Router::new()
1818+ .route("/dpop-keys", post(provision_dpop_key))
1919+ .route("/sessions", post(register_session))
2020+ .route("/sessions/{did}", delete(delete_session))
2121+}
2222+2323+// --- Request / response types ---
2424+2525+#[derive(Deserialize)]
2626+struct ProvisionKeyBody {
2727+ pkce_challenge: Option<String>,
2828+}
2929+3030+#[derive(Serialize)]
3131+struct ProvisionKeyResponse {
3232+ provision_id: String,
3333+ dpop_key: serde_json::Value,
3434+}
3535+3636+#[derive(Deserialize)]
3737+struct RegisterSessionBody {
3838+ provision_id: String,
3939+ pkce_verifier: Option<String>,
4040+ did: String,
4141+ access_token: String,
4242+ refresh_token: Option<String>,
4343+ expires_at: Option<String>,
4444+ scopes: String,
4545+ pds_url: Option<String>,
4646+ issuer: Option<String>,
4747+}
4848+4949+#[derive(Serialize)]
5050+struct RegisterSessionResponse {
5151+ session_id: String,
5252+ did: String,
5353+}
5454+5555+// --- Handlers ---
5656+5757+/// POST /oauth/dpop-keys — provision a new DPoP keypair.
5858+///
5959+/// Client credentials come from `X-Client-Key` and `X-Client-Secret` headers.
6060+async fn provision_dpop_key(
6161+ State(state): State<AppState>,
6262+ req: axum::extract::Request,
6363+) -> Result<(StatusCode, Json<ProvisionKeyResponse>), AppError> {
6464+ let client_key = req
6565+ .headers()
6666+ .get("x-client-key")
6767+ .and_then(|v| v.to_str().ok())
6868+ .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))?
6969+ .to_string();
7070+7171+ let client_secret = req
7272+ .headers()
7373+ .get("x-client-secret")
7474+ .and_then(|v| v.to_str().ok())
7575+ .map(|s| s.to_string());
7676+7777+ let origin = req
7878+ .headers()
7979+ .get("origin")
8080+ .and_then(|v| v.to_str().ok())
8181+ .map(|s| s.to_string());
8282+8383+ let body: ProvisionKeyBody = Json::<ProvisionKeyBody>::from_request(req, &state)
8484+ .await
8585+ .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))?
8686+ .0;
8787+8888+ let encryption_key = state
8989+ .config
9090+ .token_encryption_key
9191+ .as_ref()
9292+ .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?;
9393+9494+ // Authenticate the client
9595+ let client = if let Some(ref secret) = client_secret {
9696+ client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret)
9797+ .await?
9898+ } else {
9999+ // Public client — must provide PKCE challenge
100100+ if body.pkce_challenge.is_none() {
101101+ return Err(AppError::BadRequest(
102102+ "public clients must provide pkce_challenge".into(),
103103+ ));
104104+ }
105105+ client_auth::authenticate_public(
106106+ &state.db,
107107+ state.db_backend,
108108+ &client_key,
109109+ origin.as_deref(),
110110+ )
111111+ .await?
112112+ };
113113+114114+ // Generate keypair
115115+ let keypair = keys::generate_dpop_keypair()?;
116116+ let id = Uuid::new_v4().to_string();
117117+ let provision_id = format!("hvp_{}", hex::encode(rand::random::<[u8; 16]>()));
118118+119119+ // Store encrypted key
120120+ keys::store_dpop_key(
121121+ &state.db,
122122+ state.db_backend,
123123+ encryption_key,
124124+ &id,
125125+ &provision_id,
126126+ &client.id,
127127+ &keypair,
128128+ body.pkce_challenge.as_deref(),
129129+ )
130130+ .await?;
131131+132132+ log_event(
133133+ &state.db,
134134+ EventLog {
135135+ event_type: "dpop_key.provisioned".to_string(),
136136+ severity: Severity::Info,
137137+ actor_did: None,
138138+ subject: Some(provision_id.clone()),
139139+ detail: serde_json::json!({
140140+ "client_key": client.client_key,
141141+ "thumbprint": keypair.thumbprint,
142142+ }),
143143+ },
144144+ state.db_backend,
145145+ )
146146+ .await;
147147+148148+ Ok((
149149+ StatusCode::CREATED,
150150+ Json(ProvisionKeyResponse {
151151+ provision_id,
152152+ dpop_key: keypair.private_jwk,
153153+ }),
154154+ ))
155155+}
156156+157157+/// POST /oauth/sessions — register a token set after OAuth callback.
158158+///
159159+/// Client credentials come from `X-Client-Key` and `X-Client-Secret` headers.
160160+async fn register_session(
161161+ State(state): State<AppState>,
162162+ req: axum::extract::Request,
163163+) -> Result<(StatusCode, Json<RegisterSessionResponse>), AppError> {
164164+ let client_key = req
165165+ .headers()
166166+ .get("x-client-key")
167167+ .and_then(|v| v.to_str().ok())
168168+ .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))?
169169+ .to_string();
170170+171171+ let client_secret = req
172172+ .headers()
173173+ .get("x-client-secret")
174174+ .and_then(|v| v.to_str().ok())
175175+ .map(|s| s.to_string());
176176+177177+ let body: RegisterSessionBody = Json::<RegisterSessionBody>::from_request(req, &state)
178178+ .await
179179+ .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))?
180180+ .0;
181181+182182+ let encryption_key = state
183183+ .config
184184+ .token_encryption_key
185185+ .as_ref()
186186+ .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?;
187187+188188+ // Look up the DPoP key by provision_id
189189+ let (dpop_key_id, dpop_client_id, _private_jwk, _thumbprint, pkce_challenge) =
190190+ keys::get_dpop_key(
191191+ &state.db,
192192+ state.db_backend,
193193+ encryption_key,
194194+ &body.provision_id,
195195+ )
196196+ .await?;
197197+198198+ // Authenticate the client and verify it matches the key's client
199199+ let client = if let Some(ref secret) = client_secret {
200200+ client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret)
201201+ .await?
202202+ } else {
203203+ // Public client — verify PKCE
204204+ let verifier = body.pkce_verifier.as_deref().ok_or_else(|| {
205205+ AppError::BadRequest("public clients must provide pkce_verifier".into())
206206+ })?;
207207+208208+ let challenge = pkce_challenge.as_deref().ok_or_else(|| {
209209+ AppError::BadRequest("no PKCE challenge found for this provision".into())
210210+ })?;
211211+212212+ if !client_auth::verify_pkce(challenge, verifier) {
213213+ return Err(AppError::Auth("PKCE verification failed".into()));
214214+ }
215215+216216+ client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await?
217217+ };
218218+219219+ // Verify client_key matches the key's owning client
220220+ if client.id != dpop_client_id {
221221+ return Err(AppError::Auth(
222222+ "provision_id does not belong to this client".into(),
223223+ ));
224224+ }
225225+226226+ // Validate scopes
227227+ client_auth::validate_scopes(&body.scopes, &client.scopes)?;
228228+229229+ // Clean up any existing session's DPoP key before upserting
230230+ // (the ON CONFLICT upsert would orphan the old key otherwise)
231231+ {
232232+ let lookup_sql = crate::db::adapt_sql(
233233+ "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?",
234234+ state.db_backend,
235235+ );
236236+ if let Ok(Some((old_key_id,))) = sqlx::query_as::<_, (String,)>(&lookup_sql)
237237+ .bind(&client.id)
238238+ .bind(&body.did)
239239+ .fetch_optional(&state.db)
240240+ .await
241241+ && old_key_id != dpop_key_id
242242+ {
243243+ let del_sql =
244244+ crate::db::adapt_sql("DELETE FROM dpop_keys WHERE id = ?", state.db_backend);
245245+ let _ = sqlx::query(&del_sql)
246246+ .bind(&old_key_id)
247247+ .execute(&state.db)
248248+ .await;
249249+ }
250250+ }
251251+252252+ // Store the session
253253+ let session_id = Uuid::new_v4().to_string();
254254+ sessions::store_dpop_session(
255255+ &state.db,
256256+ state.db_backend,
257257+ encryption_key,
258258+ &session_id,
259259+ &client.id,
260260+ &dpop_key_id,
261261+ &body.did,
262262+ &body.access_token,
263263+ body.refresh_token.as_deref(),
264264+ body.expires_at.as_deref(),
265265+ &body.scopes,
266266+ body.pds_url.as_deref(),
267267+ body.issuer.as_deref(),
268268+ )
269269+ .await?;
270270+271271+ log_event(
272272+ &state.db,
273273+ EventLog {
274274+ event_type: "dpop_session.created".to_string(),
275275+ severity: Severity::Info,
276276+ actor_did: Some(body.did.clone()),
277277+ subject: Some(client.client_key.clone()),
278278+ detail: serde_json::json!({
279279+ "scopes": body.scopes,
280280+ }),
281281+ },
282282+ state.db_backend,
283283+ )
284284+ .await;
285285+286286+ Ok((
287287+ StatusCode::CREATED,
288288+ Json(RegisterSessionResponse {
289289+ session_id,
290290+ did: body.did,
291291+ }),
292292+ ))
293293+}
294294+295295+/// DELETE /oauth/sessions/:did — logout / revoke a session.
296296+///
297297+/// Confidential clients authenticate with `X-Client-Key` + `X-Client-Secret`.
298298+/// Public clients authenticate with `X-Client-Key` + `Authorization: DPoP <token>` + `DPoP` proof.
299299+async fn delete_session(
300300+ State(state): State<AppState>,
301301+ Path(did): Path<String>,
302302+ req: axum::extract::Request,
303303+) -> Result<StatusCode, AppError> {
304304+ let client_key = req
305305+ .headers()
306306+ .get("x-client-key")
307307+ .and_then(|v| v.to_str().ok())
308308+ .ok_or_else(|| AppError::Auth("X-Client-Key header required".into()))?
309309+ .to_string();
310310+311311+ let client_secret = req
312312+ .headers()
313313+ .get("x-client-secret")
314314+ .and_then(|v| v.to_str().ok())
315315+ .map(|s| s.to_string());
316316+317317+ let client = if let Some(ref secret) = client_secret {
318318+ client_auth::authenticate_confidential(&state.db, state.db_backend, &client_key, secret)
319319+ .await?
320320+ } else {
321321+ let resolved =
322322+ client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key).await?;
323323+324324+ // Public clients must prove they hold the DPoP key + token
325325+ if resolved.client_type == "public" {
326326+ let auth_header = req
327327+ .headers()
328328+ .get("authorization")
329329+ .and_then(|v| v.to_str().ok())
330330+ .ok_or_else(|| {
331331+ AppError::Auth("public clients must provide Authorization: DPoP <token>".into())
332332+ })?;
333333+ let access_token = auth_header.strip_prefix("DPoP ").ok_or_else(|| {
334334+ AppError::Auth("public clients must use DPoP authorization scheme".into())
335335+ })?;
336336+ let dpop_proof = req
337337+ .headers()
338338+ .get("dpop")
339339+ .and_then(|v| v.to_str().ok())
340340+ .ok_or_else(|| {
341341+ AppError::Auth("public clients must provide DPoP proof header".into())
342342+ })?;
343343+344344+ let encryption_key =
345345+ state.config.token_encryption_key.as_ref().ok_or_else(|| {
346346+ AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into())
347347+ })?;
348348+349349+ // Look up the session to get the DPoP key thumbprint
350350+ let session = sessions::get_dpop_session_by_token_hash(
351351+ &state.db,
352352+ state.db_backend,
353353+ encryption_key,
354354+ &resolved.id,
355355+ access_token,
356356+ )
357357+ .await?;
358358+359359+ let thumbprint =
360360+ keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id)
361361+ .await?;
362362+363363+ // Build request URL for htu validation
364364+ let scheme = if state.config.public_url.starts_with("https") {
365365+ "https"
366366+ } else {
367367+ "http"
368368+ };
369369+ let host = req
370370+ .headers()
371371+ .get("host")
372372+ .and_then(|v| v.to_str().ok())
373373+ .unwrap_or("localhost");
374374+ let request_url = format!("{}://{}/oauth/sessions/{}", scheme, host, did);
375375+376376+ crate::oauth::dpop_proof::validate_dpop_proof(
377377+ dpop_proof,
378378+ "DELETE",
379379+ &request_url,
380380+ access_token,
381381+ &thumbprint,
382382+ )?;
383383+ }
384384+385385+ resolved
386386+ };
387387+388388+ sessions::delete_dpop_session(&state.db, state.db_backend, &client.id, &did).await?;
389389+390390+ log_event(
391391+ &state.db,
392392+ EventLog {
393393+ event_type: "dpop_session.deleted".to_string(),
394394+ severity: Severity::Info,
395395+ actor_did: Some(did),
396396+ subject: Some(client.client_key),
397397+ detail: serde_json::json!({}),
398398+ },
399399+ state.db_backend,
400400+ )
401401+ .await;
402402+403403+ Ok(StatusCode::NO_CONTENT)
404404+}
+274
src/oauth/sessions.rs
···11+use sha2::{Digest, Sha256};
22+33+use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339};
44+use crate::error::AppError;
55+use crate::plugin::encryption::{decrypt, encrypt};
66+77+/// Compute a hex-encoded SHA-256 hash of a token for indexed lookup.
88+fn token_hash(token: &str) -> String {
99+ hex::encode(Sha256::digest(token.as_bytes()))
1010+}
1111+1212+/// Stored DPoP session data (decrypted).
1313+pub struct DpopSession {
1414+ pub id: String,
1515+ pub api_client_id: String,
1616+ pub dpop_key_id: String,
1717+ pub user_did: String,
1818+ pub access_token: String,
1919+ pub refresh_token: Option<String>,
2020+ pub token_expires_at: Option<String>,
2121+ pub scopes: String,
2222+ pub pds_url: Option<String>,
2323+ pub issuer: Option<String>,
2424+}
2525+2626+/// Store or update a DPoP session.
2727+///
2828+/// Uses ON CONFLICT to upsert — if a session already exists for this
2929+/// (api_client_id, user_did), it updates the token data.
3030+#[allow(clippy::too_many_arguments)]
3131+pub async fn store_dpop_session(
3232+ pool: &sqlx::AnyPool,
3333+ backend: DatabaseBackend,
3434+ encryption_key: &[u8; 32],
3535+ id: &str,
3636+ api_client_id: &str,
3737+ dpop_key_id: &str,
3838+ user_did: &str,
3939+ access_token: &str,
4040+ refresh_token: Option<&str>,
4141+ token_expires_at: Option<&str>,
4242+ scopes: &str,
4343+ pds_url: Option<&str>,
4444+ issuer: Option<&str>,
4545+) -> Result<(), AppError> {
4646+ let access_enc = encrypt(encryption_key, access_token.as_bytes())
4747+ .map_err(|e| AppError::Internal(format!("failed to encrypt access token: {e}")))?;
4848+4949+ let access_hash = token_hash(access_token);
5050+5151+ let refresh_enc = refresh_token
5252+ .map(|t| {
5353+ encrypt(encryption_key, t.as_bytes())
5454+ .map_err(|e| AppError::Internal(format!("failed to encrypt refresh token: {e}")))
5555+ })
5656+ .transpose()?;
5757+5858+ let now = now_rfc3339();
5959+ let sql = adapt_sql(
6060+ 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)
6161+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6262+ ON CONFLICT (api_client_id, user_did) DO UPDATE SET
6363+ dpop_key_id = EXCLUDED.dpop_key_id,
6464+ access_token_enc = EXCLUDED.access_token_enc,
6565+ access_token_hash = EXCLUDED.access_token_hash,
6666+ refresh_token_enc = EXCLUDED.refresh_token_enc,
6767+ token_expires_at = EXCLUDED.token_expires_at,
6868+ scopes = EXCLUDED.scopes,
6969+ pds_url = EXCLUDED.pds_url,
7070+ issuer = EXCLUDED.issuer,
7171+ updated_at = EXCLUDED.updated_at"#,
7272+ backend,
7373+ );
7474+7575+ sqlx::query(&sql)
7676+ .bind(id)
7777+ .bind(api_client_id)
7878+ .bind(dpop_key_id)
7979+ .bind(user_did)
8080+ .bind(&access_enc)
8181+ .bind(&access_hash)
8282+ .bind(&refresh_enc)
8383+ .bind(token_expires_at)
8484+ .bind(scopes)
8585+ .bind(pds_url)
8686+ .bind(issuer)
8787+ .bind(&now)
8888+ .bind(&now)
8989+ .execute(pool)
9090+ .await
9191+ .map_err(|e| AppError::Internal(format!("failed to store DPoP session: {e}")))?;
9292+9393+ Ok(())
9494+}
9595+9696+/// Look up a DPoP session by api_client_id and user_did, decrypting tokens.
9797+pub async fn get_dpop_session(
9898+ pool: &sqlx::AnyPool,
9999+ backend: DatabaseBackend,
100100+ encryption_key: &[u8; 32],
101101+ api_client_id: &str,
102102+ user_did: &str,
103103+) -> Result<DpopSession, AppError> {
104104+ let sql = adapt_sql(
105105+ "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 = ?",
106106+ backend,
107107+ );
108108+109109+ #[allow(clippy::type_complexity)]
110110+ let row: Option<(
111111+ String,
112112+ String,
113113+ Vec<u8>,
114114+ Option<Vec<u8>>,
115115+ Option<String>,
116116+ String,
117117+ Option<String>,
118118+ Option<String>,
119119+ )> = sqlx::query_as(&sql)
120120+ .bind(api_client_id)
121121+ .bind(user_did)
122122+ .fetch_optional(pool)
123123+ .await
124124+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?;
125125+126126+ let (id, dpop_key_id, access_enc, refresh_enc, token_expires_at, scopes, pds_url, issuer) =
127127+ row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?;
128128+129129+ let access_token = String::from_utf8(
130130+ decrypt(encryption_key, &access_enc)
131131+ .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?,
132132+ )
133133+ .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?;
134134+135135+ let refresh_token = refresh_enc
136136+ .map(|enc| {
137137+ let bytes = decrypt(encryption_key, &enc)
138138+ .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?;
139139+ String::from_utf8(bytes)
140140+ .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}")))
141141+ })
142142+ .transpose()?;
143143+144144+ Ok(DpopSession {
145145+ id,
146146+ api_client_id: api_client_id.to_string(),
147147+ dpop_key_id,
148148+ user_did: user_did.to_string(),
149149+ access_token,
150150+ refresh_token,
151151+ token_expires_at,
152152+ scopes,
153153+ pds_url,
154154+ issuer,
155155+ })
156156+}
157157+158158+/// Look up a DPoP session by api_client_id and access token.
159159+/// Uses the `access_token_hash` column for indexed lookup instead of
160160+/// decrypting every session.
161161+pub async fn get_dpop_session_by_token_hash(
162162+ pool: &sqlx::AnyPool,
163163+ backend: DatabaseBackend,
164164+ encryption_key: &[u8; 32],
165165+ api_client_id: &str,
166166+ access_token: &str,
167167+) -> Result<DpopSession, AppError> {
168168+ let hash = token_hash(access_token);
169169+ let sql = adapt_sql(
170170+ "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 = ?",
171171+ backend,
172172+ );
173173+174174+ #[allow(clippy::type_complexity)]
175175+ let row: Option<(
176176+ String,
177177+ String,
178178+ String,
179179+ Vec<u8>,
180180+ Option<Vec<u8>>,
181181+ Option<String>,
182182+ String,
183183+ Option<String>,
184184+ Option<String>,
185185+ )> = sqlx::query_as(&sql)
186186+ .bind(api_client_id)
187187+ .bind(&hash)
188188+ .fetch_optional(pool)
189189+ .await
190190+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?;
191191+192192+ let (
193193+ id,
194194+ dpop_key_id,
195195+ user_did,
196196+ access_enc,
197197+ refresh_enc,
198198+ token_expires_at,
199199+ scopes,
200200+ pds_url,
201201+ issuer,
202202+ ) = row.ok_or_else(|| AppError::Auth("no matching DPoP session".into()))?;
203203+204204+ let access_token_dec = String::from_utf8(
205205+ decrypt(encryption_key, &access_enc)
206206+ .map_err(|e| AppError::Internal(format!("failed to decrypt access token: {e}")))?,
207207+ )
208208+ .map_err(|e| AppError::Internal(format!("invalid access token bytes: {e}")))?;
209209+210210+ let refresh_token = refresh_enc
211211+ .map(|enc| {
212212+ let bytes = decrypt(encryption_key, &enc)
213213+ .map_err(|e| AppError::Internal(format!("failed to decrypt refresh token: {e}")))?;
214214+ String::from_utf8(bytes)
215215+ .map_err(|e| AppError::Internal(format!("invalid refresh token bytes: {e}")))
216216+ })
217217+ .transpose()?;
218218+219219+ Ok(DpopSession {
220220+ id,
221221+ api_client_id: api_client_id.to_string(),
222222+ dpop_key_id,
223223+ user_did,
224224+ access_token: access_token_dec,
225225+ refresh_token,
226226+ token_expires_at,
227227+ scopes,
228228+ pds_url,
229229+ issuer,
230230+ })
231231+}
232232+233233+/// Delete a DPoP session by api_client_id and user_did.
234234+pub async fn delete_dpop_session(
235235+ pool: &sqlx::AnyPool,
236236+ backend: DatabaseBackend,
237237+ api_client_id: &str,
238238+ user_did: &str,
239239+) -> Result<String, AppError> {
240240+ // Look up the dpop_key_id before deleting so we can clean up the key too
241241+ let lookup_sql = adapt_sql(
242242+ "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?",
243243+ backend,
244244+ );
245245+246246+ let row: Option<(String,)> = sqlx::query_as(&lookup_sql)
247247+ .bind(api_client_id)
248248+ .bind(user_did)
249249+ .fetch_optional(pool)
250250+ .await
251251+ .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?;
252252+253253+ let (dpop_key_id,) = row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?;
254254+255255+ let del_session_sql = adapt_sql(
256256+ "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?",
257257+ backend,
258258+ );
259259+ sqlx::query(&del_session_sql)
260260+ .bind(api_client_id)
261261+ .bind(user_did)
262262+ .execute(pool)
263263+ .await
264264+ .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?;
265265+266266+ let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend);
267267+ sqlx::query(&del_key_sql)
268268+ .bind(&dpop_key_id)
269269+ .execute(pool)
270270+ .await
271271+ .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?;
272272+273273+ Ok(dpop_key_id)
274274+}
+1-1
src/repo/mod.rs
···33mod upload_blob;
4455pub(crate) use pds::{forward_pds_response, pds_post_json_raw};
66-pub(crate) use session::get_oauth_session;
66+pub(crate) use session::{get_dpop_client_id, get_oauth_session};
77pub use upload_blob::upload_blob;
+22
src/repo/session.rs
···2233use crate::AppState;
44use crate::HappyViewOAuthSession;
55+use crate::db::adapt_sql;
56use crate::error::AppError;
77+88+/// Resolve an API client ID from a client_key.
99+/// Used by the procedure handler to route DPoP PDS writes.
1010+pub(crate) async fn get_dpop_client_id(
1111+ state: &AppState,
1212+ client_key: &str,
1313+) -> Result<String, AppError> {
1414+ let sql = adapt_sql(
1515+ "SELECT id FROM api_clients WHERE client_key = ? AND is_active = 1",
1616+ state.db_backend,
1717+ );
1818+1919+ let row: Option<(String,)> = sqlx::query_as(&sql)
2020+ .bind(client_key)
2121+ .fetch_optional(&state.db)
2222+ .await
2323+ .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?;
2424+2525+ row.map(|(id,)| id)
2626+ .ok_or_else(|| AppError::Auth("unknown API client".into()))
2727+}
628729/// Resume an OAuth session for the given DID via atrium.
830/// The returned `OAuthSession` handles DPoP and token refresh internally.
+32-5
src/repo/upload_blob.rs
···44use axum::response::Response;
5566use crate::AppState;
77-use crate::auth::Claims;
77+use crate::auth::XrpcClaims;
88use crate::error::AppError;
99use crate::rate_limit::CheckResult;
1010···13131414pub async fn upload_blob(
1515 State(state): State<AppState>,
1616- claims: Claims,
1616+ xrpc_claims: XrpcClaims,
1717 headers: HeaderMap,
1818 body: Bytes,
1919) -> Result<Response, AppError> {
2020+ let claims = xrpc_claims
2121+ .0
2222+ .ok_or_else(|| AppError::Auth("uploadBlob requires DPoP authentication".into()))?;
2023 let check = if let Some(client_key) = claims.client_key() {
2124 let cost = state
2225 .rate_limiter
···3942 });
4043 }
41444242- let session = get_oauth_session(&state, claims.did()).await?;
4343-4445 let content_type = headers
4546 .get("content-type")
4647 .and_then(|v| v.to_str().ok())
4748 .unwrap_or("application/octet-stream");
48494949- let mut response = pds_post_blob(&state, &session, content_type, body).await?;
5050+ let mut response = if let Some(client_key) = claims.client_key() {
5151+ let encryption_key = state
5252+ .config
5353+ .token_encryption_key
5454+ .as_ref()
5555+ .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?;
5656+5757+ let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?;
5858+5959+ let resp = crate::oauth::pds_write::dpop_pds_post_blob(
6060+ &state.http,
6161+ &state.db,
6262+ state.db_backend,
6363+ encryption_key,
6464+ &state.config.plc_url,
6565+ &api_client_id,
6666+ claims.did(),
6767+ content_type,
6868+ body,
6969+ )
7070+ .await?;
7171+7272+ crate::repo::forward_pds_response(resp).await?
7373+ } else {
7474+ let session = get_oauth_session(&state, claims.did()).await?;
7575+ pds_post_blob(&state, &session, content_type, body).await?
7676+ };
50775178 if let Some(CheckResult::Allowed {
5279 remaining,