+1
Cargo.lock
+1
Cargo.lock
+9
crates/atproto-oauth/Cargo.toml
+9
crates/atproto-oauth/Cargo.toml
···
14
keywords.workspace = true
15
categories.workspace = true
16
17
[dependencies]
18
atproto-identity.workspace = true
19
···
41
tracing.workspace = true
42
ulid.workspace = true
43
44
zeroize = { workspace = true, optional = true }
45
46
[features]
47
default = ["lru", "hickory-dns"]
48
lru = ["dep:lru"]
49
zeroize = ["dep:zeroize", "atproto-identity/zeroize"]
50
hickory-dns = ["atproto-identity/hickory-dns"]
51
···
14
keywords.workspace = true
15
categories.workspace = true
16
17
+
[[bin]]
18
+
name = "atproto-oauth-service-token"
19
+
test = false
20
+
bench = false
21
+
doc = true
22
+
required-features = ["clap"]
23
+
24
[dependencies]
25
atproto-identity.workspace = true
26
···
48
tracing.workspace = true
49
ulid.workspace = true
50
51
+
clap = { workspace = true, optional = true }
52
zeroize = { workspace = true, optional = true }
53
54
[features]
55
default = ["lru", "hickory-dns"]
56
lru = ["dep:lru"]
57
+
clap = ["dep:clap"]
58
zeroize = ["dep:zeroize", "atproto-identity/zeroize"]
59
hickory-dns = ["atproto-identity/hickory-dns"]
60
+255
crates/atproto-oauth/src/bin/atproto-oauth-service-token.rs
+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
+9
-14
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
···
10
use serde::{Deserialize, Serialize};
11
12
use crate::lexicon::{
13
-
TypedBlob, com_atproto_repo::TypedStrongRef, community_lexicon_attestation::Signatures,
14
};
15
use crate::typed::{LexiconType, TypedLexicon};
16
···
81
/// use std::collections::HashMap;
82
///
83
/// let award = Award {
84
-
/// badge: TypedStrongRef::new(StrongRef {
85
/// uri: "at://did:plc:issuer/community.lexicon.badge.definition/badge123".to_string(),
86
/// cid: "bafyreicid123".to_string(),
87
-
/// }),
88
/// did: "did:plc:recipient".to_string(),
89
/// issued: Utc::now(),
90
/// signatures: vec![],
···
97
#[cfg_attr(debug_assertions, derive(Debug))]
98
pub struct Award {
99
/// Reference to the badge definition being awarded
100
-
pub badge: TypedStrongRef,
101
/// DID of the recipient
102
pub did: String,
103
/// When the badge was awarded
···
222
let json = r#"{
223
"$type": "community.lexicon.badge.award",
224
"badge": {
225
-
"$type": "com.atproto.repo.strongRef",
226
"cid": "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4",
227
"uri": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c"
228
},
···
239
assert!(award.signatures.is_empty());
240
241
// badge is a TypedStrongRef, so we access the inner StrongRef
242
-
let badge_ref = &award.badge.inner;
243
assert_eq!(
244
badge_ref.cid,
245
"bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4"
···
256
fn test_serialize_badge_award() -> Result<()> {
257
use chrono::TimeZone;
258
259
-
let badge_ref = StrongRef {
260
uri: "at://did:plc:test/community.lexicon.badge.definition/abc123".to_string(),
261
cid: "bafyreicidtest123".to_string(),
262
};
263
let award = Award {
264
-
badge: TypedLexicon::new(badge_ref),
265
did: "did:plc:recipient123".to_string(),
266
issued: Utc.with_ymd_and_hms(2025, 6, 8, 22, 10, 55).unwrap(),
267
signatures: vec![],
···
275
assert!(json.contains("\"$type\": \"community.lexicon.badge.award\""));
276
assert!(json.contains("\"did\": \"did:plc:recipient123\""));
277
assert!(json.contains("\"issued\": \"2025-06-08T22:10:55Z\""));
278
-
assert!(json.contains("\"$type\": \"com.atproto.repo.strongRef\""));
279
// Empty signatures array is skipped in serialization due to skip_serializing_if
280
assert!(!json.contains("\"signatures\""));
281
···
332
// Test that typed patterns automatically handle $type fields
333
334
// StrongRef without explicit $type field
335
-
let strong_ref = StrongRef {
336
uri: "at://example".to_string(),
337
cid: "bafytest".to_string(),
338
};
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
343
// Definition without explicit $type field
344
let definition = Definition {
···
353
354
// Award without explicit $type field
355
let award = Award {
356
-
badge: typed_ref,
357
did: "did:plc:test".to_string(),
358
issued: Utc::now(),
359
signatures: vec![],
···
10
use serde::{Deserialize, Serialize};
11
12
use crate::lexicon::{
13
+
TypedBlob, com::atproto::repo::StrongRef, community::lexicon::attestation::Signatures,
14
};
15
use crate::typed::{LexiconType, TypedLexicon};
16
···
81
/// use std::collections::HashMap;
82
///
83
/// let award = Award {
84
+
/// badge: StrongRef {
85
/// uri: "at://did:plc:issuer/community.lexicon.badge.definition/badge123".to_string(),
86
/// cid: "bafyreicid123".to_string(),
87
+
/// },
88
/// did: "did:plc:recipient".to_string(),
89
/// issued: Utc::now(),
90
/// signatures: vec![],
···
97
#[cfg_attr(debug_assertions, derive(Debug))]
98
pub struct Award {
99
/// Reference to the badge definition being awarded
100
+
pub badge: StrongRef,
101
/// DID of the recipient
102
pub did: String,
103
/// When the badge was awarded
···
222
let json = r#"{
223
"$type": "community.lexicon.badge.award",
224
"badge": {
225
"cid": "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4",
226
"uri": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c"
227
},
···
238
assert!(award.signatures.is_empty());
239
240
// badge is a TypedStrongRef, so we access the inner StrongRef
241
+
let badge_ref = &award.badge;
242
assert_eq!(
243
badge_ref.cid,
244
"bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4"
···
255
fn test_serialize_badge_award() -> Result<()> {
256
use chrono::TimeZone;
257
258
+
let badge = StrongRef {
259
uri: "at://did:plc:test/community.lexicon.badge.definition/abc123".to_string(),
260
cid: "bafyreicidtest123".to_string(),
261
};
262
let award = Award {
263
+
badge,
264
did: "did:plc:recipient123".to_string(),
265
issued: Utc.with_ymd_and_hms(2025, 6, 8, 22, 10, 55).unwrap(),
266
signatures: vec![],
···
274
assert!(json.contains("\"$type\": \"community.lexicon.badge.award\""));
275
assert!(json.contains("\"did\": \"did:plc:recipient123\""));
276
assert!(json.contains("\"issued\": \"2025-06-08T22:10:55Z\""));
277
// Empty signatures array is skipped in serialization due to skip_serializing_if
278
assert!(!json.contains("\"signatures\""));
279
···
330
// Test that typed patterns automatically handle $type fields
331
332
// StrongRef without explicit $type field
333
+
let badge = StrongRef {
334
uri: "at://example".to_string(),
335
cid: "bafytest".to_string(),
336
};
337
338
// Definition without explicit $type field
339
let definition = Definition {
···
348
349
// Award without explicit $type field
350
let award = Award {
351
+
badge,
352
did: "did:plc:test".to_string(),
353
issued: Utc::now(),
354
signatures: vec![],
+5
crates/atproto-record/src/lexicon/mod.rs
+5
crates/atproto-record/src/lexicon/mod.rs