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
4f8947e119ab1096310f5b5d38bd2b5bdd133938Parent: 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-cospan3 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: