fix: verify push tokens against appview JWKS, not user's DID doc Push tokens are signed by the appview's DPoP key (ES256), with kid identifying the key in the appview's JWKS. The node was incorrectly trying to verify them against the sub claim's DID document, which typically only has a secp256k1 verification method — causing "no suitable verification method found" for every push. When the JWT header has a kid, fetch the appview's JWKS from APPVIEW_JWKS_URL and verify against that. Fall back to DID doc resolution for tokens without kid (e.g. PDS-signed tokens).

Author: Aaron Steven White
Commit 4f8947e119ab1096310f5b5d38bd2b5bdd133938
Parent: 5768add139
Structural diff unavailable

These commits were pushed via plain git push, so no pre-parsed schemas are available. Install git-remote-cospan and re-push via panproto:// to see scope-level changes, breaking change detection, and semantic diffs.

brew install panproto/tap/git-remote-cospan
3 files changed +86 -13
@@ -85,7 +85,7 @@ struct VerificationMethod {
8585 /// Extract the DID from a Bearer token with full verification.
8686 async fn extract_did_from_bearer(
8787     token: &str,
88-    _state: &std::sync::Arc<NodeState>,
88+    state: &std::sync::Arc<NodeState>,
8989 ) -> Result<String, NodeError> {
9090     // Dev mode: accept raw DID strings when COSPAN_DEV_AUTH is set.
9191     let dev_auth = std::env::var("COSPAN_DEV_AUTH").is_ok();
@@ -118,18 +118,22 @@ async fn extract_did_from_bearer(
118118         ));
119119     }
120120 
121-    // Resolve the DID document to get the signing public key.
122-    let did_doc = resolve_did_document(did)
123-        .await
124-        .map_err(|e| NodeError::Unauthorized(format!("failed to resolve DID {did}: {e}")))?;
125-
126-    // Find a verification method with a JWK public key.
127-    let decoding_key = find_decoding_key(&did_doc, algorithm)
128-        .ok_or_else(|| NodeError::Unauthorized(format!(
129-            "no suitable verification method found in DID document for {did} (algorithm: {algorithm:?})"
130-        )))?;
121+    // Push tokens from the appview have a `kid` in the header and are
122+    // signed by the appview's DPoP key. Verify against the appview's
123+    // JWKS endpoint instead of the user's DID document.
124+    let decoding_key = if let Some(ref kid) = header.kid {
125+        if let Some(key) = try_appview_jwks(state, kid, algorithm).await {
126+            tracing::debug!(kid, "verified push token via appview JWKS");
127+            key
128+        } else {
129+            // Fall back to DID document resolution.
130+            resolve_key_from_did(did, algorithm).await?
131+        }
132+    } else {
133+        resolve_key_from_did(did, algorithm).await?
134+    };
131135 
132-    // Now verify the signature with the real key.
136+    // Verify the signature with the real key.
133137     let mut verified_validation = Validation::new(algorithm);
134138     verified_validation.set_required_spec_claims(&["sub"]);
135139     verified_validation.validate_exp = true;
@@ -152,6 +156,71 @@ async fn extract_did_from_bearer(
152156     Ok(verified.claims.sub)
153157 }
154158 
159+/// Resolve the signing key from the user's DID document.
160+async fn resolve_key_from_did(did: &str, algorithm: Algorithm) -> Result<DecodingKey, NodeError> {
161+    let did_doc = resolve_did_document(did)
162+        .await
163+        .map_err(|e| NodeError::Unauthorized(format!("failed to resolve DID {did}: {e}")))?;
164+
165+    find_decoding_key(&did_doc, algorithm).ok_or_else(|| {
166+        NodeError::Unauthorized(format!(
167+            "no suitable verification method found in DID document for {did} (algorithm: {algorithm:?})"
168+        ))
169+    })
170+}
171+
172+/// Try to verify against the appview's JWKS endpoint.
173+///
174+/// Returns the matching `DecodingKey` if the JWKS URL is configured
175+/// and a key with the given `kid` and algorithm is found.
176+async fn try_appview_jwks(
177+    state: &NodeState,
178+    kid: &str,
179+    algorithm: Algorithm,
180+) -> Option<DecodingKey> {
181+    let jwks_url = state
182+        .config
183+        .auth
184+        .appview_jwks_url
185+        .as_deref()
186+        .or_else(|| {
187+            // Default: derive from APPVIEW_URL env var.
188+            None
189+        })?;
190+
191+    let http = reqwest::Client::new();
192+    let resp = http.get(jwks_url).send().await.ok()?;
193+    if !resp.status().is_success() {
194+        tracing::warn!(url = jwks_url, status = %resp.status(), "JWKS fetch failed");
195+        return None;
196+    }
197+
198+    let jwks: JwksDocument = resp.json().await.ok()?;
199+    for key in &jwks.keys {
200+        if key.kid.as_deref() == Some(kid) {
201+            if let Some(decoding_key) = try_decoding_key_from_jwk(&key.raw, algorithm) {
202+                return Some(decoding_key);
203+            }
204+        }
205+    }
206+
207+    tracing::warn!(kid, "no matching key in appview JWKS");
208+    None
209+}
210+
211+/// JWKS document structure.
212+#[derive(Debug, Deserialize)]
213+struct JwksDocument {
214+    keys: Vec<JwksKey>,
215+}
216+
217+#[derive(Debug, Deserialize)]
218+struct JwksKey {
219+    kid: Option<String>,
220+    #[serde(flatten)]
221+    raw: serde_json::Value,
222+}
223+
155224 /// Resolve a DID to its DID document.
156225 async fn resolve_did_document(did: &str) -> Result<DidDocument, String> {
157226     let http = reqwest::Client::new();
@@ -88,7 +88,10 @@ impl NodeConfig {
8888                             .join(".cospan")
8989                     }),
9090                 validation: ValidationConfig::default(),
91-                auth: AuthConfig::default(),
91+                auth: AuthConfig {
92+                    appview_jwks_url: std::env::var("APPVIEW_JWKS_URL").ok(),
93+                    ..Default::default()
94+                },
9295             })
9396         }
9497     }
@@ -85,6 +85,7 @@ services:
8585       NODE_DID: ${NODE_DID:?NODE_DID is required}
8686       NODE_LISTEN: "0.0.0.0:3001"
8787       COSPAN_DATA_DIR: /data
88+      APPVIEW_JWKS_URL: "https://cospan.dev/.well-known/jwks.json"
8889     networks:
8990       - cospan
9091     deploy:
cospan · schematic version control on atproto built on AT Protocol