A community based topic aggregation platform built on atproto

Merge branch 'fix/dpop-security-improvements'

DPoP security improvements addressing PR review findings:

- feat(auth): comprehensive DPoP security improvements
- Access token hash (ath) validation per RFC 9449
- Proxy header support (X-Forwarded-Host, RFC 7239 Forwarded)
- EscapedPath for percent-encoded URLs
- Case-insensitive DPoP scheme per RFC 7235

- fix(auth): prevent goroutine leak from DPoP replay cache
- Graceful server shutdown with signal handling
- Proper cleanup in integration tests

- docs: update authentication documentation for DPoP scheme

+37 -3
cmd/server/main.go
···
"log"
"net/http"
"os"
+
"os/signal"
"strings"
+
"syscall"
"time"
"github.com/go-chi/chi/v5"
···
port = "8080"
}
-
fmt.Printf("Coves AppView starting on port %s\n", port)
-
fmt.Printf("Default PDS: %s\n", defaultPDS)
-
log.Fatal(http.ListenAndServe(":"+port, r))
+
// Create HTTP server for graceful shutdown
+
server := &http.Server{
+
Addr: ":" + port,
+
Handler: r,
+
}
+
+
// Channel to listen for shutdown signals
+
stop := make(chan os.Signal, 1)
+
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+
+
// Start server in goroutine
+
go func() {
+
fmt.Printf("Coves AppView starting on port %s\n", port)
+
fmt.Printf("Default PDS: %s\n", defaultPDS)
+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+
log.Fatalf("Server error: %v", err)
+
}
+
}()
+
+
// Wait for shutdown signal
+
<-stop
+
log.Println("Shutting down server...")
+
+
// Graceful shutdown with timeout
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+
defer cancel()
+
+
// Stop auth middleware background goroutines (DPoP replay cache cleanup)
+
authMiddleware.Stop()
+
log.Println("Auth middleware stopped")
+
+
if err := server.Shutdown(ctx); err != nil {
+
log.Fatalf("Server shutdown error: %v", err)
+
}
+
log.Println("Server stopped gracefully")
}
// authenticateWithPDS creates a session on the PDS and returns an access token
+5 -5
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
···
- Lexicon definitions: `social.coves.community.comment.defs` and `getComments`
- Database query methods with Lemmy hot ranking algorithm
- Service layer with iterative loading strategy for nested replies
-
- XRPC HTTP handler with optional authentication
+
- XRPC HTTP handler with optional DPoP authentication
- Comprehensive integration test suite (11 test scenarios)
**What works:**
···
- Nested replies up to configurable depth (default 10, max 100)
- Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)`
- Cursor-based pagination for stable scrolling
-
- Optional authentication for viewer state (stubbed for Phase 2B)
+
- Optional DPoP authentication for viewer state (stubbed for Phase 2B)
- Timeframe filtering for "top" sort (hour/day/week/month/year/all)
**Endpoints:**
···
- Required: `post` (AT-URI)
- Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe`
- Returns: Array of `threadViewComment` with nested replies + post context
-
- Supports Bearer token for authenticated requests (viewer state)
+
- Supports DPoP-bound access token for authenticated requests (viewer state)
**Files created (9):**
1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions
···
**8. Viewer Authentication Validation (Non-Issue - Architecture Working as Designed)**
- **Initial Concern:** ViewerDID field trusted without verification in service layer
- **Investigation:** Authentication IS properly validated at middleware layer
-
- `OptionalAuth` middleware extracts and validates JWT Bearer tokens
+
- `OptionalAuth` middleware extracts and validates DPoP-bound access tokens
- Uses PDS public keys (JWKS) for signature verification
-
- Validates token expiration, DID format, issuer
+
- Validates DPoP proof, token expiration, DID format, issuer
- Only injects verified DIDs into request context
- Handler extracts DID using `middleware.GetUserDID(r)`
- **Architecture:** Follows industry best practices (authentication at perimeter)
+7 -4
docs/FEED_SYSTEM_IMPLEMENTATION.md
···
# Get personalized timeline (hot posts from subscriptions)
curl -X GET \
'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=hot&limit=15' \
-
-H 'Authorization: Bearer eyJhbGc...'
+
-H 'Authorization: DPoP eyJhbGc...' \
+
-H 'DPoP: eyJhbGc...'
# Get top posts from last week
curl -X GET \
'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \
-
-H 'Authorization: Bearer eyJhbGc...'
+
-H 'Authorization: DPoP eyJhbGc...' \
+
-H 'DPoP: eyJhbGc...'
# Get newest posts with pagination
curl -X GET \
'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \
-
-H 'Authorization: Bearer eyJhbGc...'
+
-H 'Authorization: DPoP eyJhbGc...' \
+
-H 'DPoP: eyJhbGc...'
```
**Response:**
···
- ✅ Context timeout support
### Authentication (Timeline)
-
- ✅ JWT Bearer token required
+
- ✅ DPoP-bound access token required
- ✅ DID extracted from auth context
- ✅ Validates token signature (when AUTH_SKIP_VERIFY=false)
- ✅ Returns 401 on auth failure
+3 -3
docs/PRD_OAUTH.md
···
- ✅ Auth middleware protecting community endpoints
- ✅ Handlers updated to use `GetUserDID(r)`
- ✅ Comprehensive middleware auth tests (11 test cases)
-
- ✅ E2E tests updated to use Bearer tokens
+
- ✅ E2E tests updated to use DPoP-bound tokens
- ✅ Security logging with IP, method, path, issuer
- ✅ Scope validation (atproto required)
- ✅ Issuer HTTPS validation
···
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ...
```
-
Format: `DPoP <access_token>`
+
Format: `DPoP <access_token>` (note: uses "DPoP" scheme, not "Bearer")
The access token is a JWT containing:
```json
···
- [x] All community endpoints reject requests without valid JWT structure
- [x] Integration tests pass with mock tokens (11/11 middleware tests passing)
- [x] Zero security regressions from X-User-DID (JWT validation is strictly better)
-
- [x] E2E tests updated to use proper Bearer token authentication
+
- [x] E2E tests updated to use proper DPoP token authentication
- [x] Build succeeds without compilation errors
### Phase 2 (Beta) - ✅ READY FOR TESTING
+3 -1
docs/aggregators/SETUP_GUIDE.md
···
**Request**:
```bash
+
# Note: This calls the PDS directly, so it uses Bearer authorization (not DPoP)
curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
···
**Request**:
```bash
+
# Note: This calls the Coves API, so it uses DPoP authorization
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
-
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
+
-H "Authorization: DPoP YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"communityDid": "did:plc:community123...",
+8 -2
docs/federation-prd.md
···
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
// Use service auth token instead of community credentials
+
// NOTE: Auth scheme depends on target PDS implementation:
+
// - Standard atproto service auth uses "Bearer" scheme
+
// - Our AppView uses "DPoP" scheme when DPoP-bound tokens are required
+
// For server-to-server with standard PDS, use Bearer; adjust based on target.
req.Header.Set("Authorization", "Bearer "+serviceAuthToken)
req.Header.Set("Content-Type", "application/json")
···
**Request to Remote PDS:**
```http
POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth
-
Authorization: Bearer {coves-social-instance-jwt}
+
Authorization: DPoP {coves-social-instance-jwt}
+
DPoP: {coves-social-dpop-proof}
Content-Type: application/json
{
···
**Using Token to Create Post:**
```http
POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord
-
Authorization: Bearer {service-auth-token}
+
Authorization: DPoP {service-auth-token}
+
DPoP: {service-auth-dpop-proof}
Content-Type: application/json
{
+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
+
}
+12 -8
internal/atproto/auth/README.md
···
# atProto OAuth Authentication
-
This package implements third-party OAuth authentication for Coves, validating JWT Bearer tokens from mobile apps and other atProto clients.
+
This package implements third-party OAuth authentication for Coves, validating DPoP-bound access tokens from mobile apps and other atProto clients.
## Architecture
···
```
Client Request
-
Authorization: Bearer <jwt>
+
Authorization: DPoP <access_token>
+
DPoP: <proof-jwt>
Auth Middleware
-
Extract JWT → Parse Claims → Verify Signature (via JWKS)
+
Extract JWT → Parse Claims → Verify Signature (via JWKS) → Verify DPoP Proof
Inject DID into Context → Call Handler
```
···
```bash
curl -X POST https://coves.social/xrpc/social.coves.community.create \
-
-H "Authorization: Bearer eyJhbGc..." \
+
-H "Authorization: DPoP eyJhbGc..." \
+
-H "DPoP: eyJhbGc..." \
-H "Content-Type: application/json" \
-d '{"name":"Gaming","hostedByDid":"did:plc:..."}'
```
···
│ │ │ (Coves) │
└─────────────┘ └─────────────┘
│ │
-
│ 1. Authorization: Bearer <token> │
+
│ 1. Authorization: DPoP <token> │
│ DPoP: <proof-jwt> │
│───────────────────────────────────────>│
│ │
···
# Create a test JWT (use jwt.io or a tool)
export AUTH_SKIP_VERIFY=true
curl -X POST http://localhost:8081/xrpc/social.coves.community.create \
-
-H "Authorization: Bearer <test-jwt>" \
+
-H "Authorization: DPoP <test-jwt>" \
+
-H "DPoP: <test-dpop-proof>" \
-d '{"name":"Test","hostedByDid":"did:plc:test"}'
```
···
# Use a real JWT from a PDS
export AUTH_SKIP_VERIFY=false
curl -X POST http://localhost:8081/xrpc/social.coves.community.create \
-
-H "Authorization: Bearer <real-jwt>" \
+
-H "Authorization: DPoP <real-jwt>" \
+
-H "DPoP: <real-dpop-proof>" \
-d '{"name":"Test","hostedByDid":"did:plc:test"}'
```
···
### Common Issues
-
1. **Missing Authorization header** → Add `Authorization: Bearer <token>`
+
1. **Missing Authorization header** → Add `Authorization: DPoP <token>` and `DPoP: <proof>`
2. **Token expired** → Get a new token from PDS
3. **Invalid signature** → Ensure token is from a valid PDS
4. **JWKS fetch fails** → Check PDS availability and network connectivity
+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) {
+1 -1
scripts/aggregator-setup/README.md
···
```bash
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
-
-H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \
+
-H "Authorization: DPoP $AGGREGATOR_ACCESS_JWT" \
-H "Content-Type: application/json" \
-d '{
"communityDid": "did:plc:...",
+7 -6
tests/integration/aggregator_e2e_test.go
···
listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService)
createPostHandler := post.NewCreateHandler(postService)
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing
+
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
ctx := context.Background()
···
// Create JWT for aggregator (not a user)
aggregatorJWT := createSimpleTestJWT(aggregatorDID)
-
req.Header.Set("Authorization", "Bearer "+aggregatorJWT)
+
req.Header.Set("Authorization", "DPoP "+aggregatorJWT)
// Execute request through auth middleware + handler
rr := httptest.NewRecorder()
···
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
+
req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID))
rr := httptest.NewRecorder()
handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
···
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
+
req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID))
rr := httptest.NewRecorder()
handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
···
req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
+
req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID))
rr = httptest.NewRecorder()
handler = authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
···
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(unauthorizedAggDID))
+
req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(unauthorizedAggDID))
rr := httptest.NewRecorder()
handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
···
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
+
req.Header.Set("Authorization", "DPoP "+createSimpleTestJWT(aggregatorDID))
rr := httptest.NewRecorder()
handler := authMiddleware.RequireAuth(http.HandlerFunc(createPostHandler.HandleCreate))
+1
tests/integration/blob_upload_e2e_test.go
···
assert.Equal(t, "POST", r.Method, "Should be POST request")
assert.Equal(t, "/xrpc/com.atproto.repo.uploadBlob", r.URL.Path, "Should hit uploadBlob endpoint")
assert.Equal(t, "image/png", r.Header.Get("Content-Type"), "Should have correct content type")
+
// Note: This is a PDS call, so it uses Bearer (not DPoP)
assert.Contains(t, r.Header.Get("Authorization"), "Bearer ", "Should have auth header")
// Return mock blob reference
+13 -8
tests/integration/community_e2e_test.go
···
t.Logf("✅ Authenticated - Instance DID: %s", instanceDID)
-
// Initialize auth middleware (skipVerify=true for E2E tests)
+
// Initialize auth middleware with skipVerify=true
+
// IMPORTANT: PDS password authentication returns Bearer tokens (not DPoP-bound tokens).
+
// E2E tests use these Bearer tokens with the DPoP scheme header, which only works
+
// because skipVerify=true bypasses signature and DPoP binding verification.
+
// In production, skipVerify=false requires proper DPoP-bound tokens from OAuth flow.
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
+
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
// V2.0: Extract instance domain for community provisioning
var instanceDomain string
···
}
req.Header.Set("Content-Type", "application/json")
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
}
req.Header.Set("Content-Type", "application/json")
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
req.Header.Set("Content-Type", "application/json")
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create block request: %v", err)
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create block request: %v", err)
blockHttpReq.Header.Set("Content-Type", "application/json")
-
blockHttpReq.Header.Set("Authorization", "Bearer "+accessToken)
+
blockHttpReq.Header.Set("Authorization", "DPoP "+accessToken)
blockResp, err := http.DefaultClient.Do(blockHttpReq)
if err != nil {
···
t.Fatalf("Failed to create unblock request: %v", err)
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
req.Header.Set("Content-Type", "application/json")
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
+4 -2
tests/integration/jwt_verification_test.go
···
t.Log("Testing auth middleware with skipVerify=true (dev mode)...")
authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS
+
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
handlerCalled := false
var extractedDID string
···
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+accessToken)
+
req.Header.Set("Authorization", "DPoP "+accessToken)
w := httptest.NewRecorder()
testHandler.ServeHTTP(w, req)
···
// Tampered payload should fail JWT parsing even without signature check
jwksFetcher := auth.NewCachedJWKSFetcher(1 * time.Hour)
authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true)
+
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
handlerCalled := false
testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
}))
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "Bearer "+tamperedToken)
+
req.Header.Set("Authorization", "DPoP "+tamperedToken)
w := httptest.NewRecorder()
testHandler.ServeHTTP(w, req)
+2 -1
tests/integration/post_e2e_test.go
···
// Setup auth middleware (skip JWT verification for testing)
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
+
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
// Setup HTTP handler
createHandler := post.NewCreateHandler(postService)
···
// Create a simple JWT for testing (Phase 1: no signature verification)
// In production, this would be a real OAuth token from PDS
testJWT := createSimpleTestJWT(author.DID)
-
req.Header.Set("Authorization", "Bearer "+testJWT)
+
req.Header.Set("Authorization", "DPoP "+testJWT)
// Execute request through auth middleware + handler
rr := httptest.NewRecorder()
+54 -36
tests/integration/user_journey_e2e_test.go
···
userService := users.NewUserService(userRepo, identityResolver, pdsURL)
// Extract instance domain and DID
+
// IMPORTANT: Instance domain must match PDS_SERVICE_HANDLE_DOMAINS config (.community.coves.social)
instanceDID := os.Getenv("INSTANCE_DID")
if instanceDID == "" {
-
instanceDID = "did:web:test.coves.social"
+
instanceDID = "did:web:coves.social" // Must match PDS handle domain config
}
var instanceDomain string
if strings.HasPrefix(instanceDID, "did:web:") {
···
voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
// Setup HTTP server with all routes
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing
+
// IMPORTANT: skipVerify=true because PDS password auth returns Bearer tokens (not DPoP-bound).
+
// E2E tests use Bearer tokens with DPoP scheme header, which only works with skipVerify=true.
+
// In production, skipVerify=false requires proper DPoP-bound tokens from OAuth flow.
+
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
+
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
r := chi.NewRouter()
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators
routes.RegisterPostRoutes(r, postService, authMiddleware)
···
// Cleanup test data from previous runs (clean up ALL journey test data)
timestamp := time.Now().Unix()
-
// Clean up previous test runs - use pattern that matches ANY journey test data
-
_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE '%alice-journey-%' OR voter_did LIKE '%bob-journey-%'")
-
_, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE '%alice-journey-%' OR author_did LIKE '%bob-journey-%'")
-
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE '%gaming-journey-%'")
-
_, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE '%alice-journey-%' OR user_did LIKE '%bob-journey-%'")
-
_, _ = db.Exec("DELETE FROM communities WHERE handle LIKE 'gaming-journey-%'")
-
_, _ = db.Exec("DELETE FROM users WHERE handle LIKE '%alice-journey-%' OR handle LIKE '%bob-journey-%'")
+
// Clean up previous test runs - use pattern that matches journey test data
+
// Handles are now shorter: alice{4-digit}.local.coves.dev, bob{4-digit}.local.coves.dev
+
_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE '%alice%.local.coves.dev%' OR voter_did LIKE '%bob%.local.coves.dev%'")
+
_, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE '%alice%.local.coves.dev%' OR author_did LIKE '%bob%.local.coves.dev%'")
+
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE '%gj%'")
+
_, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE '%alice%.local.coves.dev%' OR user_did LIKE '%bob%.local.coves.dev%'")
+
_, _ = db.Exec("DELETE FROM communities WHERE handle LIKE 'gj%'")
+
_, _ = db.Exec("DELETE FROM users WHERE handle LIKE 'alice%.local.coves.dev' OR handle LIKE 'bob%.local.coves.dev'")
// Defer cleanup for current test run using specific timestamp pattern
defer func() {
-
pattern := fmt.Sprintf("%%journey-%d%%", timestamp)
-
_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE $1", pattern)
-
_, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE $1", pattern)
-
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE $1", pattern)
-
_, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE $1", pattern)
-
_, _ = db.Exec("DELETE FROM communities WHERE did LIKE $1 OR handle LIKE $1", pattern, pattern)
-
_, _ = db.Exec("DELETE FROM users WHERE did LIKE $1 OR handle LIKE $1", pattern, pattern)
+
shortTS := timestamp % 10000
+
alicePattern := fmt.Sprintf("%%alice%d%%", shortTS)
+
bobPattern := fmt.Sprintf("%%bob%d%%", shortTS)
+
gjPattern := fmt.Sprintf("%%gj%d%%", shortTS)
+
_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE $1 OR voter_did LIKE $2", alicePattern, bobPattern)
+
_, _ = db.Exec("DELETE FROM comments WHERE author_did LIKE $1 OR author_did LIKE $2", alicePattern, bobPattern)
+
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE $1", gjPattern)
+
_, _ = db.Exec("DELETE FROM community_subscriptions WHERE user_did LIKE $1 OR user_did LIKE $2", alicePattern, bobPattern)
+
_, _ = db.Exec("DELETE FROM communities WHERE handle LIKE $1", gjPattern)
+
_, _ = db.Exec("DELETE FROM users WHERE handle LIKE $1 OR handle LIKE $2", alicePattern, bobPattern)
}()
// Test variables to track state across steps
···
t.Run("1. User A - Signup and Authenticate", func(t *testing.T) {
t.Log("\n👤 Part 1: User A creates account and authenticates...")
-
userAHandle = fmt.Sprintf("alice-journey-%d.local.coves.dev", timestamp)
-
email := fmt.Sprintf("alice-journey-%d@test.com", timestamp)
+
// Use short handle format to stay under PDS 34-char limit
+
shortTS := timestamp % 10000 // Use last 4 digits
+
userAHandle = fmt.Sprintf("alice%d.local.coves.dev", shortTS)
+
email := fmt.Sprintf("alice%d@test.com", shortTS)
password := "test-password-alice-123"
// Create account on PDS
···
t.Run("2. User A - Create Community", func(t *testing.T) {
t.Log("\n🏘️ Part 2: User A creates a community...")
-
communityName := fmt.Sprintf("gaming-journey-%d", timestamp%10000) // Keep name short
+
// Community handle will be {name}.community.coves.social
+
// Max 34 chars total, so name must be short (34 - 23 = 11 chars max)
+
shortTS := timestamp % 10000
+
communityName := fmt.Sprintf("gj%d", shortTS) // "gj9261" = 6 chars -> handle = 29 chars
createReq := map[string]interface{}{
"name": communityName,
···
httpServer.URL+"/xrpc/social.coves.community.create",
bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+userAToken)
+
req.Header.Set("Authorization", "DPoP "+userAToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
···
httpServer.URL+"/xrpc/social.coves.community.post.create",
bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+userAToken)
+
req.Header.Set("Authorization", "DPoP "+userAToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
···
t.Run("4. User B - Signup and Authenticate", func(t *testing.T) {
t.Log("\n👤 Part 4: User B creates account and authenticates...")
-
userBHandle = fmt.Sprintf("bob-journey-%d.local.coves.dev", timestamp)
-
email := fmt.Sprintf("bob-journey-%d@test.com", timestamp)
+
// Use short handle format to stay under PDS 34-char limit
+
shortTS := timestamp % 10000 // Use last 4 digits
+
userBHandle = fmt.Sprintf("bob%d.local.coves.dev", shortTS)
+
email := fmt.Sprintf("bob%d@test.com", shortTS)
password := "test-password-bob-123"
// Create account on PDS
···
httpServer.URL+"/xrpc/social.coves.community.subscribe",
bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+userBToken)
+
req.Header.Set("Authorization", "DPoP "+userBToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
···
t.Run("9. User B - Verify Timeline Feed Shows Subscribed Community Posts", func(t *testing.T) {
t.Log("\n📰 Part 9: User B checks timeline feed...")
-
req := httptest.NewRequest(http.MethodGet,
-
"/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
-
req = req.WithContext(middleware.SetTestUserDID(req.Context(), userBDID))
-
rec := httptest.NewRecorder()
+
// Use HTTP client to properly go through auth middleware with DPoP token
+
req, _ := http.NewRequest(http.MethodGet,
+
httpServer.URL+"/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
+
req.Header.Set("Authorization", "DPoP "+userBToken)
-
// Call timeline handler directly
-
timelineHandler := httpServer.Config.Handler
-
timelineHandler.ServeHTTP(rec, req)
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
-
require.Equal(t, http.StatusOK, rec.Code, "Timeline request should succeed")
+
require.Equal(t, http.StatusOK, resp.StatusCode, "Timeline request should succeed")
var response timelineCore.TimelineResponse
-
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
// User B should see the post from the community they subscribed to
require.NotEmpty(t, response.Feed, "Timeline should contain posts")
···
"Post author should be User A")
assert.Equal(t, communityDID, feedPost.Post.Community.DID,
"Post community should match")
-
assert.Equal(t, 1, feedPost.Post.UpvoteCount,
+
// Check stats (counts are in Stats struct, not direct fields)
+
require.NotNil(t, feedPost.Post.Stats, "Post should have stats")
+
assert.Equal(t, 1, feedPost.Post.Stats.Upvotes,
"Post should show 1 upvote from User B")
-
assert.Equal(t, 1, feedPost.Post.CommentCount,
+
assert.Equal(t, 1, feedPost.Post.Stats.CommentCount,
"Post should show 1 comment from User B")
break
}
···
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
ON CONFLICT (did) DO NOTHING
`, did, handle, strings.Split(handle, ".")[0], "Test Community", did, ownerDID,
-
"did:web:test.coves.social", "public", "moderator",
+
"did:web:coves.social", "public", "moderator",
fmt.Sprintf("at://%s/social.coves.community.profile/self", did), "fakecid")
require.NoError(t, err, "Failed to simulate community indexing")