···
4
+
"Coves/internal/atproto/auth"
···
"github.com/golang-jwt/jwt/v5"
17
+
"github.com/google/uuid"
// mockJWKSFetcher is a test double for JWKSFetcher
···
t.Errorf("expected nil claims, got %+v", claims)
336
+
// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified
337
+
func TestGetDPoPProof_NotAuthenticated(t *testing.T) {
338
+
req := httptest.NewRequest("GET", "/test", nil)
339
+
proof := GetDPoPProof(req)
342
+
t.Errorf("expected nil proof, got %+v", proof)
346
+
// TestRequireAuth_WithDPoP_SecurityModel tests the correct DPoP security model:
347
+
// Token MUST be verified first, then DPoP is checked as an additional layer.
348
+
// DPoP is NOT a fallback for failed token verification.
349
+
func TestRequireAuth_WithDPoP_SecurityModel(t *testing.T) {
350
+
// Generate an ECDSA key pair for DPoP
351
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
353
+
t.Fatalf("failed to generate key: %v", err)
356
+
// Calculate JWK thumbprint for cnf.jkt
357
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
358
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
360
+
t.Fatalf("failed to calculate thumbprint: %v", err)
363
+
t.Run("DPoP_is_NOT_fallback_for_failed_verification", func(t *testing.T) {
364
+
// SECURITY TEST: When token verification fails, DPoP should NOT be used as fallback
365
+
// This prevents an attacker from forging a token with their own cnf.jkt
367
+
// Create a DPoP-bound access token (unsigned - will fail verification)
368
+
claims := auth.Claims{
369
+
RegisteredClaims: jwt.RegisteredClaims{
370
+
Subject: "did:plc:attacker",
371
+
Issuer: "https://external.pds.local",
372
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
373
+
IssuedAt: jwt.NewNumericDate(time.Now()),
376
+
Confirmation: map[string]interface{}{
381
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
382
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
384
+
// Create valid DPoP proof (attacker has the private key)
385
+
dpopProof := createDPoPProof(t, privateKey, "GET", "https://test.local/api/endpoint")
387
+
// Mock fetcher that fails (simulating external PDS without JWKS)
388
+
fetcher := &mockJWKSFetcher{shouldFail: true}
389
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false
391
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
392
+
t.Error("SECURITY VULNERABILITY: handler was called despite token verification failure")
395
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
396
+
req.Header.Set("Authorization", "Bearer "+tokenString)
397
+
req.Header.Set("DPoP", dpopProof)
398
+
w := httptest.NewRecorder()
400
+
handler.ServeHTTP(w, req)
402
+
// MUST reject - token verification failed, DPoP cannot substitute for signature verification
403
+
if w.Code != http.StatusUnauthorized {
404
+
t.Errorf("SECURITY: expected 401 for unverified token, got %d", w.Code)
408
+
t.Run("DPoP_required_when_cnf_jkt_present_in_verified_token", func(t *testing.T) {
409
+
// When token has cnf.jkt, DPoP header MUST be present
410
+
// This test uses skipVerify=true to simulate a verified token
412
+
claims := auth.Claims{
413
+
RegisteredClaims: jwt.RegisteredClaims{
414
+
Subject: "did:plc:test123",
415
+
Issuer: "https://test.pds.local",
416
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
417
+
IssuedAt: jwt.NewNumericDate(time.Now()),
420
+
Confirmation: map[string]interface{}{
425
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
426
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
428
+
// NO DPoP header - should fail when skipVerify is false
429
+
// Note: with skipVerify=true, DPoP is not checked
430
+
fetcher := &mockJWKSFetcher{}
431
+
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true for parsing
433
+
handlerCalled := false
434
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
435
+
handlerCalled = true
436
+
w.WriteHeader(http.StatusOK)
439
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
440
+
req.Header.Set("Authorization", "Bearer "+tokenString)
442
+
w := httptest.NewRecorder()
444
+
handler.ServeHTTP(w, req)
446
+
// With skipVerify=true, DPoP is not checked, so this should succeed
447
+
if !handlerCalled {
448
+
t.Error("handler should be called when skipVerify=true")
453
+
// TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback is the key security test.
454
+
// It ensures that DPoP cannot be used as a fallback when token signature verification fails.
455
+
func TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback(t *testing.T) {
456
+
// Generate a key pair (attacker's key)
457
+
attackerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
458
+
jwk := ecdsaPublicKeyToJWK(&attackerKey.PublicKey)
459
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
461
+
// Create a FORGED token claiming to be the victim
462
+
claims := auth.Claims{
463
+
RegisteredClaims: jwt.RegisteredClaims{
464
+
Subject: "did:plc:victim_user", // Attacker claims to be victim
465
+
Issuer: "https://untrusted.pds",
466
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
467
+
IssuedAt: jwt.NewNumericDate(time.Now()),
470
+
Confirmation: map[string]interface{}{
471
+
"jkt": thumbprint, // Attacker uses their own key
475
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
476
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
478
+
// Attacker creates a valid DPoP proof with their key
479
+
dpopProof := createDPoPProof(t, attackerKey, "POST", "https://api.example.com/protected")
481
+
// Fetcher fails (external PDS without JWKS)
482
+
fetcher := &mockJWKSFetcher{shouldFail: true}
483
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification
485
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
486
+
t.Fatalf("CRITICAL SECURITY FAILURE: Request authenticated as %s despite forged token!",
490
+
req := httptest.NewRequest("POST", "https://api.example.com/protected", nil)
491
+
req.Header.Set("Authorization", "Bearer "+tokenString)
492
+
req.Header.Set("DPoP", dpopProof)
493
+
w := httptest.NewRecorder()
495
+
handler.ServeHTTP(w, req)
497
+
// MUST reject - the token signature was never verified
498
+
if w.Code != http.StatusUnauthorized {
499
+
t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)
503
+
// TestVerifyDPoPBinding_UsesForwardedProto ensures we honor the external HTTPS
504
+
// scheme when TLS is terminated upstream and X-Forwarded-Proto is present.
505
+
func TestVerifyDPoPBinding_UsesForwardedProto(t *testing.T) {
506
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
508
+
t.Fatalf("failed to generate key: %v", err)
511
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
512
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
514
+
t.Fatalf("failed to calculate thumbprint: %v", err)
517
+
claims := &auth.Claims{
518
+
RegisteredClaims: jwt.RegisteredClaims{
519
+
Subject: "did:plc:test123",
520
+
Issuer: "https://test.pds.local",
521
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
522
+
IssuedAt: jwt.NewNumericDate(time.Now()),
525
+
Confirmation: map[string]interface{}{
530
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
531
+
defer middleware.Stop()
533
+
externalURI := "https://api.example.com/protected/resource"
534
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
536
+
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
537
+
req.Host = "api.example.com"
538
+
req.Header.Set("X-Forwarded-Proto", "https")
540
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof)
542
+
t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err)
545
+
if proof == nil || proof.Claims == nil {
546
+
t.Fatal("expected DPoP proof to be returned")
550
+
// TestMiddlewareStop tests that the middleware can be stopped properly
551
+
func TestMiddlewareStop(t *testing.T) {
552
+
fetcher := &mockJWKSFetcher{}
553
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
555
+
// Stop should not panic and should clean up resources
558
+
// Calling Stop again should also be safe (idempotent-ish)
559
+
// Note: The underlying DPoPVerifier.Stop() closes a channel, so this might panic
560
+
// if not handled properly. We test that at least one Stop works.
563
+
// TestOptionalAuth_DPoPBoundToken_NoDPoPHeader tests that OptionalAuth treats
564
+
// tokens with cnf.jkt but no DPoP header as unauthenticated (potential token theft)
565
+
func TestOptionalAuth_DPoPBoundToken_NoDPoPHeader(t *testing.T) {
566
+
// Generate a key pair for DPoP binding
567
+
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
568
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
569
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
571
+
// Create a DPoP-bound token (has cnf.jkt)
572
+
claims := auth.Claims{
573
+
RegisteredClaims: jwt.RegisteredClaims{
574
+
Subject: "did:plc:user123",
575
+
Issuer: "https://test.pds.local",
576
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
577
+
IssuedAt: jwt.NewNumericDate(time.Now()),
580
+
Confirmation: map[string]interface{}{
585
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
586
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
588
+
// Use skipVerify=true to simulate a verified token
589
+
// (In production, skipVerify would be false and VerifyJWT would be called)
590
+
// However, for this test we need skipVerify=false to trigger DPoP checking
591
+
// But the fetcher will fail, so let's use skipVerify=true and verify the logic
592
+
// Actually, the DPoP check only happens when skipVerify=false
594
+
t.Run("with_skipVerify_false", func(t *testing.T) {
595
+
// This will fail at JWT verification level, but that's expected
596
+
// The important thing is the code path for DPoP checking
597
+
fetcher := &mockJWKSFetcher{shouldFail: true}
598
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
599
+
defer middleware.Stop()
601
+
handlerCalled := false
602
+
var capturedDID string
603
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
604
+
handlerCalled = true
605
+
capturedDID = GetUserDID(r)
606
+
w.WriteHeader(http.StatusOK)
609
+
req := httptest.NewRequest("GET", "/test", nil)
610
+
req.Header.Set("Authorization", "Bearer "+tokenString)
611
+
// Deliberately NOT setting DPoP header
612
+
w := httptest.NewRecorder()
614
+
handler.ServeHTTP(w, req)
616
+
// Handler should be called (optional auth doesn't block)
617
+
if !handlerCalled {
618
+
t.Error("handler should be called")
621
+
// But since JWT verification fails, user should not be authenticated
622
+
if capturedDID != "" {
623
+
t.Errorf("expected empty DID when verification fails, got %s", capturedDID)
627
+
t.Run("with_skipVerify_true_dpop_not_checked", func(t *testing.T) {
628
+
// When skipVerify=true, DPoP is not checked (Phase 1 mode)
629
+
fetcher := &mockJWKSFetcher{}
630
+
middleware := NewAtProtoAuthMiddleware(fetcher, true)
631
+
defer middleware.Stop()
633
+
handlerCalled := false
634
+
var capturedDID string
635
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
636
+
handlerCalled = true
637
+
capturedDID = GetUserDID(r)
638
+
w.WriteHeader(http.StatusOK)
641
+
req := httptest.NewRequest("GET", "/test", nil)
642
+
req.Header.Set("Authorization", "Bearer "+tokenString)
644
+
w := httptest.NewRecorder()
646
+
handler.ServeHTTP(w, req)
648
+
if !handlerCalled {
649
+
t.Error("handler should be called")
652
+
// With skipVerify=true, DPoP check is bypassed - token is trusted
653
+
if capturedDID != "did:plc:user123" {
654
+
t.Errorf("expected DID when skipVerify=true, got %s", capturedDID)
659
+
// TestDPoPReplayProtection tests that the same DPoP proof cannot be used twice
660
+
func TestDPoPReplayProtection(t *testing.T) {
661
+
// This tests the NonceCache functionality
662
+
cache := auth.NewNonceCache(5 * time.Minute)
665
+
jti := "unique-proof-id-123"
667
+
// First use should succeed
668
+
if !cache.CheckAndStore(jti) {
669
+
t.Error("First use of jti should succeed")
672
+
// Second use should fail (replay detected)
673
+
if cache.CheckAndStore(jti) {
674
+
t.Error("SECURITY: Replay attack not detected - same jti accepted twice")
677
+
// Different jti should succeed
678
+
if !cache.CheckAndStore("different-jti-456") {
679
+
t.Error("Different jti should succeed")
683
+
// Helper: createDPoPProof creates a DPoP proof JWT for testing
684
+
func createDPoPProof(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri string) string {
685
+
// Create JWK from public key
686
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
688
+
// Create DPoP claims with UUID for jti to ensure uniqueness across tests
689
+
claims := auth.DPoPClaims{
690
+
RegisteredClaims: jwt.RegisteredClaims{
691
+
IssuedAt: jwt.NewNumericDate(time.Now()),
692
+
ID: uuid.New().String(),
694
+
HTTPMethod: method,
698
+
// Create token with custom header
699
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
700
+
token.Header["typ"] = "dpop+jwt"
701
+
token.Header["jwk"] = jwk
703
+
// Sign with private key
704
+
signedToken, err := token.SignedString(privateKey)
706
+
t.Fatalf("failed to sign DPoP proof: %v", err)
712
+
// Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map
713
+
func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} {
716
+
switch pubKey.Curve {
717
+
case elliptic.P256():
719
+
case elliptic.P384():
721
+
case elliptic.P521():
724
+
panic("unsupported curve")
727
+
// Encode coordinates
728
+
xBytes := pubKey.X.Bytes()
729
+
yBytes := pubKey.Y.Bytes()
731
+
// Ensure proper byte length (pad if needed)
732
+
keySize := (pubKey.Curve.Params().BitSize + 7) / 8
733
+
xPadded := make([]byte, keySize)
734
+
yPadded := make([]byte, keySize)
735
+
copy(xPadded[keySize-len(xBytes):], xBytes)
736
+
copy(yPadded[keySize-len(yBytes):], yBytes)
738
+
return map[string]interface{}{
741
+
"x": base64.RawURLEncoding.EncodeToString(xPadded),
742
+
"y": base64.RawURLEncoding.EncodeToString(yPadded),