A library for ATProtocol identities.

feature: Added atproto-oauth-service-token tool

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

Changed files
+279 -14
crates
atproto-oauth
atproto-record
+1
Cargo.lock
··· 185 185 "atproto-identity", 186 186 "base64", 187 187 "chrono", 188 + "clap", 188 189 "ecdsa", 189 190 "elliptic-curve", 190 191 "k256",
+9
crates/atproto-oauth/Cargo.toml
··· 14 14 keywords.workspace = true 15 15 categories.workspace = true 16 16 17 + [[bin]] 18 + name = "atproto-oauth-service-token" 19 + test = false 20 + bench = false 21 + doc = true 22 + required-features = ["clap"] 23 + 17 24 [dependencies] 18 25 atproto-identity.workspace = true 19 26 ··· 41 48 tracing.workspace = true 42 49 ulid.workspace = true 43 50 51 + clap = { workspace = true, optional = true } 44 52 zeroize = { workspace = true, optional = true } 45 53 46 54 [features] 47 55 default = ["lru", "hickory-dns"] 48 56 lru = ["dep:lru"] 57 + clap = ["dep:clap"] 49 58 zeroize = ["dep:zeroize", "atproto-identity/zeroize"] 50 59 hickory-dns = ["atproto-identity/hickory-dns"] 51 60
+255
crates/atproto-oauth/src/bin/atproto-oauth-service-token.rs
··· 1 + //! AT Protocol inter-service authentication token generator. 2 + //! 3 + //! Generate JWT tokens for inter-service authentication with AT Protocol services. 4 + //! Supports customizable issuer, audience, and additional claims via key=value pairs. 5 + 6 + use anyhow::Result; 7 + use atproto_identity::key::{KeyType, identify_key}; 8 + use atproto_identity::validation::{ 9 + is_valid_did_method_plc, is_valid_did_method_web, is_valid_did_method_webvh, 10 + }; 11 + use atproto_oauth::jwt::{Claims, Header, JoseClaims, mint}; 12 + use chrono::Utc; 13 + use clap::Parser; 14 + use serde_json::json; 15 + use std::env; 16 + use std::time::{SystemTime, UNIX_EPOCH}; 17 + use ulid::Ulid; 18 + 19 + /// Helper function to validate if a string is a valid DID 20 + fn is_valid_did(did: &str) -> bool { 21 + is_valid_did_method_plc(did) 22 + || is_valid_did_method_web(did, false) 23 + || is_valid_did_method_webvh(did, false) 24 + || did.starts_with("did:key:") 25 + } 26 + 27 + #[derive(Parser)] 28 + #[command( 29 + name = "atproto-oauth-service-token", 30 + version, 31 + about = "Generate AT Protocol inter-service authentication tokens", 32 + long_about = "Generate JWT tokens for inter-service authentication with AT Protocol services. 33 + 34 + USAGE: 35 + atproto-oauth-service-token <ISSUER_DID> <SIGNING_KEY> <AUDIENCE_DID> [KEY=VALUE...] 36 + 37 + ARGUMENTS: 38 + <ISSUER_DID> DID-formatted identity the token is for (e.g., did:plc:cbkjy5n7bk3ax2wplmtjofq2) 39 + <SIGNING_KEY> Private signing key in did:key format OR name of environment variable containing the key 40 + <AUDIENCE_DID> DID-formatted identity the token is for (e.g., did:web:example.com) 41 + [KEY=VALUE...] Additional claims to include in the JWT (e.g., lxm=lexicon.method exp=3600) 42 + 43 + SUPPORTED CLAIM KEYS: 44 + exp=<seconds> Expiration time in seconds since epoch (use exp=+<secs> for relative time, default: now + 60) 45 + iat=<seconds> Issued at time in seconds since epoch (use iat= or iat=now for current time, default: now) 46 + jti=<id> JWT ID (use jti= for random ULID, default: randomly generated ULID) 47 + lxm=<method> XRPC method to bind the token to 48 + <any>=<value> Any other key=value will be added as a private claim 49 + 50 + EXAMPLES: 51 + # Basic token with 60 second expiration: 52 + atproto-oauth-service-token did:plc:issuer did:key:private did:web:audience 53 + 54 + # Token with signing key from environment variable: 55 + SIGNING_KEY=did:key:z3vLYrthScXDXC1AUPvRperPn5T7nWxpJkkQVhCzdgfCxxhg 56 + atproto-oauth-service-token did:plc:issuer SIGNING_KEY did:web:audience 57 + 58 + # Token with custom expiration and XRPC method: 59 + atproto-oauth-service-token did:plc:issuer did:key:private did:web:audience \\ 60 + exp=+3600 lxm=com.atproto.repo.createRecord 61 + 62 + # Token with multiple custom claims and generated JTI: 63 + atproto-oauth-service-token did:plc:issuer did:key:private did:web:audience \\ 64 + lxm=garden.lexicon.helloworld.Hello scope=read:repo jti= custom_claim=value 65 + 66 + # Token with current timestamp and generated JTI: 67 + atproto-oauth-service-token did:plc:issuer did:key:private did:web:audience \\ 68 + iat=now jti= exp=+3600 69 + 70 + # Token with relative expiration time (1 hour from now): 71 + atproto-oauth-service-token did:plc:issuer did:key:private did:web:audience \\ 72 + exp=+3600 jti= 73 + 74 + OUTPUT: 75 + eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NDg5ODg0OTcsImlzcyI6ImRpZDpwbGM6Y2Jrank1bjdiazNheDJ3cGxtdGpvZnEyIiwiYXVkIjoiZGlkOndlYjpuZ2VyYWtpbmVzLnR1bm4uZGV2IiwiZXhwIjoxNzQ4OTg4NTU3LCJseG0iOiJnYXJkZW4ubGV4aWNvbi5uZ2VyYWtpbmVzLmhlbGxvd29ybGQuSGVsbG8iLCJqdGkiOiI0ODQ2YjQ1OWMyMDFiMDNjZjBlZGMzYmE3NjQxNTk0MiJ9.sj74PPS97z81LSay6EyDOu3IQcF-bd4xGqK5u6qruhhWWiQR2IW89YMJ1s0H-P25xaTM1Zacp-pa4RlVsrH2uA" 76 + )] 77 + struct Args { 78 + /// Issuer DID (identity the token is for) 79 + issuer: String, 80 + 81 + /// Signing key (did:key format with private key) or environment variable name 82 + signing_key: String, 83 + 84 + /// Audience DID (who the token is for) 85 + audience: String, 86 + 87 + /// Additional key=value pairs for JWT claims 88 + additional_claims: Vec<String>, 89 + } 90 + 91 + fn main() -> Result<()> { 92 + let args = Args::parse(); 93 + 94 + // Validate issuer DID format 95 + if !is_valid_did(&args.issuer) { 96 + anyhow::bail!("Invalid issuer DID format: {}", args.issuer); 97 + } 98 + 99 + // Validate audience DID format 100 + if !is_valid_did(&args.audience) { 101 + anyhow::bail!("Invalid audience DID format: {}", args.audience); 102 + } 103 + 104 + // Get signing key - either directly or from environment variable 105 + let signing_key = if args.signing_key.starts_with("did:key:") { 106 + // Direct key value provided 107 + args.signing_key.clone() 108 + } else { 109 + // Treat as environment variable name 110 + env::var(&args.signing_key).map_err(|_| { 111 + anyhow::anyhow!( 112 + "Environment variable '{}' not found or empty", 113 + args.signing_key 114 + ) 115 + })? 116 + }; 117 + 118 + // Parse and validate the signing key 119 + let key_data = identify_key(&signing_key)?; 120 + 121 + // Verify it's a private key 122 + match key_data.key_type() { 123 + KeyType::P256Private | KeyType::P384Private | KeyType::K256Private => { 124 + // Valid private key 125 + } 126 + _ => { 127 + anyhow::bail!( 128 + "Signing key must be a private key, got: {:?}", 129 + key_data.key_type() 130 + ); 131 + } 132 + } 133 + 134 + // Parse additional claims 135 + let mut duration: Option<u64> = None; 136 + let mut exp: Option<u64> = None; 137 + let mut iat: Option<u64> = None; 138 + let mut jti: Option<String> = None; 139 + let mut private_claims = std::collections::BTreeMap::new(); 140 + 141 + // Get current time 142 + let now = SystemTime::now() 143 + .duration_since(UNIX_EPOCH) 144 + .expect("System time error") 145 + .as_secs(); 146 + 147 + for claim in &args.additional_claims { 148 + if let Some((key, value)) = claim.split_once('=') { 149 + match key { 150 + "exp" => { 151 + // If value starts with "+", set duration instead of exp 152 + if let Some(offset_str) = value.strip_prefix('+') { 153 + duration = Some(offset_str.parse().map_err(|_| { 154 + anyhow::anyhow!("Invalid exp offset value: {}", offset_str) 155 + })?); 156 + } else { 157 + exp = Some( 158 + value 159 + .parse() 160 + .map_err(|_| anyhow::anyhow!("Invalid exp value: {}", value))?, 161 + ); 162 + } 163 + } 164 + "iat" => { 165 + // If value is empty or "now", use current time 166 + iat = if value.is_empty() || value == "now" { 167 + Some(Utc::now().timestamp() as u64) 168 + } else { 169 + Some( 170 + value 171 + .parse() 172 + .map_err(|_| anyhow::anyhow!("Invalid iat value: {}", value))?, 173 + ) 174 + }; 175 + } 176 + "jti" => { 177 + // If value is empty, generate a random ULID 178 + jti = if value.is_empty() { 179 + Some(Ulid::new().to_string().to_lowercase()) 180 + } else { 181 + Some(value.to_string()) 182 + }; 183 + } 184 + _ => { 185 + // Add as private claim 186 + // Try to parse as number first, then as boolean, then as string 187 + let json_value = if let Ok(n) = value.parse::<i64>() { 188 + json!(n) 189 + } else if let Ok(f) = value.parse::<f64>() { 190 + json!(f) 191 + } else if let Ok(b) = value.parse::<bool>() { 192 + json!(b) 193 + } else { 194 + json!(value) 195 + }; 196 + private_claims.insert(key.to_string(), json_value); 197 + } 198 + } 199 + } else { 200 + eprintln!( 201 + "Warning: Ignoring invalid claim format: {} (expected key=value)", 202 + claim 203 + ); 204 + } 205 + } 206 + 207 + // Create header from the key 208 + let mut header: Header = key_data.clone().try_into()?; 209 + 210 + // Always set typ field to "JWT" 211 + header.type_ = Some("JWT".to_string()); 212 + 213 + // Determine issued_at time 214 + let issued_at = iat.unwrap_or(now); 215 + 216 + // Determine expiration time 217 + let expiration = if let Some(exp_value) = exp { 218 + Some(exp_value) 219 + } else if let Some(duration_value) = duration { 220 + Some(issued_at + duration_value) 221 + } else { 222 + // Default to 60 seconds from issued_at 223 + Some(issued_at + 60) 224 + }; 225 + 226 + // Generate JWT ID if not provided 227 + let jwt_id = jti.unwrap_or_else(|| Ulid::new().to_string().to_lowercase()); 228 + 229 + // Create standard JOSE claims 230 + let jose = JoseClaims { 231 + issuer: Some(args.issuer), 232 + subject: None, 233 + audience: Some(args.audience), 234 + expiration, 235 + not_before: None, 236 + issued_at: Some(issued_at), 237 + json_web_token_id: Some(jwt_id), 238 + http_method: None, 239 + http_uri: None, 240 + nonce: None, 241 + auth: None, 242 + }; 243 + 244 + // Create claims with private claims 245 + let mut claims = Claims::new(jose); 246 + claims.private = private_claims; 247 + 248 + // Mint the JWT token 249 + let token = mint(&key_data, &header, &claims)?; 250 + 251 + // Output the token 252 + println!("{}", token); 253 + 254 + Ok(()) 255 + }
+9 -14
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
··· 10 10 use serde::{Deserialize, Serialize}; 11 11 12 12 use crate::lexicon::{ 13 - TypedBlob, com_atproto_repo::TypedStrongRef, community_lexicon_attestation::Signatures, 13 + TypedBlob, com::atproto::repo::StrongRef, community::lexicon::attestation::Signatures, 14 14 }; 15 15 use crate::typed::{LexiconType, TypedLexicon}; 16 16 ··· 81 81 /// use std::collections::HashMap; 82 82 /// 83 83 /// let award = Award { 84 - /// badge: TypedStrongRef::new(StrongRef { 84 + /// badge: StrongRef { 85 85 /// uri: "at://did:plc:issuer/community.lexicon.badge.definition/badge123".to_string(), 86 86 /// cid: "bafyreicid123".to_string(), 87 - /// }), 87 + /// }, 88 88 /// did: "did:plc:recipient".to_string(), 89 89 /// issued: Utc::now(), 90 90 /// signatures: vec![], ··· 97 97 #[cfg_attr(debug_assertions, derive(Debug))] 98 98 pub struct Award { 99 99 /// Reference to the badge definition being awarded 100 - pub badge: TypedStrongRef, 100 + pub badge: StrongRef, 101 101 /// DID of the recipient 102 102 pub did: String, 103 103 /// When the badge was awarded ··· 222 222 let json = r#"{ 223 223 "$type": "community.lexicon.badge.award", 224 224 "badge": { 225 - "$type": "com.atproto.repo.strongRef", 226 225 "cid": "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4", 227 226 "uri": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c" 228 227 }, ··· 239 238 assert!(award.signatures.is_empty()); 240 239 241 240 // badge is a TypedStrongRef, so we access the inner StrongRef 242 - let badge_ref = &award.badge.inner; 241 + let badge_ref = &award.badge; 243 242 assert_eq!( 244 243 badge_ref.cid, 245 244 "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4" ··· 256 255 fn test_serialize_badge_award() -> Result<()> { 257 256 use chrono::TimeZone; 258 257 259 - let badge_ref = StrongRef { 258 + let badge = StrongRef { 260 259 uri: "at://did:plc:test/community.lexicon.badge.definition/abc123".to_string(), 261 260 cid: "bafyreicidtest123".to_string(), 262 261 }; 263 262 let award = Award { 264 - badge: TypedLexicon::new(badge_ref), 263 + badge, 265 264 did: "did:plc:recipient123".to_string(), 266 265 issued: Utc.with_ymd_and_hms(2025, 6, 8, 22, 10, 55).unwrap(), 267 266 signatures: vec![], ··· 275 274 assert!(json.contains("\"$type\": \"community.lexicon.badge.award\"")); 276 275 assert!(json.contains("\"did\": \"did:plc:recipient123\"")); 277 276 assert!(json.contains("\"issued\": \"2025-06-08T22:10:55Z\"")); 278 - assert!(json.contains("\"$type\": \"com.atproto.repo.strongRef\"")); 279 277 // Empty signatures array is skipped in serialization due to skip_serializing_if 280 278 assert!(!json.contains("\"signatures\"")); 281 279 ··· 332 330 // Test that typed patterns automatically handle $type fields 333 331 334 332 // StrongRef without explicit $type field 335 - let strong_ref = StrongRef { 333 + let badge = StrongRef { 336 334 uri: "at://example".to_string(), 337 335 cid: "bafytest".to_string(), 338 336 }; 339 - let typed_ref = TypedLexicon::new(strong_ref); 340 - let json = serde_json::to_value(&typed_ref)?; 341 - assert_eq!(json["$type"], "com.atproto.repo.strongRef"); 342 337 343 338 // Definition without explicit $type field 344 339 let definition = Definition { ··· 353 348 354 349 // Award without explicit $type field 355 350 let award = Award { 356 - badge: typed_ref, 351 + badge, 357 352 did: "did:plc:test".to_string(), 358 353 issued: Utc::now(), 359 354 signatures: vec![],
+5
crates/atproto-record/src/lexicon/mod.rs
··· 76 76 pub mod attestation { 77 77 pub use crate::lexicon::community_lexicon_attestation::*; 78 78 } 79 + 80 + /// Badge and award types 81 + pub mod badge { 82 + pub use crate::lexicon::community_lexicon_badge::*; 83 + } 79 84 } 80 85 }