forked from
microcosm.blue/microcosm-rs
Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
1use atrium_crypto::did::parse_multikey;
2use atrium_crypto::verify::Verifier;
3use jwt_compact::UntrustedToken;
4use serde::Deserialize;
5use std::collections::HashMap;
6use std::time::Duration;
7use thiserror::Error;
8
9#[derive(Debug, Deserialize)]
10struct MiniDoc {
11 signing_key: String,
12 did: String,
13}
14
15#[derive(Error, Debug)]
16pub enum VerifyError {
17 #[error("The cross-service authorization token failed verification: {0}")]
18 VerificationFailed(&'static str),
19 #[error("Error trying to resolve the DID to a signing key, retry in a moment: {0}")]
20 ResolutionFailed(&'static str),
21}
22
23pub struct TokenVerifier {
24 domain: String,
25 client: reqwest::Client,
26}
27
28impl TokenVerifier {
29 pub fn new(domain: &str) -> Self {
30 let client = reqwest::Client::builder()
31 .user_agent(format!(
32 "microcosm pocket v{} (dev: @bad-example.com)",
33 env!("CARGO_PKG_VERSION")
34 ))
35 .no_proxy()
36 .timeout(Duration::from_secs(12)) // slingshot timeout is 10s
37 .build()
38 .unwrap();
39 Self {
40 client,
41 domain: domain.to_string(),
42 }
43 }
44
45 pub async fn verify(&self, expected_lxm: &str, token: &str) -> Result<String, VerifyError> {
46 let untrusted = UntrustedToken::new(token).unwrap();
47
48 // danger! unfortunately we need to decode the DID from the jwt body before we have a public key to verify the jwt with
49 let Ok(untrusted_claims) =
50 untrusted.deserialize_claims_unchecked::<HashMap<String, String>>()
51 else {
52 return Err(VerifyError::VerificationFailed(
53 "could not deserialize jtw claims",
54 ));
55 };
56
57 // get the (untrusted!) claimed DID
58 let Some(untrusted_did) = untrusted_claims.custom.get("iss") else {
59 return Err(VerifyError::VerificationFailed(
60 "jwt must include the user's did in `iss`",
61 ));
62 };
63
64 // bail if it's not even a user-ish did
65 if !untrusted_did.starts_with("did:") {
66 return Err(VerifyError::VerificationFailed("iss should be a did"));
67 }
68 if untrusted_did.contains("#") {
69 return Err(VerifyError::VerificationFailed(
70 "iss should be a user did without a service identifier",
71 ));
72 }
73
74 let endpoint =
75 "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc";
76 let doc: MiniDoc = self
77 .client
78 .get(format!("{endpoint}?identifier={untrusted_did}"))
79 .send()
80 .await
81 .map_err(|_| VerifyError::ResolutionFailed("failed to fetch minidoc"))?
82 .error_for_status()
83 .map_err(|_| VerifyError::ResolutionFailed("non-ok response for minidoc"))?
84 .json()
85 .await
86 .map_err(|_| VerifyError::ResolutionFailed("failed to parse json to minidoc"))?;
87
88 // sanity check before we go ahead with this signing key
89 if doc.did != *untrusted_did {
90 return Err(VerifyError::VerificationFailed(
91 "wtf, resolveMiniDoc returned a doc for a different DID, slingshot bug",
92 ));
93 }
94
95 let Ok((alg, public_key)) = parse_multikey(&doc.signing_key) else {
96 return Err(VerifyError::VerificationFailed(
97 "could not parse signing key form minidoc",
98 ));
99 };
100
101 // i _guess_ we've successfully bootstrapped the verification of the jwt unless this fails
102 if let Err(e) = Verifier::default().verify(
103 alg,
104 &public_key,
105 &untrusted.signed_data,
106 untrusted.signature_bytes(),
107 ) {
108 log::warn!("jwt verification failed: {e}");
109 return Err(VerifyError::VerificationFailed(
110 "jwt signature verification failed",
111 ));
112 }
113
114 // past this point we're should have established trust. crossing ts and dotting is.
115 let did = &untrusted_did;
116 let claims = &untrusted_claims;
117
118 let Some(aud) = claims.custom.get("aud") else {
119 return Err(VerifyError::VerificationFailed("missing aud"));
120 };
121 if *aud != format!("did:web:{}#bsky_appview", self.domain) {
122 return Err(VerifyError::VerificationFailed("wrong aud"));
123 }
124 let Some(lxm) = claims.custom.get("lxm") else {
125 return Err(VerifyError::VerificationFailed("missing lxm"));
126 };
127 if lxm != expected_lxm {
128 return Err(VerifyError::VerificationFailed("wrong lxm"));
129 }
130
131 Ok(did.to_string())
132 }
133}