1use axum::{
2 Extension, Router,
3 body::Body,
4 extract::Request,
5 http::{StatusCode, header},
6 middleware,
7 routing::get,
8};
9use base64::Engine;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11use bytes::Bytes;
12use jacquard_axum::service_auth::{
13 ExtractServiceAuth, ServiceAuthConfig, VerifiedServiceAuth, service_auth_middleware,
14};
15use jacquard_common::{
16 CowStr, IntoStatic,
17 service_auth::JwtHeader,
18 types::{
19 did::Did,
20 did_doc::{DidDocument, VerificationMethod},
21 },
22};
23use jacquard_identity::resolver::{
24 DidDocResponse, IdentityError, IdentityResolver, ResolverOptions,
25};
26use reqwest::StatusCode as ReqwestStatusCode;
27use serde_json::json;
28use std::future::Future;
29use tower::ServiceExt;
30
31// Test helper: create a signed JWT
32fn create_test_jwt(
33 iss: &str,
34 aud: &str,
35 exp: i64,
36 lxm: Option<&str>,
37 signing_key: &k256::ecdsa::SigningKey,
38) -> String {
39 use k256::ecdsa::signature::Signer;
40
41 let header = JwtHeader {
42 alg: CowStr::new_static("ES256K"),
43 typ: CowStr::new_static("JWT"),
44 };
45
46 let mut claims_json = json!({
47 "iss": iss,
48 "aud": aud,
49 "exp": exp,
50 "iat": chrono::Utc::now().timestamp(),
51 });
52
53 if let Some(lxm_val) = lxm {
54 claims_json["lxm"] = json!(lxm_val);
55 }
56
57 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
58 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims_json).unwrap());
59
60 let signing_input = format!("{}.{}", header_b64, payload_b64);
61
62 let signature: k256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
63 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
64
65 format!("{}.{}", signing_input, signature_b64)
66}
67
68// Test helper: create DID document with k256 key
69fn create_test_did_doc(did: &str, public_key: &k256::ecdsa::VerifyingKey) -> DidDocument<'static> {
70 use std::collections::BTreeMap;
71
72 // Encode as compressed SEC1
73 let encoded_point = public_key.to_encoded_point(true);
74 let key_bytes = encoded_point.as_bytes();
75
76 // Multicodec prefix for secp256k1-pub (0xe701)
77 let mut multicodec_bytes = vec![0xe7, 0x01];
78 multicodec_bytes.extend_from_slice(key_bytes);
79
80 // Multibase encode (base58btc = 'z')
81 let multibase_key = multibase::encode(multibase::Base::Base58Btc, &multicodec_bytes);
82
83 DidDocument {
84 id: Did::new_owned(did).unwrap().into_static(),
85 also_known_as: None,
86 verification_method: Some(vec![VerificationMethod {
87 id: CowStr::Owned(format!("{}#atproto", did).into()),
88 r#type: CowStr::new_static("Multikey"),
89 controller: Some(CowStr::Owned(did.into())),
90 public_key_multibase: Some(CowStr::Owned(multibase_key.into())),
91 extra_data: BTreeMap::new(),
92 }]),
93 service: None,
94 extra_data: BTreeMap::new(),
95 }
96}
97
98// Mock resolver for tests
99#[derive(Clone)]
100struct MockResolver {
101 did_doc: DidDocument<'static>,
102 options: ResolverOptions,
103}
104
105impl MockResolver {
106 fn new(did_doc: DidDocument<'static>) -> Self {
107 Self {
108 did_doc,
109 options: ResolverOptions::default(),
110 }
111 }
112}
113
114impl IdentityResolver for MockResolver {
115 fn options(&self) -> &ResolverOptions {
116 &self.options
117 }
118
119 fn resolve_handle(
120 &self,
121 _handle: &jacquard_common::types::string::Handle<'_>,
122 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send {
123 async { Err(IdentityError::InvalidWellKnown) }
124 }
125
126 fn resolve_did_doc(
127 &self,
128 _did: &Did<'_>,
129 ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send {
130 let doc = self.did_doc.clone();
131 async move {
132 let json = serde_json::to_vec(&doc).unwrap();
133 Ok(DidDocResponse {
134 buffer: Bytes::from(json),
135 status: ReqwestStatusCode::OK,
136 requested: Some(doc.id.clone()),
137 })
138 }
139 }
140}
141
142#[tokio::test]
143async fn test_extractor_with_valid_jwt() {
144 // Generate keypair
145 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
146 let verifying_key = signing_key.verifying_key();
147
148 // Create test DID and JWT
149 let user_did = "did:plc:test123";
150 let service_did = "did:web:feedgen.example.com";
151 let exp = chrono::Utc::now().timestamp() + 300;
152
153 // JWT with lxm
154 let jwt = create_test_jwt(
155 user_did,
156 service_did,
157 exp,
158 Some("app.bsky.feed.getFeedSkeleton"),
159 &signing_key,
160 );
161
162 // Create mock resolver
163 let did_doc = create_test_did_doc(user_did, verifying_key);
164 let resolver = MockResolver::new(did_doc);
165
166 // Create config (default: require_lxm = true)
167 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
168
169 // Create handler
170 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
171 format!("Authenticated as {}", auth.did())
172 }
173
174 let app = Router::new()
175 .route("/test", get(handler))
176 .with_state(config);
177
178 // Create request with JWT
179 let request = Request::builder()
180 .uri("/test")
181 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
182 .body(Body::empty())
183 .unwrap();
184
185 // Send request
186 let response = app.oneshot(request).await.unwrap();
187
188 assert_eq!(response.status(), StatusCode::OK);
189
190 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
191 .await
192 .unwrap();
193 let body = String::from_utf8(body_bytes.to_vec()).unwrap();
194
195 assert_eq!(body, format!("Authenticated as {}", user_did));
196}
197
198#[tokio::test]
199async fn test_extractor_with_expired_jwt() {
200 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
201 let verifying_key = signing_key.verifying_key();
202
203 let user_did = "did:plc:test123";
204 let service_did = "did:web:feedgen.example.com";
205 let exp = chrono::Utc::now().timestamp() - 300; // Expired
206
207 let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key);
208
209 let did_doc = create_test_did_doc(user_did, verifying_key);
210 let resolver = MockResolver::new(did_doc);
211
212 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
213
214 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
215 format!("Authenticated as {}", auth.did())
216 }
217
218 let app = Router::new()
219 .route("/test", get(handler))
220 .with_state(config);
221
222 let request = Request::builder()
223 .uri("/test")
224 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
225 .body(Body::empty())
226 .unwrap();
227
228 let response = app.oneshot(request).await.unwrap();
229
230 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
231}
232
233#[tokio::test]
234async fn test_extractor_with_wrong_audience() {
235 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
236 let verifying_key = signing_key.verifying_key();
237
238 let user_did = "did:plc:test123";
239 let service_did = "did:web:feedgen.example.com";
240 let wrong_aud = "did:web:other.example.com";
241 let exp = chrono::Utc::now().timestamp() + 300;
242
243 let jwt = create_test_jwt(user_did, wrong_aud, exp, None, &signing_key);
244
245 let did_doc = create_test_did_doc(user_did, verifying_key);
246 let resolver = MockResolver::new(did_doc);
247
248 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
249
250 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
251 format!("Authenticated as {}", auth.did())
252 }
253
254 let app = Router::new()
255 .route("/test", get(handler))
256 .with_state(config);
257
258 let request = Request::builder()
259 .uri("/test")
260 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
261 .body(Body::empty())
262 .unwrap();
263
264 let response = app.oneshot(request).await.unwrap();
265
266 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
267}
268
269#[tokio::test]
270async fn test_extractor_missing_auth_header() {
271 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
272 let verifying_key = signing_key.verifying_key();
273
274 let user_did = "did:plc:test123";
275 let service_did = "did:web:feedgen.example.com";
276
277 let did_doc = create_test_did_doc(user_did, verifying_key);
278 let resolver = MockResolver::new(did_doc);
279
280 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
281
282 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
283 format!("Authenticated as {}", auth.did())
284 }
285
286 let app = Router::new()
287 .route("/test", get(handler))
288 .with_state(config);
289
290 let request = Request::builder().uri("/test").body(Body::empty()).unwrap();
291
292 let response = app.oneshot(request).await.unwrap();
293
294 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
295}
296
297#[tokio::test]
298async fn test_middleware_with_valid_jwt() {
299 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
300 let verifying_key = signing_key.verifying_key();
301
302 let user_did = "did:plc:test123";
303 let service_did = "did:web:feedgen.example.com";
304 let exp = chrono::Utc::now().timestamp() + 300;
305
306 // JWT with lxm
307 let jwt = create_test_jwt(
308 user_did,
309 service_did,
310 exp,
311 Some("app.bsky.feed.getFeedSkeleton"),
312 &signing_key,
313 );
314
315 let did_doc = create_test_did_doc(user_did, verifying_key);
316 let resolver = MockResolver::new(did_doc);
317
318 // Create config (default: require_lxm = true)
319 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
320
321 async fn handler(Extension(auth): Extension<VerifiedServiceAuth<'static>>) -> String {
322 format!("Authenticated as {}", auth.did())
323 }
324
325 let app = Router::new()
326 .route("/test", get(handler))
327 .layer(middleware::from_fn_with_state(
328 config.clone(),
329 service_auth_middleware::<ServiceAuthConfig<MockResolver>>,
330 ))
331 .with_state(config);
332
333 let request = Request::builder()
334 .uri("/test")
335 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
336 .body(Body::empty())
337 .unwrap();
338
339 let response = app.oneshot(request).await.unwrap();
340
341 assert_eq!(response.status(), StatusCode::OK);
342
343 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
344 .await
345 .unwrap();
346 let body = String::from_utf8(body_bytes.to_vec()).unwrap();
347
348 assert_eq!(body, format!("Authenticated as {}", user_did));
349}
350
351#[tokio::test]
352async fn test_require_lxm() {
353 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
354 let verifying_key = signing_key.verifying_key();
355
356 let user_did = "did:plc:test123";
357 let service_did = "did:web:feedgen.example.com";
358 let exp = chrono::Utc::now().timestamp() + 300;
359
360 // JWT without lxm
361 let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key);
362
363 let did_doc = create_test_did_doc(user_did, verifying_key);
364 let resolver = MockResolver::new(did_doc);
365
366 let config =
367 ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true);
368
369 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
370 format!("Authenticated as {}", auth.did())
371 }
372
373 let app = Router::new()
374 .route("/test", get(handler))
375 .with_state(config);
376
377 let request = Request::builder()
378 .uri("/test")
379 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
380 .body(Body::empty())
381 .unwrap();
382
383 let response = app.oneshot(request).await.unwrap();
384
385 // Should fail because lxm is required but missing
386 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
387}
388
389#[tokio::test]
390async fn test_with_lxm_present() {
391 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
392 let verifying_key = signing_key.verifying_key();
393
394 let user_did = "did:plc:test123";
395 let service_did = "did:web:feedgen.example.com";
396 let exp = chrono::Utc::now().timestamp() + 300;
397
398 // JWT with lxm
399 let jwt = create_test_jwt(
400 user_did,
401 service_did,
402 exp,
403 Some("app.bsky.feed.getFeedSkeleton"),
404 &signing_key,
405 );
406
407 let did_doc = create_test_did_doc(user_did, verifying_key);
408 let resolver = MockResolver::new(did_doc);
409
410 let config =
411 ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true);
412
413 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
414 format!(
415 "Authenticated as {} for {}",
416 auth.did(),
417 auth.lxm().unwrap()
418 )
419 }
420
421 let app = Router::new()
422 .route("/test", get(handler))
423 .with_state(config);
424
425 let request = Request::builder()
426 .uri("/test")
427 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
428 .body(Body::empty())
429 .unwrap();
430
431 let response = app.oneshot(request).await.unwrap();
432
433 assert_eq!(response.status(), StatusCode::OK);
434
435 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
436 .await
437 .unwrap();
438 let body = String::from_utf8(body_bytes.to_vec()).unwrap();
439
440 assert_eq!(
441 body,
442 format!(
443 "Authenticated as {} for app.bsky.feed.getFeedSkeleton",
444 user_did
445 )
446 );
447}
448
449#[tokio::test]
450async fn test_legacy_without_lxm() {
451 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
452 let verifying_key = signing_key.verifying_key();
453
454 let user_did = "did:plc:test123";
455 let service_did = "did:web:feedgen.example.com";
456 let exp = chrono::Utc::now().timestamp() + 300;
457
458 // JWT without lxm
459 let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key);
460
461 let did_doc = create_test_did_doc(user_did, verifying_key);
462 let resolver = MockResolver::new(did_doc);
463
464 // Legacy config: lxm not required
465 let config =
466 ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(false);
467
468 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
469 format!("Authenticated as {}", auth.did())
470 }
471
472 let app = Router::new()
473 .route("/test", get(handler))
474 .with_state(config);
475
476 let request = Request::builder()
477 .uri("/test")
478 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
479 .body(Body::empty())
480 .unwrap();
481
482 let response = app.oneshot(request).await.unwrap();
483
484 // Should succeed because lxm is not required
485 assert_eq!(response.status(), StatusCode::OK);
486
487 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
488 .await
489 .unwrap();
490 let body = String::from_utf8(body_bytes.to_vec()).unwrap();
491
492 assert_eq!(body, format!("Authenticated as {}", user_did));
493}
494
495#[tokio::test]
496async fn test_invalid_signature() {
497 // Real JWT token from did:plc:uc7pehijmk5jrllip4cglxdd with bogus signature
498 let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NjAzOTMyMzUsImlzcyI6ImRpZDpwbGM6dWM3cGVoaWptazVqcmxsaXA0Y2dseGRkIiwiYXVkIjoiZGlkOndlYjpkZXYucGRzbW9vdmVyLmNvbSIsImV4cCI6MTc2MDM5MzI5NSwibHhtIjoiY29tLnBkc21vb3Zlci5iYWNrdXAuc2lnblVwIiwianRpIjoiMTk0MDQzMzQyNmMyNTNlZjhmNmYxZDJjZWE1YzI0NGMifQ.h5BrgYE";
499
500 // Real DID document for did:plc:uc7pehijmk5jrllip4cglxdd
501 let did_doc_json = r##"{
502 "id": "did:plc:uc7pehijmk5jrllip4cglxdd",
503 "alsoKnownAs": ["at://bailey.skeetcentral.com"],
504 "verificationMethod": [{
505 "controller": "did:plc:uc7pehijmk5jrllip4cglxdd",
506 "id": "did:plc:uc7pehijmk5jrllip4cglxdd#atproto",
507 "publicKeyMultibase": "zQ3shNBS3N4EB3vX5G1HoxFkS8tDLFXUHaV85rHQZgVM88rM5",
508 "type": "Multikey"
509 }],
510 "service": [{
511 "id": "#atproto_pds",
512 "serviceEndpoint": "https://skeetcentral.com",
513 "type": "AtprotoPersonalDataServer"
514 }]
515 }"##;
516
517 let did_doc: DidDocument = serde_json::from_str(did_doc_json).unwrap();
518 let resolver = MockResolver::new(did_doc);
519
520 let config = ServiceAuthConfig::new(
521 Did::new_static("did:web:dev.pdsmoover.com").unwrap(),
522 resolver,
523 );
524
525 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
526 format!("Authenticated as {}", auth.did())
527 }
528
529 let app = Router::new()
530 .route("/test", get(handler))
531 .with_state(config);
532
533 let request = Request::builder()
534 .uri("/test")
535 .header(header::AUTHORIZATION, format!("Bearer {}", token))
536 .body(Body::empty())
537 .unwrap();
538
539 let response = app.oneshot(request).await.unwrap();
540
541 // Should fail due to invalid signature
542 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
543}