atd_runtime/ucan/
parse.rs1use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12
13use super::error::UcanParseError;
14use super::types::{UcanHeader, UcanPayload};
15
16pub fn parse_jwt(token: &str) -> Result<UcanPayload, UcanParseError> {
35 let parts: Vec<&str> = token.split('.').collect();
36 if parts.len() != 3 {
37 return Err(UcanParseError::MalformedJwt(parts.len()));
38 }
39 let (header_b64, payload_b64, _sig_b64) = (parts[0], parts[1], parts[2]);
40
41 let header_bytes = URL_SAFE_NO_PAD
43 .decode(header_b64)
44 .map_err(|e| UcanParseError::base64("header", e))?;
45 let header: UcanHeader =
46 serde_json::from_slice(&header_bytes).map_err(|e| UcanParseError::json("header", e))?;
47
48 if header.alg != "EdDSA" {
49 return Err(UcanParseError::UnsupportedAlg(header.alg));
50 }
51 if header.typ != "ucan/1.0+jwt" {
52 return Err(UcanParseError::UnsupportedTyp(header.typ));
53 }
54 if header.ucv != "1.0" {
55 return Err(UcanParseError::UnsupportedUcv(header.ucv));
56 }
57
58 let payload_bytes = URL_SAFE_NO_PAD
60 .decode(payload_b64)
61 .map_err(|e| UcanParseError::base64("payload", e))?;
62 let payload: UcanPayload =
63 serde_json::from_slice(&payload_bytes).map_err(|e| UcanParseError::json("payload", e))?;
64
65 if payload.cmd != "atd-cap" {
66 return Err(UcanParseError::NonAtdCap(payload.cmd));
67 }
68 require_did_key(&payload.iss, "iss")?;
69 require_did_key(&payload.aud, "aud")?;
70
71 Ok(payload)
72}
73
74fn require_did_key(did: &str, field: &'static str) -> Result<(), UcanParseError> {
78 if did.starts_with("did:key:z") {
79 Ok(())
80 } else {
81 Err(UcanParseError::UnsupportedDidMethod {
82 field,
83 did: did.to_string(),
84 })
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
97 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
98 use serde_json::json;
99
100 fn build_jwt(header: serde_json::Value, payload: serde_json::Value) -> String {
101 let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
102 let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
103 let s = URL_SAFE_NO_PAD.encode(b"signature-placeholder");
104 format!("{h}.{p}.{s}")
105 }
106
107 fn canonical_header() -> serde_json::Value {
108 json!({ "alg": "EdDSA", "typ": "ucan/1.0+jwt", "ucv": "1.0" })
109 }
110
111 fn canonical_payload() -> serde_json::Value {
112 json!({
113 "iss": "did:key:z6MkA_abbreviated",
114 "aud": "did:key:z6MkB_abbreviated",
115 "sub": "did:key:z6MkU_abbreviated",
116 "cmd": "atd-cap",
117 "args": { "caps": ["records:read"], "with": [{"patient": "Patient/X"}] },
118 "nonce": "Mv3K0000000000000000",
119 "exp": 9999999999_i64
120 })
121 }
122
123 #[test]
124 fn parse_well_formed_token_succeeds() {
125 let tok = build_jwt(canonical_header(), canonical_payload());
126 let parsed = parse_jwt(&tok).expect("well-formed UCAN should parse");
127 assert_eq!(parsed.iss, "did:key:z6MkA_abbreviated");
128 assert_eq!(parsed.aud, "did:key:z6MkB_abbreviated");
129 assert_eq!(parsed.cmd, "atd-cap");
130 assert_eq!(parsed.args.caps, vec!["records:read"]);
131 assert_eq!(parsed.exp, 9999999999_i64);
132 }
133
134 #[test]
135 fn parse_unsupported_alg_rejects() {
136 let mut h = canonical_header();
137 h["alg"] = json!("RS256");
138 let tok = build_jwt(h, canonical_payload());
139 match parse_jwt(&tok) {
140 Err(UcanParseError::UnsupportedAlg(alg)) => assert_eq!(alg, "RS256"),
141 other => panic!("expected UnsupportedAlg, got {other:?}"),
142 }
143 }
144
145 #[test]
146 fn parse_non_did_key_issuer_rejects() {
147 let mut p = canonical_payload();
148 p["iss"] = json!("did:web:example.org");
149 let tok = build_jwt(canonical_header(), p);
150 match parse_jwt(&tok) {
151 Err(UcanParseError::UnsupportedDidMethod { field, did }) => {
152 assert_eq!(field, "iss");
153 assert_eq!(did, "did:web:example.org");
154 }
155 other => panic!("expected UnsupportedDidMethod(iss), got {other:?}"),
156 }
157 }
158
159 #[test]
160 fn parse_malformed_jwt_rejects() {
161 let tok = "abc.def";
163 match parse_jwt(tok) {
164 Err(UcanParseError::MalformedJwt(n)) => assert_eq!(n, 2),
165 other => panic!("expected MalformedJwt(2), got {other:?}"),
166 }
167
168 let tok4 = "a.b.c.d";
170 match parse_jwt(tok4) {
171 Err(UcanParseError::MalformedJwt(n)) => assert_eq!(n, 4),
172 other => panic!("expected MalformedJwt(4), got {other:?}"),
173 }
174 }
175
176 #[test]
179 fn parse_non_atd_cap_command_rejects() {
180 let mut p = canonical_payload();
181 p["cmd"] = json!("/bsky/post");
182 let tok = build_jwt(canonical_header(), p);
183 match parse_jwt(&tok) {
184 Err(UcanParseError::NonAtdCap(c)) => assert_eq!(c, "/bsky/post"),
185 other => panic!("expected NonAtdCap, got {other:?}"),
186 }
187 }
188
189 #[test]
190 fn parse_wrong_typ_rejects() {
191 let mut h = canonical_header();
192 h["typ"] = json!("JWT");
193 let tok = build_jwt(h, canonical_payload());
194 assert!(matches!(
195 parse_jwt(&tok),
196 Err(UcanParseError::UnsupportedTyp(_))
197 ));
198 }
199
200 #[test]
201 fn parse_wrong_ucv_rejects() {
202 let mut h = canonical_header();
203 h["ucv"] = json!("0.9");
204 let tok = build_jwt(h, canonical_payload());
205 assert!(matches!(
206 parse_jwt(&tok),
207 Err(UcanParseError::UnsupportedUcv(_))
208 ));
209 }
210
211 #[test]
212 fn parse_garbage_base64_in_header_rejects() {
213 let tok = "!!!.aaaa.bbbb";
214 assert!(matches!(
215 parse_jwt(tok),
216 Err(UcanParseError::Base64Decode {
217 segment: "header",
218 ..
219 })
220 ));
221 }
222
223 #[test]
224 fn parse_audience_non_did_key_rejects() {
225 let mut p = canonical_payload();
226 p["aud"] = json!("did:plc:abcdef");
227 let tok = build_jwt(canonical_header(), p);
228 match parse_jwt(&tok) {
229 Err(UcanParseError::UnsupportedDidMethod { field, .. }) => assert_eq!(field, "aud"),
230 other => panic!("expected UnsupportedDidMethod(aud), got {other:?}"),
231 }
232 }
233}