CLI app for developers prototyping atproto functionality
1//! Minimal hand-rolled JWT (RFC 7515 compact JWS) encoder and decoder for
2//! atproto service-auth.
3//!
4//! This module exists to avoid pulling a full JWT library for a handful of
5//! tightly-scoped use cases: minting self-mint JWTs for labeler conformance
6//! tests, and decoding them in tests to verify round-trip correctness.
7//! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature
8//! encoding, unpadded base64url segments, UTF-8 JSON payloads.
9
10// pattern: Functional Core
11
12use base64::Engine;
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::common::identity::AnySigningKey;
18
19#[cfg(test)]
20use crate::common::identity::{AnySignature, AnySignatureError, AnyVerifyingKey};
21
22/// Compact JWS header for atproto service-auth tokens.
23///
24/// Field names map 1:1 to the JWS wire format (RFC 7515 §4.1). Do NOT
25/// rename without updating `serde` attributes — atproto wire format
26/// requires exactly `alg` and `typ`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct JwtHeader {
29 /// Algorithm identifier: "ES256K" (secp256k1) or "ES256" (P-256).
30 pub alg: String,
31 /// Token type; always "JWT".
32 pub typ: String,
33}
34
35impl JwtHeader {
36 /// Build a header for the given signing key, setting `alg` to match the
37 /// curve and `typ` to `"JWT"`.
38 pub fn for_signing_key(key: &AnySigningKey) -> Self {
39 Self {
40 alg: key.jwt_alg().to_string(),
41 typ: "JWT".to_string(),
42 }
43 }
44}
45
46/// Atproto service-auth JWT claims.
47///
48/// Fields match the atproto inter-service authentication spec:
49/// <https://atproto.com/specs/xrpc#inter-service-authentication>. `nbf` is
50/// deliberately omitted — the spec does not require it and some servers
51/// reject unexpected claims.
52///
53/// **Field names are wire-format-critical:** `iss`, `aud`, `exp`, `iat`,
54/// `lxm`, `jti` are the exact JSON keys atproto labelers expect. Do NOT
55/// rename without adding `#[serde(rename = "...")]` attributes.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct JwtClaims {
58 /// Issuer DID (e.g., `did:web:127.0.0.1%3A5000`).
59 pub iss: String,
60 /// Audience — the target service's DID, bare (no `#fragment`).
61 pub aud: String,
62 /// Expiration, UNIX seconds.
63 pub exp: i64,
64 /// Issued-at, UNIX seconds.
65 pub iat: i64,
66 /// Lexicon method NSID the token authorizes (e.g.,
67 /// `com.atproto.moderation.createReport`).
68 pub lxm: String,
69 /// Random nonce to prevent replay — hex string, 32 chars (16 bytes).
70 pub jti: String,
71}
72
73/// Errors from JWT encode.
74///
75/// **Not user-rendered:** these errors only surface inside tests and
76/// library helpers. They deliberately do NOT derive `miette::Diagnostic`
77/// with stable codes — the stage converts any failure into a
78/// `CreateReportStageError::Transport` or a specific check SpecViolation
79/// before rendering. If a future caller needs one of these variants
80/// rendered to the user, they must wrap it in a stage-local diagnostic
81/// with a proper `code = "labeler::..."` string.
82#[derive(Debug, Error)]
83pub(crate) enum EncodeError {
84 /// JSON serialization of header or claims failed (should not happen for
85 /// well-formed structs).
86 #[error("JSON encode failed")]
87 JsonEncode(serde_json::Error),
88}
89
90/// Errors from JWT decode.
91///
92/// **Not user-rendered:** these errors only surface inside tests and
93/// library helpers. They deliberately do NOT derive `miette::Diagnostic`
94/// with stable codes — the stage converts any failure into a
95/// `CreateReportStageError::Transport` or a specific check SpecViolation
96/// before rendering. If a future caller needs one of these variants
97/// rendered to the user, they must wrap it in a stage-local diagnostic
98/// with a proper `code = "labeler::..."` string.
99#[cfg(test)]
100#[derive(Debug, Error)]
101pub(crate) enum JwtError {
102 /// Compact form was not three `.`-separated base64url segments.
103 #[error("malformed compact JWT: expected three segments")]
104 MalformedCompact,
105 /// A base64url segment failed to decode.
106 #[error("base64url decode failed for {segment}")]
107 Base64Decode {
108 /// Which segment failed: "header", "claims", or "signature".
109 segment: &'static str,
110 /// Underlying base64 error.
111 #[source]
112 source: base64::DecodeError,
113 },
114 /// A segment decoded to valid bytes but invalid JSON.
115 #[error("JSON decode failed for {segment}")]
116 JsonDecode {
117 /// Which segment failed: "header" or "claims".
118 segment: &'static str,
119 /// Underlying serde_json error.
120 #[source]
121 source: serde_json::Error,
122 },
123 /// Signature was not exactly 64 bytes.
124 #[error("signature was {actual} bytes; expected 64")]
125 SignatureLength {
126 /// Actual length in bytes.
127 actual: usize,
128 },
129 /// Signature had the correct length but invalid scalar values (e.g., r or s
130 /// is 0 or exceeds the curve order).
131 #[error("signature has invalid scalar values")]
132 InvalidSignatureScalar,
133 /// The algorithm identifier in the header is not recognized.
134 #[error("unsupported JWT alg `{alg}` (expected ES256 or ES256K)")]
135 UnsupportedAlg {
136 /// The unrecognized algorithm string.
137 alg: String,
138 },
139 /// Underlying ECDSA verification failure (e.g., curve mismatch).
140 #[error("signature verification failed")]
141 SignatureVerify(#[from] AnySignatureError),
142}
143
144/// Encode a JWT in compact form: `base64url(header).base64url(claims).base64url(signature)`.
145///
146/// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256
147/// prehash under the supplied key. Returns the full compact token string.
148pub(crate) fn encode_compact(
149 header: &JwtHeader,
150 claims: &JwtClaims,
151 signer: &AnySigningKey,
152) -> Result<String, EncodeError> {
153 let header_json = serde_json::to_vec(header).map_err(EncodeError::JsonEncode)?;
154 let claims_json = serde_json::to_vec(claims).map_err(EncodeError::JsonEncode)?;
155 let header_b64 = URL_SAFE_NO_PAD.encode(&header_json);
156 let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json);
157 let signing_input = format!("{header_b64}.{claims_b64}");
158 let sig = signer.sign(signing_input.as_bytes());
159 let sig_bytes = sig.to_jws_bytes();
160 let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes);
161 Ok(format!("{header_b64}.{claims_b64}.{sig_b64}"))
162}
163
164/// Decode a compact JWT into `(header, claims, signature_bytes)`.
165///
166/// Does NOT verify the signature — use `verify_compact` for that. This helper
167/// is primarily for test round-tripping and for negative-test assertions
168/// (e.g., "the minted token has the expected `alg` header").
169#[cfg(test)]
170fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> {
171 let parts: Vec<&str> = token.split('.').collect();
172 if parts.len() != 3 {
173 return Err(JwtError::MalformedCompact);
174 }
175 let header_b64 = parts[0];
176 let claims_b64 = parts[1];
177 let sig_b64 = parts[2];
178 let header_bytes =
179 URL_SAFE_NO_PAD
180 .decode(header_b64)
181 .map_err(|source| JwtError::Base64Decode {
182 segment: "header",
183 source,
184 })?;
185 let claims_bytes =
186 URL_SAFE_NO_PAD
187 .decode(claims_b64)
188 .map_err(|source| JwtError::Base64Decode {
189 segment: "claims",
190 source,
191 })?;
192 let sig_bytes = URL_SAFE_NO_PAD
193 .decode(sig_b64)
194 .map_err(|source| JwtError::Base64Decode {
195 segment: "signature",
196 source,
197 })?;
198 let header: JwtHeader =
199 serde_json::from_slice(&header_bytes).map_err(|source| JwtError::JsonDecode {
200 segment: "header",
201 source,
202 })?;
203 let claims: JwtClaims =
204 serde_json::from_slice(&claims_bytes).map_err(|source| JwtError::JsonDecode {
205 segment: "claims",
206 source,
207 })?;
208 Ok((header, claims, sig_bytes))
209}
210
211/// Verify a compact JWT against the given verifying key. Does NOT check
212/// claim values (exp/aud/lxm) — that is the labeler's job in production,
213/// or the stage's assertion job in tests. Only verifies the signature.
214#[cfg(test)]
215pub(crate) fn verify_compact(
216 token: &str,
217 vkey: &AnyVerifyingKey,
218) -> Result<(JwtHeader, JwtClaims), JwtError> {
219 let (header, claims, sig_bytes) = decode_compact(token)?;
220 let expected_alg = match vkey {
221 AnyVerifyingKey::K256(_) => "ES256K",
222 AnyVerifyingKey::P256(_) => "ES256",
223 };
224 if header.alg != expected_alg {
225 return Err(JwtError::UnsupportedAlg {
226 alg: header.alg.clone(),
227 });
228 }
229 if sig_bytes.len() != 64 {
230 return Err(JwtError::SignatureLength {
231 actual: sig_bytes.len(),
232 });
233 }
234 let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().expect("len checked above");
235 let any_sig = match vkey {
236 AnyVerifyingKey::K256(_) => {
237 let sig = k256::ecdsa::Signature::from_bytes(&sig_array.into())
238 .map_err(|_| JwtError::InvalidSignatureScalar)?;
239 AnySignature::K256(sig)
240 }
241 AnyVerifyingKey::P256(_) => {
242 let sig = p256::ecdsa::Signature::from_bytes(&sig_array.into())
243 .map_err(|_| JwtError::InvalidSignatureScalar)?;
244 AnySignature::P256(sig)
245 }
246 };
247 // Recompute the signing input and verify.
248 let dot = token
249 .rfind('.')
250 .expect("three-segment token has a last dot");
251 let signing_input = &token[..dot];
252 use sha2::{Digest, Sha256};
253 let prehash: [u8; 32] = Sha256::digest(signing_input.as_bytes()).into();
254 vkey.verify_prehash(&prehash, &any_sig)?;
255 Ok((header, claims))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use k256::ecdsa::SigningKey as K256SigningKey;
262 use p256::ecdsa::SigningKey as P256SigningKey;
263
264 #[test]
265 fn encode_decode_roundtrip_k256() {
266 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
267 let vkey = key.verifying_key();
268 let header = JwtHeader::for_signing_key(&key);
269 let claims = JwtClaims {
270 iss: "did:web:127.0.0.1%3A5000".to_string(),
271 aud: "did:plc:test".to_string(),
272 exp: 2000000000,
273 iat: 1700000000,
274 lxm: "com.atproto.moderation.createReport".to_string(),
275 jti: "0123456789abcdef".to_string(),
276 };
277
278 let token = encode_compact(&header, &claims, &key).expect("encode succeeds");
279 let (decoded_header, decoded_claims) =
280 verify_compact(&token, &vkey).expect("verify succeeds");
281
282 assert_eq!(decoded_header.alg, "ES256K");
283 assert_eq!(decoded_claims.iss, claims.iss);
284 assert_eq!(decoded_claims.aud, claims.aud);
285 }
286
287 #[test]
288 fn encode_decode_roundtrip_p256() {
289 let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
290 let vkey = key.verifying_key();
291 let header = JwtHeader::for_signing_key(&key);
292 let claims = JwtClaims {
293 iss: "did:web:example.com".to_string(),
294 aud: "did:plc:test".to_string(),
295 exp: 2000000000,
296 iat: 1700000000,
297 lxm: "com.atproto.moderation.createReport".to_string(),
298 jti: "fedcba9876543210".to_string(),
299 };
300
301 let token = encode_compact(&header, &claims, &key).expect("encode succeeds");
302 let (decoded_header, decoded_claims) =
303 verify_compact(&token, &vkey).expect("verify succeeds");
304
305 assert_eq!(decoded_header.alg, "ES256");
306 assert_eq!(decoded_claims.aud, claims.aud);
307 }
308
309 #[test]
310 fn encode_decode_roundtrip_tampered_claims_fails() {
311 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
312 let vkey = key.verifying_key();
313 let header = JwtHeader::for_signing_key(&key);
314 let claims = JwtClaims {
315 iss: "did:web:127.0.0.1%3A5000".to_string(),
316 aud: "did:plc:test".to_string(),
317 exp: 2000000000,
318 iat: 1700000000,
319 lxm: "com.atproto.moderation.createReport".to_string(),
320 jti: "0123456789abcdef".to_string(),
321 };
322
323 let token = encode_compact(&header, &claims, &key).expect("encode succeeds");
324 let parts: Vec<&str> = token.split('.').collect();
325 assert_eq!(parts.len(), 3);
326
327 // Tamper with claims segment.
328 let tampered = format!("{}.YWJj.{}", parts[0], parts[2]);
329 let result = verify_compact(&tampered, &vkey);
330 assert!(result.is_err());
331 }
332
333 #[test]
334 fn decode_compact_malformed_two_segments() {
335 let result = decode_compact("header.claims");
336 assert!(matches!(result, Err(JwtError::MalformedCompact)));
337 }
338
339 #[test]
340 fn decode_compact_malformed_four_segments() {
341 // Tokens with four or more segments are malformed.
342 let result = decode_compact("YQ.Yg.Yw.ZA");
343 assert!(matches!(result, Err(JwtError::MalformedCompact)));
344 }
345
346 #[test]
347 fn decode_compact_invalid_base64() {
348 let result = decode_compact("!!!.claims.sig");
349 assert!(matches!(
350 result,
351 Err(JwtError::Base64Decode {
352 segment: "header",
353 ..
354 })
355 ));
356 }
357
358 #[test]
359 fn verify_compact_curve_mismatch() {
360 let k256_key =
361 AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
362 let p256_key =
363 AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
364
365 let header = JwtHeader::for_signing_key(&k256_key);
366 let claims = JwtClaims {
367 iss: "did:web:test".to_string(),
368 aud: "did:plc:test".to_string(),
369 exp: 2000000000,
370 iat: 1700000000,
371 lxm: "com.atproto.moderation.createReport".to_string(),
372 jti: "0123456789abcdef".to_string(),
373 };
374
375 let token = encode_compact(&header, &claims, &k256_key).expect("encode succeeds");
376 let p256_vkey = p256_key.verifying_key();
377
378 // Trying to verify a K256-signed token with a P256 key should fail.
379 let result = verify_compact(&token, &p256_vkey);
380 assert!(result.is_err());
381 }
382
383 #[test]
384 fn encode_compact_produces_valid_structure() {
385 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
386 let header = JwtHeader::for_signing_key(&key);
387 let claims = JwtClaims {
388 iss: "did:web:test".to_string(),
389 aud: "did:plc:test".to_string(),
390 exp: 2000000000,
391 iat: 1700000000,
392 lxm: "com.atproto.moderation.createReport".to_string(),
393 jti: "0123456789abcdef".to_string(),
394 };
395
396 let token = encode_compact(&header, &claims, &key).expect("encode succeeds");
397
398 // Token must have exactly 3 segments.
399 let parts: Vec<&str> = token.split('.').collect();
400 assert_eq!(parts.len(), 3);
401
402 // Each segment must decode as valid base64url.
403 for (i, segment) in parts.iter().enumerate() {
404 let segment_name = ["header", "claims", "signature"][i];
405 let result = URL_SAFE_NO_PAD.decode(segment);
406 assert!(
407 result.is_ok(),
408 "segment {segment_name} failed to decode as base64url"
409 );
410 }
411 }
412
413 #[test]
414 fn verify_compact_invalid_signature_scalar_k256() {
415 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed"));
416 let vkey = key.verifying_key();
417 let header = JwtHeader::for_signing_key(&key);
418 let claims = JwtClaims {
419 iss: "did:web:127.0.0.1%3A5000".to_string(),
420 aud: "did:plc:test".to_string(),
421 exp: 2000000000,
422 iat: 1700000000,
423 lxm: "com.atproto.moderation.createReport".to_string(),
424 jti: "0123456789abcdef".to_string(),
425 };
426
427 let token = encode_compact(&header, &claims, &key).expect("encode succeeds");
428 let parts: Vec<&str> = token.split('.').collect();
429 assert_eq!(parts.len(), 3);
430
431 // Replace the signature with all zeros (64 bytes, base64url-encoded).
432 let zero_sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
433 let tampered = format!("{}.{}.{}", parts[0], parts[1], zero_sig);
434
435 let result = verify_compact(&tampered, &vkey);
436 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar)));
437 }
438
439 #[test]
440 fn verify_compact_invalid_signature_scalar_p256() {
441 let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed"));
442 let vkey = key.verifying_key();
443 let header = JwtHeader::for_signing_key(&key);
444 let claims = JwtClaims {
445 iss: "did:web:example.com".to_string(),
446 aud: "did:plc:test".to_string(),
447 exp: 2000000000,
448 iat: 1700000000,
449 lxm: "com.atproto.moderation.createReport".to_string(),
450 jti: "fedcba9876543210".to_string(),
451 };
452
453 let token = encode_compact(&header, &claims, &key).expect("encode succeeds");
454 let parts: Vec<&str> = token.split('.').collect();
455 assert_eq!(parts.len(), 3);
456
457 // Replace the signature with all zeros (64 bytes, base64url-encoded).
458 let zero_sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
459 let tampered = format!("{}.{}.{}", parts[0], parts[1], zero_sig);
460
461 let result = verify_compact(&tampered, &vkey);
462 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar)));
463 }
464
465 // Property-based roundtrip tests pinning the invariant that
466 // `verify_compact(encode_compact(claims, key), key.verifying_key())`
467 // recovers the same claim payload, for every well-formed claim set
468 // and every signing key generated from a 32-byte seed.
469 mod pbt {
470 use super::*;
471 use proptest::prelude::*;
472 use rand_chacha::ChaCha20Rng;
473 use rand_core::SeedableRng;
474
475 // 16 hex chars matches the format atproto labelers expect for
476 // `jti` and is what `RelyingParty::new_jti` produces.
477 const JTI_REGEX: &str = "[0-9a-f]{16}";
478
479 proptest! {
480 #![proptest_config(ProptestConfig::with_cases(32))]
481
482 #[test]
483 fn encode_verify_compact_roundtrip_k256(
484 seed in any::<[u8; 32]>(),
485 jti in JTI_REGEX,
486 iat in 1_500_000_000i64..2_500_000_000i64,
487 exp_offset in 1i64..86_400i64,
488 ) {
489 let mut rng = ChaCha20Rng::from_seed(seed);
490 let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng));
491 let vkey = signing.verifying_key();
492 let header = JwtHeader::for_signing_key(&signing);
493 let claims = JwtClaims {
494 iss: "did:web:test".to_string(),
495 aud: "did:plc:test".to_string(),
496 exp: iat + exp_offset,
497 iat,
498 lxm: "com.atproto.moderation.createReport".to_string(),
499 jti,
500 };
501
502 let token = encode_compact(&header, &claims, &signing)
503 .expect("encode_compact must succeed");
504 let (decoded_header, decoded_claims) = verify_compact(&token, &vkey)
505 .expect("verify_compact must accept a freshly produced token");
506
507 prop_assert_eq!(decoded_header.alg, "ES256K");
508 prop_assert_eq!(decoded_header.typ, header.typ);
509 prop_assert_eq!(decoded_claims.iss, claims.iss);
510 prop_assert_eq!(decoded_claims.aud, claims.aud);
511 prop_assert_eq!(decoded_claims.exp, claims.exp);
512 prop_assert_eq!(decoded_claims.iat, claims.iat);
513 prop_assert_eq!(decoded_claims.lxm, claims.lxm);
514 prop_assert_eq!(decoded_claims.jti, claims.jti);
515 }
516
517 #[test]
518 fn encode_verify_compact_roundtrip_p256(
519 seed in any::<[u8; 32]>(),
520 jti in JTI_REGEX,
521 iat in 1_500_000_000i64..2_500_000_000i64,
522 exp_offset in 1i64..86_400i64,
523 ) {
524 let mut rng = ChaCha20Rng::from_seed(seed);
525 let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng));
526 let vkey = signing.verifying_key();
527 let header = JwtHeader::for_signing_key(&signing);
528 let claims = JwtClaims {
529 iss: "did:web:example.com".to_string(),
530 aud: "did:plc:test".to_string(),
531 exp: iat + exp_offset,
532 iat,
533 lxm: "com.atproto.moderation.createReport".to_string(),
534 jti,
535 };
536
537 let token = encode_compact(&header, &claims, &signing)
538 .expect("encode_compact must succeed");
539 let (decoded_header, decoded_claims) = verify_compact(&token, &vkey)
540 .expect("verify_compact must accept a freshly produced token");
541
542 prop_assert_eq!(decoded_header.alg, "ES256");
543 prop_assert_eq!(decoded_header.typ, header.typ);
544 prop_assert_eq!(decoded_claims.iss, claims.iss);
545 prop_assert_eq!(decoded_claims.aud, claims.aud);
546 prop_assert_eq!(decoded_claims.exp, claims.exp);
547 prop_assert_eq!(decoded_claims.iat, claims.iat);
548 prop_assert_eq!(decoded_claims.lxm, claims.lxm);
549 prop_assert_eq!(decoded_claims.jti, claims.jti);
550 }
551 }
552 }
553}