A community based topic aggregation platform built on atproto

feat(auth): comprehensive DPoP security improvements

This commit addresses multiple security findings from PR review:

1. Access Token Hash (ath) Validation (RFC 9449 Section 4.2)
- Added VerifyAccessTokenHash() to verify DPoP proof's ath claim
- If ath is present, it MUST match SHA-256 hash of access token
- Prevents proof reuse across different tokens

2. Proxy Header Support for htu Verification
- Added extractSchemeAndHost() for X-Forwarded-Proto/Host support
- RFC 7239 Forwarded header parsing with mixed-case keys and quotes
- Critical for DPoP verification behind TLS-terminating proxies

3. Percent-Encoded Path Handling
- Use r.URL.EscapedPath() instead of r.URL.Path
- Preserves percent-encoding for accurate htu matching

4. Case-Insensitive DPoP Scheme (RFC 7235)
- Added extractDPoPToken() helper with strings.EqualFold()
- Accepts "DPoP", "dpop", "DPOP" per HTTP auth spec

Tests added for all security improvements:
- TestVerifyDPoPBinding_UsesForwardedHost
- TestVerifyDPoPBinding_UsesStandardForwardedHeader
- TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes
- TestVerifyDPoPBinding_AthValidation
- TestRequireAuth_CaseInsensitiveScheme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+523 -47
internal
api
middleware
atproto
auth
+124 -31
internal/api/middleware/auth.go
···
// RequireAuth middleware ensures the user is authenticated with a valid JWT
// If not authenticated, returns 401
// If authenticated, injects user DID and JWT claims into context
+
//
+
// Only accepts DPoP authorization scheme per RFC 9449:
+
// - Authorization: DPoP <token> (DPoP-bound tokens)
func (m *AtProtoAuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract Authorization header
···
return
}
-
// Must be Bearer token
-
if !strings.HasPrefix(authHeader, "Bearer ") {
-
writeAuthError(w, "Invalid Authorization header format. Expected: Bearer <token>")
+
// Only accept DPoP scheme per RFC 9449
+
// HTTP auth schemes are case-insensitive per RFC 7235
+
token, ok := extractDPoPToken(authHeader)
+
if !ok {
+
writeAuthError(w, "Invalid Authorization header format. Expected: DPoP <token>")
return
}
-
-
token := strings.TrimPrefix(authHeader, "Bearer ")
-
token = strings.TrimSpace(token)
var claims *auth.Claims
var err error
···
return
}
-
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader)
+
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader, token)
if err != nil {
log.Printf("[AUTH_FAILURE] type=dpop_verification_failed ip=%s method=%s path=%s error=%v",
r.RemoteAddr, r.Method, r.URL.Path, err)
···
// OptionalAuth middleware loads user info if authenticated, but doesn't require it
// Useful for endpoints that work for both authenticated and anonymous users
+
//
+
// Only accepts DPoP authorization scheme per RFC 9449:
+
// - Authorization: DPoP <token> (DPoP-bound tokens)
func (m *AtProtoAuthMiddleware) OptionalAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
-
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
-
// Not authenticated - continue without user context
+
+
// Only accept DPoP scheme per RFC 9449
+
// HTTP auth schemes are case-insensitive per RFC 7235
+
token, ok := extractDPoPToken(authHeader)
+
if !ok {
+
// Not authenticated or invalid format - continue without user context
next.ServeHTTP(w, r)
return
}
-
-
token := strings.TrimPrefix(authHeader, "Bearer ")
-
token = strings.TrimSpace(token)
var claims *auth.Claims
var err error
···
return
}
-
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader)
+
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader, token)
if err != nil {
// DPoP verification failed - cannot trust this token
log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err)
···
//
// This prevents token theft attacks by proving the client possesses the private key
// corresponding to the public key thumbprint in the token's cnf.jkt claim.
-
func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader string) (*auth.DPoPProof, error) {
+
func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader, accessToken string) (*auth.DPoPProof, error) {
// Extract the cnf.jkt claim from the already-verified token
jkt, err := auth.ExtractCnfJkt(claims)
if err != nil {
···
}
// Build the HTTP URI for DPoP verification
-
// Use the full URL including scheme and host
-
scheme := strings.TrimSpace(r.URL.Scheme)
+
// Use the full URL including scheme and host, respecting proxy headers
+
scheme, host := extractSchemeAndHost(r)
+
+
// Use EscapedPath to preserve percent-encoding (P3 fix)
+
// r.URL.Path is decoded, but DPoP proofs contain the raw encoded path
+
path := r.URL.EscapedPath()
+
if path == "" {
+
path = r.URL.Path // Fallback if EscapedPath returns empty
+
}
+
+
httpURI := scheme + "://" + host + path
+
+
// Verify the DPoP proof
+
proof, err := m.dpopVerifier.VerifyDPoPProof(dpopProofHeader, r.Method, httpURI)
+
if err != nil {
+
return nil, fmt.Errorf("DPoP proof verification failed: %w", err)
+
}
+
+
// Verify the binding between the proof and the token (cnf.jkt)
+
if err := m.dpopVerifier.VerifyTokenBinding(proof, jkt); err != nil {
+
return nil, fmt.Errorf("DPoP binding verification failed: %w", err)
+
}
+
+
// Verify the access token hash (ath) if present in the proof
+
// Per RFC 9449 section 4.2, if ath is present, it MUST match the access token
+
if err := m.dpopVerifier.VerifyAccessTokenHash(proof, accessToken); err != nil {
+
return nil, fmt.Errorf("DPoP ath verification failed: %w", err)
+
}
+
+
return proof, nil
+
}
+
+
// extractSchemeAndHost extracts the scheme and host from the request,
+
// respecting proxy headers (X-Forwarded-Proto, X-Forwarded-Host, Forwarded).
+
// This is critical for DPoP verification when behind TLS-terminating proxies.
+
func extractSchemeAndHost(r *http.Request) (scheme, host string) {
+
// Start with request defaults
+
scheme = r.URL.Scheme
+
host = r.Host
+
+
// Check X-Forwarded-Proto for scheme (most common)
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
-
// Forwarded proto may contain a comma-separated list; use the first entry
parts := strings.Split(forwardedProto, ",")
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
scheme = strings.ToLower(strings.TrimSpace(parts[0]))
}
}
+
+
// Check X-Forwarded-Host for host (common with nginx/traefik)
+
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
+
parts := strings.Split(forwardedHost, ",")
+
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
+
host = strings.TrimSpace(parts[0])
+
}
+
}
+
+
// Check standard Forwarded header (RFC 7239) - takes precedence if present
+
// Format: Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43;host=example.com
+
// RFC 7239 allows: mixed-case keys (Proto, PROTO), quoted values (host="example.com")
+
if forwarded := r.Header.Get("Forwarded"); forwarded != "" {
+
// Parse the first entry (comma-separated list)
+
firstEntry := strings.Split(forwarded, ",")[0]
+
for _, part := range strings.Split(firstEntry, ";") {
+
part = strings.TrimSpace(part)
+
// Split on first '=' to properly handle key=value pairs
+
if idx := strings.Index(part, "="); idx != -1 {
+
key := strings.ToLower(strings.TrimSpace(part[:idx]))
+
value := strings.TrimSpace(part[idx+1:])
+
// Strip optional quotes per RFC 7239 section 4
+
value = strings.Trim(value, "\"")
+
+
switch key {
+
case "proto":
+
scheme = strings.ToLower(value)
+
case "host":
+
host = value
+
}
+
}
+
}
+
}
+
+
// Fallback scheme detection from TLS
if scheme == "" {
if r.TLS != nil {
scheme = "https"
···
scheme = "http"
}
}
-
scheme = strings.ToLower(scheme)
-
httpURI := scheme + "://" + r.Host + r.URL.Path
-
// Verify the DPoP proof
-
proof, err := m.dpopVerifier.VerifyDPoPProof(dpopProofHeader, r.Method, httpURI)
-
if err != nil {
-
return nil, fmt.Errorf("DPoP proof verification failed: %w", err)
-
}
-
-
// Verify the binding between the proof and the token
-
if err := m.dpopVerifier.VerifyTokenBinding(proof, jkt); err != nil {
-
return nil, fmt.Errorf("DPoP binding verification failed: %w", err)
-
}
-
-
return proof, nil
+
return strings.ToLower(scheme), host
}
// writeAuthError writes a JSON error response for authentication failures
···
log.Printf("Failed to write auth error response: %v", err)
}
}
+
+
// extractDPoPToken extracts the token from a DPoP Authorization header.
+
// HTTP auth schemes are case-insensitive per RFC 7235, so "DPoP", "dpop", "DPOP" are all valid.
+
// Returns the token and true if valid DPoP scheme, empty string and false otherwise.
+
func extractDPoPToken(authHeader string) (string, bool) {
+
if authHeader == "" {
+
return "", false
+
}
+
+
// Split on first space: "DPoP <token>" -> ["DPoP", "<token>"]
+
parts := strings.SplitN(authHeader, " ", 2)
+
if len(parts) != 2 {
+
return "", false
+
}
+
+
// Case-insensitive scheme comparison per RFC 7235
+
if !strings.EqualFold(parts[0], "DPoP") {
+
return "", false
+
}
+
+
token := strings.TrimSpace(parts[1])
+
if token == "" {
+
return "", false
+
}
+
+
return token, true
+
}
+378 -16
internal/api/middleware/auth_test.go
···
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
+
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
+
"strings"
"testing"
"time"
···
return tokenString
}
-
// TestRequireAuth_ValidToken tests that valid tokens are accepted (Phase 1)
+
// TestRequireAuth_ValidToken tests that valid tokens are accepted with DPoP scheme (Phase 1)
func TestRequireAuth_ValidToken(t *testing.T) {
fetcher := &mockJWKSFetcher{}
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true
···
token := createTestToken("did:plc:test123")
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+token)
+
req.Header.Set("Authorization", "DPoP "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
}
}
-
// TestRequireAuth_InvalidAuthHeaderFormat tests that non-Bearer tokens are rejected
+
// TestRequireAuth_InvalidAuthHeaderFormat tests that non-DPoP tokens are rejected (including Bearer)
func TestRequireAuth_InvalidAuthHeaderFormat(t *testing.T) {
fetcher := &mockJWKSFetcher{}
middleware := NewAtProtoAuthMiddleware(fetcher, true)
+
tests := []struct {
+
name string
+
header string
+
}{
+
{"Basic auth", "Basic dGVzdDp0ZXN0"},
+
{"Bearer scheme", "Bearer some-token"},
+
{"Invalid format", "InvalidFormat"},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Error("handler should not be called")
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", tt.header)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("expected status 401, got %d", w.Code)
+
}
+
})
+
}
+
}
+
+
// TestRequireAuth_BearerRejectionErrorMessage verifies that Bearer tokens are rejected
+
// with a helpful error message guiding users to use DPoP scheme
+
func TestRequireAuth_BearerRejectionErrorMessage(t *testing.T) {
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true)
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called")
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0") // Wrong format
+
req.Header.Set("Authorization", "Bearer some-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
+
}
+
+
// Verify error message guides user to use DPoP
+
body := w.Body.String()
+
if !strings.Contains(body, "Expected: DPoP") {
+
t.Errorf("error message should guide user to use DPoP, got: %s", body)
+
}
+
}
+
+
// TestRequireAuth_CaseInsensitiveScheme verifies that DPoP scheme matching is case-insensitive
+
// per RFC 7235 which states HTTP auth schemes are case-insensitive
+
func TestRequireAuth_CaseInsensitiveScheme(t *testing.T) {
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true)
+
+
// Create a valid JWT for testing
+
validToken := createValidJWT(t, "did:plc:test123", time.Hour)
+
+
testCases := []struct {
+
name string
+
scheme string
+
}{
+
{"lowercase", "dpop"},
+
{"uppercase", "DPOP"},
+
{"mixed_case", "DpOp"},
+
{"standard", "DPoP"},
+
}
+
+
for _, tc := range testCases {
+
t.Run(tc.name, func(t *testing.T) {
+
handlerCalled := false
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", tc.scheme+" "+validToken)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
if !handlerCalled {
+
t.Errorf("scheme %q should be accepted (case-insensitive per RFC 7235), got status %d: %s",
+
tc.scheme, w.Code, w.Body.String())
+
}
+
})
}
}
···
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer not-a-valid-jwt")
+
req.Header.Set("Authorization", "DPoP not-a-valid-jwt")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
}
}
-
// TestOptionalAuth_WithToken tests that OptionalAuth accepts valid tokens
+
// TestOptionalAuth_WithToken tests that OptionalAuth accepts valid DPoP tokens
func TestOptionalAuth_WithToken(t *testing.T) {
fetcher := &mockJWKSFetcher{}
middleware := NewAtProtoAuthMiddleware(fetcher, true)
···
token := createTestToken("did:plc:test123")
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+token)
+
req.Header.Set("Authorization", "DPoP "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer not-a-valid-jwt")
+
req.Header.Set("Authorization", "DPoP not-a-valid-jwt")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
}))
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
req.Header.Set("DPoP", dpopProof)
w := httptest.NewRecorder()
···
}))
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
// No DPoP header
w := httptest.NewRecorder()
···
}))
req := httptest.NewRequest("POST", "https://api.example.com/protected", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
req.Header.Set("DPoP", dpopProof)
w := httptest.NewRecorder()
···
req.Host = "api.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
-
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof)
+
// Pass a fake access token - ath verification will pass since we don't include ath in the DPoP proof
+
fakeAccessToken := "fake-access-token-for-testing"
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
if err != nil {
t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err)
}
···
}
}
+
// TestVerifyDPoPBinding_UsesForwardedHost ensures we honor X-Forwarded-Host header
+
// when behind a TLS-terminating proxy that rewrites the Host header.
+
func TestVerifyDPoPBinding_UsesForwardedHost(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
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()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
+
// External URI that the client uses
+
externalURI := "https://api.example.com/protected/resource"
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
+
+
// Request hits internal service with internal hostname, but X-Forwarded-Host has public hostname
+
req := httptest.NewRequest("GET", "http://internal-service:8080/protected/resource", nil)
+
req.Host = "internal-service:8080" // Internal host after proxy
+
req.Header.Set("X-Forwarded-Proto", "https")
+
req.Header.Set("X-Forwarded-Host", "api.example.com") // Original public host
+
+
fakeAccessToken := "fake-access-token-for-testing"
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
+
if err != nil {
+
t.Fatalf("expected DPoP verification to succeed with X-Forwarded-Host, got %v", err)
+
}
+
+
if proof == nil || proof.Claims == nil {
+
t.Fatal("expected DPoP proof to be returned")
+
}
+
}
+
+
// TestVerifyDPoPBinding_UsesStandardForwardedHeader tests RFC 7239 Forwarded header parsing
+
func TestVerifyDPoPBinding_UsesStandardForwardedHeader(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
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()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
+
// External URI
+
externalURI := "https://api.example.com/protected/resource"
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
+
+
// Request with standard Forwarded header (RFC 7239)
+
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
+
req.Host = "internal-service"
+
req.Header.Set("Forwarded", "for=192.0.2.60;proto=https;host=api.example.com")
+
+
fakeAccessToken := "fake-access-token-for-testing"
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
+
if err != nil {
+
t.Fatalf("expected DPoP verification to succeed with Forwarded header, got %v", err)
+
}
+
+
if proof == nil {
+
t.Fatal("expected DPoP proof to be returned")
+
}
+
}
+
+
// TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes tests RFC 7239 edge cases:
+
// mixed-case keys (Proto vs proto) and quoted values (host="example.com")
+
func TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
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()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
+
// External URI that the client uses
+
externalURI := "https://api.example.com/protected/resource"
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
+
+
// Request with RFC 7239 Forwarded header using:
+
// - Mixed-case keys: "Proto" instead of "proto", "Host" instead of "host"
+
// - Quoted value: Host="api.example.com" (legal per RFC 7239 section 4)
+
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
+
req.Host = "internal-service"
+
req.Header.Set("Forwarded", `for=192.0.2.60;Proto=https;Host="api.example.com"`)
+
+
fakeAccessToken := "fake-access-token-for-testing"
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
+
if err != nil {
+
t.Fatalf("expected DPoP verification to succeed with mixed-case/quoted Forwarded header, got %v", err)
+
}
+
+
if proof == nil {
+
t.Fatal("expected DPoP proof to be returned")
+
}
+
}
+
+
// TestVerifyDPoPBinding_AthValidation tests access token hash (ath) claim validation
+
func TestVerifyDPoPBinding_AthValidation(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
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()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
+
accessToken := "real-access-token-12345"
+
+
t.Run("ath_matches_access_token", func(t *testing.T) {
+
// Create DPoP proof with ath claim matching the access token
+
dpopProof := createDPoPProofWithAth(t, privateKey, "GET", "https://api.example.com/resource", accessToken)
+
+
req := httptest.NewRequest("GET", "https://api.example.com/resource", nil)
+
req.Host = "api.example.com"
+
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, accessToken)
+
if err != nil {
+
t.Fatalf("expected verification to succeed with matching ath, got %v", err)
+
}
+
if proof == nil {
+
t.Fatal("expected proof to be returned")
+
}
+
})
+
+
t.Run("ath_mismatch_rejected", func(t *testing.T) {
+
// Create DPoP proof with ath for a DIFFERENT token
+
differentToken := "different-token-67890"
+
dpopProof := createDPoPProofWithAth(t, privateKey, "POST", "https://api.example.com/resource", differentToken)
+
+
req := httptest.NewRequest("POST", "https://api.example.com/resource", nil)
+
req.Host = "api.example.com"
+
+
// Try to use with the original access token - should fail
+
_, err := middleware.verifyDPoPBinding(req, claims, dpopProof, accessToken)
+
if err == nil {
+
t.Fatal("SECURITY: expected verification to fail when ath doesn't match access token")
+
}
+
if !strings.Contains(err.Error(), "ath") {
+
t.Errorf("error should mention ath mismatch, got: %v", err)
+
}
+
})
+
}
+
// TestMiddlewareStop tests that the middleware can be stopped properly
func TestMiddlewareStop(t *testing.T) {
fetcher := &mockJWKSFetcher{}
···
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
// Deliberately NOT setting DPoP header
w := httptest.NewRecorder()
···
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("Authorization", "DPoP "+tokenString)
// No DPoP header
w := httptest.NewRecorder()
···
return signedToken
}
+
// Helper: createDPoPProofWithAth creates a DPoP proof JWT with ath (access token hash) claim
+
func createDPoPProofWithAth(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri, accessToken string) string {
+
// Create JWK from public key
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
+
// Calculate ath: base64url(SHA-256(access_token))
+
hash := sha256.Sum256([]byte(accessToken))
+
ath := base64.RawURLEncoding.EncodeToString(hash[:])
+
+
// Create DPoP claims with ath
+
claims := auth.DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
ID: uuid.New().String(),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
AccessTokenHash: ath,
+
}
+
+
// 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)
+
if err != nil {
+
t.Fatalf("failed to sign DPoP proof: %v", err)
+
}
+
+
return signedToken
+
}
+
// Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map
func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} {
// Get curve name
···
"y": base64.RawURLEncoding.EncodeToString(yPadded),
}
}
+
+
// Helper: createValidJWT creates a valid unsigned JWT token for testing
+
// This is used with skipVerify=true middleware where signature verification is skipped
+
func createValidJWT(t *testing.T, subject string, expiry time.Duration) string {
+
t.Helper()
+
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: subject,
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
}
+
+
// Create unsigned token (for skipVerify=true tests)
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
signedToken, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
if err != nil {
+
t.Fatalf("failed to create test JWT: %v", err)
+
}
+
+
return signedToken
+
}
+21
internal/atproto/auth/dpop.go
···
return nil
}
+
// VerifyAccessTokenHash verifies the DPoP proof's ath (access token hash) claim
+
// matches the SHA-256 hash of the presented access token.
+
// Per RFC 9449 section 4.2, if ath is present, the RS MUST verify it.
+
func (v *DPoPVerifier) VerifyAccessTokenHash(proof *DPoPProof, accessToken string) error {
+
// If ath claim is not present, that's acceptable per RFC 9449
+
// (ath is only required when the RS mandates it)
+
if proof.Claims.AccessTokenHash == "" {
+
return nil
+
}
+
+
// Calculate the expected ath: base64url(SHA-256(access_token))
+
hash := sha256.Sum256([]byte(accessToken))
+
expectedAth := base64.RawURLEncoding.EncodeToString(hash[:])
+
+
if proof.Claims.AccessTokenHash != expectedAth {
+
return fmt.Errorf("DPoP proof ath mismatch: proof bound to different access token")
+
}
+
+
return nil
+
}
+
// CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638
// The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation
func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) {