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 client: reqwest::Client,
25}
26
27impl TokenVerifier {
28 pub fn new() -> Self {
29 let client = reqwest::Client::builder()
30 .user_agent(format!(
31 "microcosm pocket v{} (dev: @bad-example.com)",
32 env!("CARGO_PKG_VERSION")
33 ))
34 .no_proxy()
35 .timeout(Duration::from_secs(12)) // slingshot timeout is 10s
36 .build()
37 .unwrap();
38 Self { client }
39 }
40
41 pub async fn verify(
42 &self,
43 expected_lxm: &str,
44 token: &str,
45 ) -> Result<(String, 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 let Some(mut aud) = aud.strip_prefix("did:web:") else {
122 return Err(VerifyError::VerificationFailed("expected a did:web aud"));
123 };
124 if let Some((aud_without_hash, _)) = aud.split_once("#") {
125 log::warn!("aud claim is missing service id fragment: {aud:?}");
126 aud = aud_without_hash;
127 }
128 let Some(lxm) = claims.custom.get("lxm") else {
129 return Err(VerifyError::VerificationFailed("missing lxm"));
130 };
131 if lxm != expected_lxm {
132 return Err(VerifyError::VerificationFailed("wrong lxm"));
133 }
134
135 Ok((did.to_string(), aud.to_string()))
136 }
137}
138
139impl Default for TokenVerifier {
140 fn default() -> Self {
141 Self::new()
142 }
143}