···
+
"Coves/internal/atproto/auth"
···
"github.com/golang-jwt/jwt/v5"
+
"github.com/google/uuid"
// mockJWKSFetcher is a test double for JWKSFetcher
···
t.Errorf("expected nil claims, got %+v", claims)
+
// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified
+
func TestGetDPoPProof_NotAuthenticated(t *testing.T) {
+
req := httptest.NewRequest("GET", "/test", nil)
+
proof := GetDPoPProof(req)
+
t.Errorf("expected nil proof, got %+v", proof)
+
// TestRequireAuth_WithDPoP_SecurityModel tests the correct DPoP security model:
+
// Token MUST be verified first, then DPoP is checked as an additional layer.
+
// DPoP is NOT a fallback for failed token verification.
+
func TestRequireAuth_WithDPoP_SecurityModel(t *testing.T) {
+
// Generate an ECDSA key pair for DPoP
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
t.Fatalf("failed to generate key: %v", err)
+
// Calculate JWK thumbprint for cnf.jkt
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
t.Fatalf("failed to calculate thumbprint: %v", err)
+
t.Run("DPoP_is_NOT_fallback_for_failed_verification", func(t *testing.T) {
+
// SECURITY TEST: When token verification fails, DPoP should NOT be used as fallback
+
// This prevents an attacker from forging a token with their own cnf.jkt
+
// Create a DPoP-bound access token (unsigned - will fail verification)
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:attacker",
+
Issuer: "https://external.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
Confirmation: map[string]interface{}{
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
// Create valid DPoP proof (attacker has the private key)
+
dpopProof := createDPoPProof(t, privateKey, "GET", "https://test.local/api/endpoint")
+
// Mock fetcher that fails (simulating external PDS without JWKS)
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Error("SECURITY VULNERABILITY: handler was called despite token verification failure")
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("DPoP", dpopProof)
+
w := httptest.NewRecorder()
+
handler.ServeHTTP(w, req)
+
// MUST reject - token verification failed, DPoP cannot substitute for signature verification
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("SECURITY: expected 401 for unverified token, got %d", w.Code)
+
t.Run("DPoP_required_when_cnf_jkt_present_in_verified_token", func(t *testing.T) {
+
// When token has cnf.jkt, DPoP header MUST be present
+
// This test uses skipVerify=true to simulate a verified token
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:test123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
Confirmation: map[string]interface{}{
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
// NO DPoP header - should fail when skipVerify is false
+
// Note: with skipVerify=true, DPoP is not checked
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true for parsing
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
w := httptest.NewRecorder()
+
handler.ServeHTTP(w, req)
+
// With skipVerify=true, DPoP is not checked, so this should succeed
+
t.Error("handler should be called when skipVerify=true")
+
// TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback is the key security test.
+
// It ensures that DPoP cannot be used as a fallback when token signature verification fails.
+
func TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback(t *testing.T) {
+
// Generate a key pair (attacker's key)
+
attackerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
jwk := ecdsaPublicKeyToJWK(&attackerKey.PublicKey)
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
+
// Create a FORGED token claiming to be the victim
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:victim_user", // Attacker claims to be victim
+
Issuer: "https://untrusted.pds",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint, // Attacker uses their own key
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
// Attacker creates a valid DPoP proof with their key
+
dpopProof := createDPoPProof(t, attackerKey, "POST", "https://api.example.com/protected")
+
// Fetcher fails (external PDS without JWKS)
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Fatalf("CRITICAL SECURITY FAILURE: Request authenticated as %s despite forged token!",
+
req := httptest.NewRequest("POST", "https://api.example.com/protected", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("DPoP", dpopProof)
+
w := httptest.NewRecorder()
+
handler.ServeHTTP(w, req)
+
// MUST reject - the token signature was never verified
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)
+
// TestVerifyDPoPBinding_UsesForwardedProto ensures we honor the external HTTPS
+
// scheme when TLS is terminated upstream and X-Forwarded-Proto is present.
+
func TestVerifyDPoPBinding_UsesForwardedProto(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
t.Fatalf("failed to generate key: %v", err)
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
t.Fatalf("failed to calculate thumbprint: %v", err)
+
claims := &auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:test123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
Confirmation: map[string]interface{}{
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
externalURI := "https://api.example.com/protected/resource"
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
+
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
+
req.Host = "api.example.com"
+
req.Header.Set("X-Forwarded-Proto", "https")
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof)
+
t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err)
+
if proof == nil || proof.Claims == nil {
+
t.Fatal("expected DPoP proof to be returned")
+
// TestMiddlewareStop tests that the middleware can be stopped properly
+
func TestMiddlewareStop(t *testing.T) {
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
+
// Stop should not panic and should clean up resources
+
// Calling Stop again should also be safe (idempotent-ish)
+
// Note: The underlying DPoPVerifier.Stop() closes a channel, so this might panic
+
// if not handled properly. We test that at least one Stop works.
+
// TestOptionalAuth_DPoPBoundToken_NoDPoPHeader tests that OptionalAuth treats
+
// tokens with cnf.jkt but no DPoP header as unauthenticated (potential token theft)
+
func TestOptionalAuth_DPoPBoundToken_NoDPoPHeader(t *testing.T) {
+
// Generate a key pair for DPoP binding
+
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
+
// Create a DPoP-bound token (has cnf.jkt)
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:user123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
Confirmation: map[string]interface{}{
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
// Use skipVerify=true to simulate a verified token
+
// (In production, skipVerify would be false and VerifyJWT would be called)
+
// However, for this test we need skipVerify=false to trigger DPoP checking
+
// But the fetcher will fail, so let's use skipVerify=true and verify the logic
+
// Actually, the DPoP check only happens when skipVerify=false
+
t.Run("with_skipVerify_false", func(t *testing.T) {
+
// This will fail at JWT verification level, but that's expected
+
// The important thing is the code path for DPoP checking
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
+
defer middleware.Stop()
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// Deliberately NOT setting DPoP header
+
w := httptest.NewRecorder()
+
handler.ServeHTTP(w, req)
+
// Handler should be called (optional auth doesn't block)
+
t.Error("handler should be called")
+
// But since JWT verification fails, user should not be authenticated
+
t.Errorf("expected empty DID when verification fails, got %s", capturedDID)
+
t.Run("with_skipVerify_true_dpop_not_checked", func(t *testing.T) {
+
// When skipVerify=true, DPoP is not checked (Phase 1 mode)
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true)
+
defer middleware.Stop()
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
w := httptest.NewRecorder()
+
handler.ServeHTTP(w, req)
+
t.Error("handler should be called")
+
// With skipVerify=true, DPoP check is bypassed - token is trusted
+
if capturedDID != "did:plc:user123" {
+
t.Errorf("expected DID when skipVerify=true, got %s", capturedDID)
+
// TestDPoPReplayProtection tests that the same DPoP proof cannot be used twice
+
func TestDPoPReplayProtection(t *testing.T) {
+
// This tests the NonceCache functionality
+
cache := auth.NewNonceCache(5 * time.Minute)
+
jti := "unique-proof-id-123"
+
// First use should succeed
+
if !cache.CheckAndStore(jti) {
+
t.Error("First use of jti should succeed")
+
// Second use should fail (replay detected)
+
if cache.CheckAndStore(jti) {
+
t.Error("SECURITY: Replay attack not detected - same jti accepted twice")
+
// Different jti should succeed
+
if !cache.CheckAndStore("different-jti-456") {
+
t.Error("Different jti should succeed")
+
// Helper: createDPoPProof creates a DPoP proof JWT for testing
+
func createDPoPProof(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri string) string {
+
// Create JWK from public key
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
// Create DPoP claims with UUID for jti to ensure uniqueness across tests
+
claims := auth.DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
ID: uuid.New().String(),
+
// Create token with custom header
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = jwk
+
// Sign with private key
+
signedToken, err := token.SignedString(privateKey)
+
t.Fatalf("failed to sign DPoP proof: %v", err)
+
// Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map
+
func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} {
+
panic("unsupported curve")
+
xBytes := pubKey.X.Bytes()
+
yBytes := pubKey.Y.Bytes()
+
// Ensure proper byte length (pad if needed)
+
keySize := (pubKey.Curve.Params().BitSize + 7) / 8
+
xPadded := make([]byte, keySize)
+
yPadded := make([]byte, keySize)
+
copy(xPadded[keySize-len(xBytes):], xBytes)
+
copy(yPadded[keySize-len(yBytes):], yBytes)
+
return map[string]interface{}{
+
"x": base64.RawURLEncoding.EncodeToString(xPadded),
+
"y": base64.RawURLEncoding.EncodeToString(yPadded),