A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+2520 -395
docs
internal
static
.well-known
tests
+1 -2
internal/api/handlers/aggregator/errors.go
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/aggregators"
)
// ErrorResponse represents an XRPC error response
+1 -2
internal/api/handlers/aggregator/get_authorizations.go
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/aggregators"
)
// GetAuthorizationsHandler handles listing authorizations for an aggregator
+1 -2
internal/api/handlers/aggregator/get_services.go
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/core/aggregators"
)
// GetServicesHandler handles aggregator service details retrieval
+1 -2
internal/api/handlers/aggregator/list_for_community.go
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/aggregators"
)
// ListForCommunityHandler handles listing aggregators for a community
+1 -1
internal/api/handlers/aggregator/register.go
···
if err != nil {
return fmt.Errorf("failed to fetch .well-known/atproto-did from %s: %w", domain, err)
}
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Check status code
if resp.StatusCode != http.StatusOK {
+1 -2
internal/api/handlers/comments/errors.go
···
package comments
import (
+
"Coves/internal/core/comments"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/comments"
)
// errorResponse represents a standardized JSON error response
+2 -3
internal/api/handlers/comments/get_comments.go
···
package comments
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/comments"
)
// GetCommentsHandler handles comment retrieval for posts
+1 -2
internal/api/handlers/comments/middleware.go
···
package comments
import (
-
"net/http"
-
"Coves/internal/api/middleware"
+
"net/http"
)
// OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package.
+1 -2
internal/api/handlers/comments/service_adapter.go
···
package comments
import (
-
"net/http"
-
"Coves/internal/core/comments"
+
"net/http"
)
// ServiceAdapter adapts the core comments.Service to the handler's Service interface
+2 -3
internal/api/handlers/community/block.go
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// BlockHandler handles community blocking operations
+2 -3
internal/api/handlers/community/create.go
···
package community
import (
-
"encoding/json"
-
"net/http"
-
"Coves/internal/api/middleware"
"Coves/internal/core/communities"
+
"encoding/json"
+
"net/http"
)
// CreateHandler handles community creation
+6 -7
internal/api/handlers/community/create_test.go
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"net/http/httptest"
"testing"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// mockCommunityService implements communities.Service for testing
···
return nil, nil
}
-
func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {
-
return nil, 0, nil
+
func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+
return nil, nil
}
func (m *mockCommunityService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
···
tests := []struct {
name string
-
allowedDIDs []string
requestDID string
-
expectedStatus int
expectedError string
+
allowedDIDs []string
+
expectedStatus int
}{
{
name: "allowed DID can create community",
+1 -2
internal/api/handlers/community/errors.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/communities"
)
// XRPCError represents an XRPC error response
+1 -2
internal/api/handlers/community/get.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
-
-
"Coves/internal/core/communities"
)
// GetHandler handles community retrieval
+1 -2
internal/api/handlers/community/list.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
"strconv"
-
-
"Coves/internal/core/communities"
)
// ListHandler handles listing communities
+1 -2
internal/api/handlers/community/search.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
"strconv"
-
-
"Coves/internal/core/communities"
)
// SearchHandler handles community search
+2 -3
internal/api/handlers/community/subscribe.go
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// SubscribeHandler handles community subscriptions
+2 -3
internal/api/handlers/community/update.go
···
package community
import (
-
"encoding/json"
-
"net/http"
-
"Coves/internal/api/middleware"
"Coves/internal/core/communities"
+
"encoding/json"
+
"net/http"
)
// UpdateHandler handles community updates
+1 -2
internal/api/handlers/communityFeed/errors.go
···
package communityFeed
import (
+
"Coves/internal/core/communityFeeds"
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/communityFeeds"
)
// ErrorResponse represents an XRPC error response
+2 -3
internal/api/handlers/communityFeed/get_community.go
···
package communityFeed
import (
+
"Coves/internal/core/communityFeeds"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/communityFeeds"
-
"Coves/internal/core/posts"
)
// GetCommunityHandler handles community feed retrieval
+1 -2
internal/api/handlers/discover/errors.go
···
package discover
import (
+
"Coves/internal/core/discover"
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/discover"
)
// XRPCError represents an XRPC error response
+2 -3
internal/api/handlers/discover/get_discover.go
···
package discover
import (
+
"Coves/internal/core/discover"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/discover"
-
"Coves/internal/core/posts"
)
// GetDiscoverHandler handles discover feed retrieval
+2 -3
internal/api/handlers/post/create.go
···
package post
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/posts"
)
// CreateHandler handles post creation requests
+2 -3
internal/api/handlers/post/errors.go
···
package post
import (
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/posts"
)
type errorResponse struct {
+1 -2
internal/api/handlers/timeline/errors.go
···
package timeline
import (
+
"Coves/internal/core/timeline"
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/timeline"
)
// XRPCError represents an XRPC error response
+3 -4
internal/api/handlers/timeline/get_timeline.go
···
package timeline
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/timeline"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/timeline"
)
// GetTimelineHandler handles timeline feed retrieval
+1 -2
internal/atproto/jetstream/aggregator_consumer.go
···
package jetstream
import (
+
"Coves/internal/core/aggregators"
"context"
"encoding/json"
"fmt"
"log"
"time"
-
-
"Coves/internal/core/aggregators"
)
// AggregatorEventConsumer consumes aggregator-related events from Jetstream
+2 -3
internal/atproto/jetstream/comment_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/comments"
"context"
"database/sql"
"encoding/json"
···
"strings"
"time"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/comments"
-
"github.com/lib/pq"
)
+3 -4
internal/atproto/jetstream/community_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
···
"strings"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
-
lru "github.com/hashicorp/golang-lru/v2"
"golang.org/x/net/publicsuffix"
"golang.org/x/time/rate"
+3 -4
internal/atproto/jetstream/post_consumer.go
···
package jetstream
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"database/sql"
"encoding/json"
···
"log"
"strings"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
)
// PostEventConsumer consumes post-related events from Jetstream
+2 -3
internal/atproto/jetstream/user_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"fmt"
···
"sync"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/core/users"
-
"github.com/gorilla/websocket"
)
+3 -4
internal/atproto/jetstream/vote_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
"context"
"database/sql"
"fmt"
"log"
"strings"
"time"
-
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/users"
-
"Coves/internal/core/votes"
)
// VoteEventConsumer consumes vote-related events from Jetstream
+1 -2
internal/core/aggregators/service.go
···
package aggregators
import (
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
"time"
-
"Coves/internal/core/communities"
-
"github.com/xeipuuv/gojsonschema"
)
+1 -2
internal/core/blobs/service.go
···
package blobs
import (
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"log"
"net/http"
"time"
-
-
"Coves/internal/core/communities"
)
// Service defines the interface for blob operations
+3 -4
internal/core/comments/comment_service.go
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"errors"
···
"net/url"
"strings"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
)
const (
+5 -6
internal/core/comments/comment_service_test.go
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"errors"
"testing"
"time"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"github.com/stretchr/testify/assert"
)
···
return nil
}
-
func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {
-
return nil, 0, nil
+
func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+
return nil, nil
}
func (m *mockCommunityRepo) Search(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
+1 -2
internal/core/communities/service.go
···
package communities
import (
+
"Coves/internal/atproto/utils"
"bytes"
"context"
"encoding/json"
···
"strings"
"sync"
"time"
-
-
"Coves/internal/atproto/utils"
)
// Community handle validation regex (DNS-valid handle: name.community.instance.com)
+1 -2
internal/core/communityFeeds/service.go
···
package communityFeeds
import (
+
"Coves/internal/core/communities"
"context"
"fmt"
-
-
"Coves/internal/core/communities"
)
type feedService struct {
+1 -2
internal/core/communityFeeds/types.go
···
package communityFeeds
import (
-
"time"
-
"Coves/internal/core/posts"
+
"time"
)
// GetCommunityFeedRequest represents input for fetching a community feed
+1 -2
internal/core/discover/types.go
···
package discover
import (
+
"Coves/internal/core/posts"
"context"
"errors"
-
-
"Coves/internal/core/posts"
)
// Repository defines discover data access interface
+5 -6
internal/core/posts/service.go
···
package posts
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/blobs"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/unfurl"
"bytes"
"context"
"encoding/json"
···
"net/http"
"os"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/blobs"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/unfurl"
)
type postService struct {
+1 -2
internal/core/timeline/types.go
···
package timeline
import (
+
"Coves/internal/core/posts"
"context"
"errors"
"time"
-
-
"Coves/internal/core/posts"
)
// Repository defines timeline data access interface
+1 -2
internal/core/users/service.go
···
package users
import (
+
"Coves/internal/atproto/identity"
"bytes"
"context"
"encoding/json"
···
"regexp"
"strings"
"time"
-
-
"Coves/internal/atproto/identity"
)
// atProto handle validation regex (per official atProto spec: https://atproto.com/specs/handle)
+1 -2
internal/db/postgres/aggregator_repo.go
···
package postgres
import (
+
"Coves/internal/core/aggregators"
"context"
"database/sql"
"fmt"
"strings"
"time"
-
-
"Coves/internal/core/aggregators"
)
type postgresAggregatorRepo struct {
+1 -2
internal/db/postgres/comment_repo.go
···
package postgres
import (
+
"Coves/internal/core/comments"
"context"
"database/sql"
"encoding/base64"
···
"log"
"strings"
-
"Coves/internal/core/comments"
-
"github.com/lib/pq"
)
+2 -4
internal/db/postgres/community_repo.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
"Coves/internal/core/communities"
-
"github.com/lib/pq"
)
···
}
// Build sort clause - map sort enum to DB columns
-
sortColumn := "subscriber_count" // default: popular
-
sortOrder := "DESC"
+
var sortColumn, sortOrder string
switch req.Sort {
case "popular":
+1 -2
internal/db/postgres/community_repo_blocks.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
-
-
"Coves/internal/core/communities"
)
// BlockCommunity creates a new block record (idempotent)
+1 -2
internal/db/postgres/community_repo_memberships.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/communities"
)
// CreateMembership creates a new membership record
+1 -2
internal/db/postgres/community_repo_subscriptions.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/communities"
)
// Subscribe creates a new subscription record
+1 -2
internal/db/postgres/discover_repo.go
···
package postgres
import (
+
"Coves/internal/core/discover"
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/discover"
)
type postgresDiscoverRepo struct {
+1 -2
internal/db/postgres/feed_repo.go
···
package postgres
import (
+
"Coves/internal/core/communityFeeds"
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/communityFeeds"
)
type postgresFeedRepo struct {
+1 -2
internal/db/postgres/feed_repo_base.go
···
package postgres
import (
+
"Coves/internal/core/posts"
"crypto/hmac"
"crypto/sha256"
"database/sql"
···
"strconv"
"strings"
"time"
-
-
"Coves/internal/core/posts"
)
// feedRepoBase contains shared logic for timeline and discover feed repositories
+1 -2
internal/db/postgres/post_repo.go
···
package postgres
import (
+
"Coves/internal/core/posts"
"context"
"database/sql"
"fmt"
"strings"
-
-
"Coves/internal/core/posts"
)
type postgresPostRepo struct {
+1 -2
internal/db/postgres/timeline_repo.go
···
package postgres
import (
+
"Coves/internal/core/timeline"
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/timeline"
)
type postgresTimelineRepo struct {
+1 -2
internal/db/postgres/user_repo.go
···
package postgres
import (
+
"Coves/internal/core/users"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
"Coves/internal/core/users"
-
"github.com/lib/pq"
)
+1 -2
internal/db/postgres/vote_repo.go
···
package postgres
import (
+
"Coves/internal/core/votes"
"context"
"database/sql"
"fmt"
"strings"
-
-
"Coves/internal/core/votes"
)
type postgresVoteRepo struct {
+1 -2
internal/db/postgres/vote_repo_test.go
···
package postgres
import (
+
"Coves/internal/core/votes"
"context"
"database/sql"
"os"
"testing"
"time"
-
"Coves/internal/core/votes"
-
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/assert"
+7 -8
tests/e2e/error_recovery_test.go
···
package e2e
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"fmt"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
)
···
testCases := []struct {
name string
-
event jetstream.JetstreamEvent
shouldLog string
+
event jetstream.JetstreamEvent
}{
{
name: "Nil identity data",
···
if shouldFail.Load() {
t.Logf("Mock PDS: Simulating unavailability (request #%d)", requestCount.Load())
w.WriteHeader(http.StatusServiceUnavailable)
-
w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
+
_, _ = w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
return
}
t.Logf("Mock PDS: Serving request successfully (request #%d)", requestCount.Load())
// Simulate successful PDS response
w.WriteHeader(http.StatusOK)
-
w.Write([]byte(`{"did":"did:plc:pdstest123","handle":"pds.test"}`))
+
_, _ = w.Write([]byte(`{"did":"did:plc:pdstest123","handle":"pds.test"}`))
}))
defer mockPDS.Close()
+1 -2
tests/e2e/ratelimit_e2e_test.go
···
package e2e
import (
+
"Coves/internal/api/middleware"
"bytes"
"encoding/json"
"net/http"
···
"testing"
"time"
-
"Coves/internal/api/middleware"
-
"github.com/stretchr/testify/assert"
)
+4 -5
tests/e2e/user_signup_test.go
···
package e2e
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
)
+10 -11
tests/integration/aggregator_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/aggregator"
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/handlers/aggregator"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/assert"
+14 -14
tests/integration/aggregator_registration_test.go
···
// Setup test database
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
testDID := "did:plc:test123"
testHandle := "aggregator.bsky.social"
···
wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Header().Set("Content-Type", "text/plain")
-
w.Write([]byte(testDID))
+
_, _ = w.Write([]byte(testDID))
} else {
w.WriteHeader(http.StatusNotFound)
}
···
// Setup test database
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
// Setup test server that returns wrong DID
wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Header().Set("Content-Type", "text/plain")
-
w.Write([]byte("did:plc:wrongdid"))
+
_, _ = w.Write([]byte("did:plc:wrongdid"))
} else {
w.WriteHeader(http.StatusNotFound)
}
···
}
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
tests := []struct {
name string
···
}
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
// Pre-create user with same DID
existingDID := "did:plc:existing123"
···
wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Header().Set("Content-Type", "text/plain")
-
w.Write([]byte(existingDID))
+
_, _ = w.Write([]byte(existingDID))
} else {
w.WriteHeader(http.StatusNotFound)
}
···
}
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
// Setup test server that returns 404 for .well-known
wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
}
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
testDID := "did:plc:toolarge"
···
}
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
testDID := "did:plc:nonexistent"
···
wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Header().Set("Content-Type", "text/plain")
-
w.Write([]byte(testDID))
+
_, _ = w.Write([]byte(testDID))
} else {
w.WriteHeader(http.StatusNotFound)
}
···
}
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
testDID := "did:plc:largedos123"
···
// with real .well-known server and real identity resolution
db := setupTestDB(t)
-
defer db.Close()
+
defer func() { _ = db.Close() }()
testDID := "did:plc:e2etest123"
testHandle := "e2ebot.bsky.social"
···
wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Header().Set("Content-Type", "text/plain")
-
w.Write([]byte(testDID))
+
_, _ = w.Write([]byte(testDID))
} else {
w.WriteHeader(http.StatusNotFound)
}
+3 -4
tests/integration/aggregator_test.go
···
package integration
import (
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestAggregatorRepository_Create tests basic aggregator creation
+13 -14
tests/integration/blob_upload_e2e_test.go
···
package integration
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/blobs"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/blobs"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
···
if err != nil {
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
}
-
defer healthResp.Body.Close()
+
defer func() { _ = healthResp.Body.Close() }()
if healthResp.StatusCode != http.StatusOK {
t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
}
···
if err != nil {
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
}
-
defer healthResp.Body.Close()
+
defer func() { _ = healthResp.Body.Close() }()
if healthResp.StatusCode != http.StatusOK {
t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
}
···
t.Run("Accept matching image formats with correct MIME types", func(t *testing.T) {
testCases := []struct {
+
createFunc func(*testing.T, int, int, color.Color) []byte
format string
mimeType string
-
createFunc func(*testing.T, int, int, color.Color) []byte
}{
-
{"PNG", "image/png", createTestPNG},
-
{"JPEG", "image/jpeg", createTestJPEG},
+
{createTestPNG, "PNG", "image/png"},
+
{createTestJPEG, "JPEG", "image/jpeg"},
// Note: WebP requires external library (golang.org/x/image/webp)
// For now, we test that the MIME type is accepted even with PNG data
// In production, actual WebP validation would happen at PDS
-
{"WebP (MIME only)", "image/webp", createTestPNG},
+
{createTestPNG, "WebP (MIME only)", "image/webp"},
}
for _, tc := range testCases {
+14 -14
tests/integration/block_handle_resolution_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/community"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"net/http/httptest"
"testing"
-
"Coves/internal/api/handlers/community"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
postgresRepo "Coves/internal/db/postgres"
)
···
// We expect 401 (no auth) but verify the error is NOT "Community not found"
// If handle resolution worked, we'd get past that validation
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("Handle resolution failed - got 404 CommunityNotFound")
···
// Expected: 401 Unauthorized (because we didn't add auth context)
if resp.StatusCode != http.StatusUnauthorized {
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
t.Logf("Response status: %d, body: %+v", resp.StatusCode, errorResp)
}
})
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("@-prefixed handle resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("Scoped format resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("DID resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Should return 400 Bad Request for validation errors
if resp.StatusCode != http.StatusBadRequest {
···
}
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
if errorCode, ok := errorResp["error"].(string); !ok || errorCode != "InvalidRequest" {
t.Errorf("Expected error code 'InvalidRequest', got %v", errorResp["error"])
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Expected: 401 (auth check happens before resolution)
// In a real scenario with auth, invalid handle would return 404
···
blockHandler.HandleUnblock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Should NOT be 404 (handle resolution should work)
if resp.StatusCode == http.StatusNotFound {
···
// Expected: 401 (no auth context)
if resp.StatusCode != http.StatusUnauthorized {
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
t.Logf("Response: status=%d, body=%+v", resp.StatusCode, errorResp)
}
})
···
blockHandler.HandleUnblock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Expected: 401 (auth check happens before resolution)
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound {
+3 -4
tests/integration/comment_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
)
func TestCommentConsumer_CreateComment(t *testing.T) {
+3 -4
tests/integration/comment_query_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"encoding/json"
···
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+4 -5
tests/integration/comment_vote_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"testing"
-
"time"
-
"Coves/internal/atproto/jetstream"
"Coves/internal/core/comments"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"testing"
+
"time"
)
// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates
+2 -3
tests/integration/community_blocking_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
postgresRepo "Coves/internal/db/postgres"
)
+4 -5
tests/integration/community_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"errors"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityConsumer_HandleCommunityProfile(t *testing.T) {
+2 -3
tests/integration/community_credentials_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityRepository_CredentialPersistence tests that PDS credentials are properly persisted
+13 -14
tests/integration/community_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/middleware"
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
+4 -5
tests/integration/community_hostedby_security_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"net/http"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/db/postgres"
)
// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain
···
// Return a DID document with matching alsoKnownAs
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
-
fmt.Fprintf(w, `{
+
_, _ = fmt.Fprintf(w, `{
"id": "did:web:example.com",
"alsoKnownAs": ["at://example.com"],
"verificationMethod": [],
···
// Return a DID document WITHOUT alsoKnownAs field
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
-
fmt.Fprintf(w, `{
+
_, _ = fmt.Fprintf(w, `{
"id": "did:web:example.com",
"verificationMethod": [],
"service": []
+2 -3
tests/integration/community_identifier_resolution_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"os"
···
"testing"
"time"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+2 -3
tests/integration/community_provisioning_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityRepository_PasswordEncryption verifies P0 fix:
+2 -3
tests/integration/community_repo_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityRepository_Create(t *testing.T) {
+2 -3
tests/integration/community_service_integration_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityService_CreateWithRealPDS tests the complete service layer flow
+3 -4
tests/integration/community_v2_validation_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityConsumer_V2RKeyValidation tests that only V2 communities (rkey="self") are accepted
+6 -7
tests/integration/concurrent_scenarios_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"sync"
-
"testing"
-
"time"
-
"Coves/internal/atproto/jetstream"
"Coves/internal/core/comments"
"Coves/internal/core/communities"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"sync"
+
"testing"
+
"time"
)
// TestConcurrentVoting_MultipleUsersOnSamePost tests race conditions when multiple users
···
wg.Add(numAttempts)
type result struct {
-
success bool
err error
+
success bool
}
results := make(chan result, numAttempts)
+2 -3
tests/integration/discover_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/discover"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"testing"
"time"
-
"Coves/internal/api/handlers/discover"
-
"Coves/internal/db/postgres"
-
discoverCore "Coves/internal/core/discover"
"github.com/stretchr/testify/assert"
+4 -5
tests/integration/feed_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/communityFeed"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/communityFeeds"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"testing"
"time"
-
"Coves/internal/api/handlers/communityFeed"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/communityFeeds"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+2 -3
tests/integration/helpers.go
···
package integration
import (
+
"Coves/internal/atproto/auth"
+
"Coves/internal/core/users"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/atproto/auth"
-
"Coves/internal/core/users"
-
"github.com/golang-jwt/jwt/v5"
)
+1 -2
tests/integration/identity_resolution_test.go
···
package integration
import (
+
"Coves/internal/atproto/identity"
"context"
"fmt"
"os"
"testing"
"time"
-
-
"Coves/internal/atproto/identity"
)
// uniqueID generates a unique identifier for test isolation
+3 -4
tests/integration/jetstream_consumer_test.go
···
package integration
import (
-
"context"
-
"testing"
-
"time"
-
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"testing"
+
"time"
)
func TestUserIndexingFromJetstream(t *testing.T) {
+3 -4
tests/integration/jwt_verification_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/auth"
"fmt"
"net/http"
"net/http/httptest"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/auth"
)
// TestJWTSignatureVerification tests end-to-end JWT signature verification
···
// Check if JWKS is available (production PDS) or symmetric secret (dev PDS)
jwksResp, _ := http.Get(pdsURL + "/oauth/jwks")
if jwksResp != nil {
-
defer jwksResp.Body.Close()
+
defer func() { _ = jwksResp.Body.Close() }()
}
t.Run("JWT parsing and middleware integration", func(t *testing.T) {
+3 -4
tests/integration/post_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
)
// TestPostConsumer_CommentCountReconciliation tests that post comment_count
+4 -5
tests/integration/post_creation_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"strings"
-
"testing"
-
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/core/communities"
"Coves/internal/core/posts"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"strings"
+
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+8 -9
tests/integration/post_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
+5 -6
tests/integration/post_handler_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/db/postgres"
"bytes"
"encoding/json"
"net/http"
···
"strings"
"testing"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+5 -6
tests/integration/post_thumb_validation_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"net/http/httptest"
"testing"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+5 -6
tests/integration/post_unfurl_test.go
···
package integration
import (
-
"context"
-
"encoding/json"
-
"fmt"
-
"testing"
-
"time"
-
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
···
"Coves/internal/core/unfurl"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"encoding/json"
+
"fmt"
+
"testing"
+
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+2 -3
tests/integration/subscription_indexing_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
postgresRepo "Coves/internal/db/postgres"
)
+3 -4
tests/integration/timeline_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/timeline"
+
"Coves/internal/api/middleware"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"testing"
"time"
-
"Coves/internal/api/handlers/timeline"
-
"Coves/internal/api/middleware"
-
"Coves/internal/db/postgres"
-
timelineCore "Coves/internal/core/timeline"
"github.com/stretchr/testify/assert"
+2 -3
tests/integration/token_refresh_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestTokenRefresh_ExpirationDetection tests the NeedsRefresh function with various token states
+11 -11
tests/integration/user_journey_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/middleware"
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
timelineCore "Coves/internal/core/timeline"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Community creation should succeed")
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Post creation should succeed")
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Subscription should succeed")
+1 -2
tests/unit/community_service_test.go
···
package unit
import (
+
"Coves/internal/core/communities"
"context"
"fmt"
"net/http"
···
"sync/atomic"
"testing"
"time"
-
-
"Coves/internal/core/communities"
)
// mockCommunityRepo is a minimal mock for testing service layer
+5 -3
Caddyfile
···
# Domain architecture:
# - coves.social: AppView (API, web app)
# - *.coves.social: Community handles (route atproto-did to PDS)
-
# - coves.me: PDS (data storage)
+
# - pds.coves.me: PDS canonical hostname (for relay registration)
+
# - coves.me: PDS legacy hostname (kept for compatibility)
# Community handle subdomains (e.g., gaming.coves.social)
# These need to route /.well-known/atproto-did to PDS for handle resolution
···
encode gzip zstd
}
-
# PDS Domain
-
coves.me {
+
# PDS Domain (both hostnames point to same PDS)
+
# pds.coves.me is the canonical hostname for relay registration
+
pds.coves.me, coves.me {
reverse_proxy pds:3000 {
# Health check
health_uri /xrpc/_health
+23
static/.well-known/did.json
···
+
{
+
"@context": [
+
"https://www.w3.org/ns/did/v1",
+
"https://w3id.org/security/multikey/v1"
+
],
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "zQ3shu1T3Y3MYoC1n7fCqkZqyrk8FiY3PV3BYM2JwyqcXFY6s"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://pds.coves.me"
+
}
+
]
+
}
+1 -1
docs/E2E_TESTING.md
···
Query via API:
```bash
-
curl "http://localhost:8081/xrpc/social.coves.actor.getProfile?actor=alice.local.coves.dev"
+
curl "http://localhost:8081/xrpc/social.coves.actor.getprofile?actor=alice.local.coves.dev"
```
Expected response:
+1 -1
internal/atproto/lexicon/social/coves/actor/getProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.getProfile",
+
"id": "social.coves.actor.getprofile",
"defs": {
"main": {
"type": "query",
+1 -1
internal/atproto/lexicon/social/coves/actor/updateProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.updateProfile",
+
"id": "social.coves.actor.updateprofile",
"defs": {
"main": {
"type": "procedure",
+44 -5
internal/atproto/lexicon/social/coves/embed/external.json
···
"defs": {
"main": {
"type": "object",
-
"description": "External link embed with preview metadata and provider support",
+
"description": "External link embed with optional aggregated sources for megathreads",
"required": ["external"],
"properties": {
"external": {
···
},
"external": {
"type": "object",
-
"description": "External link metadata",
+
"description": "Primary external link metadata",
"required": ["uri"],
"properties": {
"uri": {
"type": "string",
"format": "uri",
-
"description": "URI of the external content"
+
"description": "URI of the primary external content"
},
"title": {
"type": "string",
···
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 1000000,
-
"description": "Thumbnail image for the link"
+
"description": "Thumbnail image for the post (applies to primary link)"
},
"domain": {
"type": "string",
-
"description": "Domain of the linked content"
+
"maxLength": 253,
+
"description": "Domain of the linked content (e.g., nytimes.com)"
},
"embedType": {
"type": "string",
···
},
"provider": {
"type": "string",
+
"maxLength": 100,
"description": "Service provider name (e.g., imgur, streamable)"
},
"images": {
···
"type": "integer",
"minimum": 0,
"description": "Total number of items if more than displayed (for galleries)"
+
},
+
"sources": {
+
"type": "array",
+
"description": "Aggregated source links for megathreads. Each source references an original article and optionally the Coves post that shared it",
+
"maxLength": 50,
+
"items": {
+
"type": "ref",
+
"ref": "#source"
+
}
+
}
+
}
+
},
+
"source": {
+
"type": "object",
+
"description": "A source link aggregated into a megathread",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "uri",
+
"description": "URI of the source article"
+
},
+
"title": {
+
"type": "string",
+
"maxLength": 500,
+
"maxGraphemes": 500,
+
"description": "Title of the source article"
+
},
+
"domain": {
+
"type": "string",
+
"maxLength": 253,
+
"description": "Domain of the source (e.g., nytimes.com)"
+
},
+
"sourcePost": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Reference to the Coves post that originally shared this link. Used for feed deprioritization of rolled-up posts"
}
}
}
+52
internal/atproto/auth/combined_key_fetcher.go
···
+
package auth
+
+
import (
+
"context"
+
"fmt"
+
"strings"
+
+
indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
+
)
+
+
// CombinedKeyFetcher handles JWT public key fetching for both:
+
// - DID issuers (did:plc:, did:web:) โ†’ resolves via DID document
+
// - URL issuers (https://) โ†’ fetches via JWKS endpoint (legacy/fallback)
+
//
+
// For atproto service authentication, the issuer is typically the user's DID,
+
// and the signing key is published in their DID document.
+
type CombinedKeyFetcher struct {
+
didFetcher *DIDKeyFetcher
+
jwksFetcher JWKSFetcher
+
}
+
+
// NewCombinedKeyFetcher creates a key fetcher that supports both DID and URL issuers.
+
// Parameters:
+
// - directory: Indigo's identity directory for DID resolution
+
// - jwksFetcher: fallback JWKS fetcher for URL issuers (can be nil if not needed)
+
func NewCombinedKeyFetcher(directory indigoIdentity.Directory, jwksFetcher JWKSFetcher) *CombinedKeyFetcher {
+
return &CombinedKeyFetcher{
+
didFetcher: NewDIDKeyFetcher(directory),
+
jwksFetcher: jwksFetcher,
+
}
+
}
+
+
// FetchPublicKey fetches the public key for verifying a JWT.
+
// Routes to the appropriate fetcher based on issuer format:
+
// - DID (did:plc:, did:web:) โ†’ DIDKeyFetcher
+
// - URL (https://) โ†’ JWKSFetcher
+
func (f *CombinedKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+
// Check if issuer is a DID
+
if strings.HasPrefix(issuer, "did:") {
+
return f.didFetcher.FetchPublicKey(ctx, issuer, token)
+
}
+
+
// Check if issuer is a URL (https:// or http:// in dev)
+
if strings.HasPrefix(issuer, "https://") || strings.HasPrefix(issuer, "http://") {
+
if f.jwksFetcher == nil {
+
return nil, fmt.Errorf("URL issuer %s requires JWKS fetcher, but none configured", issuer)
+
}
+
return f.jwksFetcher.FetchPublicKey(ctx, issuer, token)
+
}
+
+
return nil, fmt.Errorf("unsupported issuer format: %s (expected DID or URL)", issuer)
+
}
+116
internal/atproto/auth/did_key_fetcher.go
···
+
package auth
+
+
import (
+
"context"
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"encoding/base64"
+
"fmt"
+
"math/big"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// DIDKeyFetcher fetches public keys from DID documents for JWT verification.
+
// This is the primary method for atproto service authentication, where:
+
// - The JWT issuer is the user's DID (e.g., did:plc:abc123)
+
// - The signing key is published in the user's DID document
+
// - Verification happens by resolving the DID and checking the signature
+
type DIDKeyFetcher struct {
+
directory indigoIdentity.Directory
+
}
+
+
// NewDIDKeyFetcher creates a new DID-based key fetcher.
+
func NewDIDKeyFetcher(directory indigoIdentity.Directory) *DIDKeyFetcher {
+
return &DIDKeyFetcher{
+
directory: directory,
+
}
+
}
+
+
// FetchPublicKey fetches the public key for verifying a JWT from the issuer's DID document.
+
// For DID issuers (did:plc: or did:web:), resolves the DID and extracts the signing key.
+
// Returns an *ecdsa.PublicKey suitable for use with jwt-go.
+
func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+
// Only handle DID issuers
+
if !strings.HasPrefix(issuer, "did:") {
+
return nil, fmt.Errorf("DIDKeyFetcher only handles DID issuers, got: %s", issuer)
+
}
+
+
// Parse the DID
+
did, err := syntax.ParseDID(issuer)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DID format: %w", err)
+
}
+
+
// Resolve the DID to get the identity (includes public keys)
+
ident, err := f.directory.LookupDID(ctx, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve DID %s: %w", issuer, err)
+
}
+
+
// Get the atproto signing key from the DID document
+
pubKey, err := ident.PublicKey()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get public key from DID document: %w", err)
+
}
+
+
// Convert to JWK format to extract coordinates
+
jwk, err := pubKey.JWK()
+
if err != nil {
+
return nil, fmt.Errorf("failed to convert public key to JWK: %w", err)
+
}
+
+
// Convert atcrypto JWK to Go ecdsa.PublicKey
+
return atcryptoJWKToECDSA(jwk)
+
}
+
+
// atcryptoJWKToECDSA converts an atcrypto.JWK to a Go ecdsa.PublicKey
+
func atcryptoJWKToECDSA(jwk *atcrypto.JWK) (*ecdsa.PublicKey, error) {
+
if jwk.KeyType != "EC" {
+
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
}
+
+
// Decode X and Y coordinates (base64url, no padding)
+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK X coordinate encoding: %w", err)
+
}
+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK Y coordinate encoding: %w", err)
+
}
+
+
var ecCurve elliptic.Curve
+
switch jwk.Curve {
+
case "P-256":
+
ecCurve = elliptic.P256()
+
case "P-384":
+
ecCurve = elliptic.P384()
+
case "P-521":
+
ecCurve = elliptic.P521()
+
case "secp256k1":
+
// secp256k1 (K-256) is used by some atproto implementations
+
// Go's standard library doesn't include secp256k1, but we can still
+
// construct the key - jwt-go may not support it directly
+
return nil, fmt.Errorf("secp256k1 curve requires special handling for JWT verification")
+
default:
+
return nil, fmt.Errorf("unsupported JWK curve: %s", jwk.Curve)
+
}
+
+
// Create the public key
+
pubKey := &ecdsa.PublicKey{
+
Curve: ecCurve,
+
X: new(big.Int).SetBytes(xBytes),
+
Y: new(big.Int).SetBytes(yBytes),
+
}
+
+
// Validate point is on curve
+
if !ecCurve.IsOnCurve(pubKey.X, pubKey.Y) {
+
return nil, fmt.Errorf("invalid public key: point not on curve")
+
}
+
+
return pubKey, nil
+
}
+5
.env.dev
···
# When false, verifies JWT signature against issuer's JWKS
AUTH_SKIP_VERIFY=true
+
# HS256 Issuers: PDSes allowed to use HS256 (shared secret) authentication
+
# Must share PDS_JWT_SECRET with Coves instance. External PDSes use ES256 via DID resolution.
+
# For local dev, allow the local PDS or turn AUTH_SKIP_VERIFY = true
+
HS256_ISSUERS=http://localhost:3001
+
# Logging
LOG_LEVEL=debug
LOG_ENABLED=true
+28
.env.prod.example
···
# PDS_EMAIL_SMTP_URL=smtp://user:pass@smtp.example.com:587
# PDS_EMAIL_FROM_ADDRESS=noreply@coves.me
+
# =============================================================================
+
# JWT Authentication
+
# =============================================================================
+
# Coves supports two JWT verification methods:
+
#
+
# 1. HS256 (shared secret) - For your own PDS
+
# - Fast, no network calls needed
+
# - Requires shared PDS_JWT_SECRET
+
# - Only for PDSes you control
+
#
+
# 2. ES256 (DID resolution) - For federated users
+
# - Works with any PDS (bsky.social, etc.)
+
# - Resolves user's DID document to get public key
+
# - No shared secret needed
+
#
+
# HS256_ISSUERS: Comma-separated list of PDS URLs allowed to use HS256
+
# These PDSes MUST share the same PDS_JWT_SECRET with Coves
+
# Example: HS256_ISSUERS=https://pds.coves.social,https://pds.example.com
+
HS256_ISSUERS=https://pds.coves.me
+
+
# PLC Directory URL for DID resolution (optional)
+
# Defaults to https://plc.directory if not set
+
# PLC_DIRECTORY_URL=https://plc.directory
+
+
# Skip JWT signature verification (DEVELOPMENT ONLY!)
+
# Set to false in production for proper security
+
AUTH_SKIP_VERIFY=false
+
# =============================================================================
# AppView OAuth (for mobile app authentication)
# =============================================================================
+484
internal/atproto/auth/dpop.go
···
+
package auth
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"math/big"
+
"strings"
+
"sync"
+
"time"
+
+
indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/golang-jwt/jwt/v5"
+
)
+
+
// NonceCache provides replay protection for DPoP proofs by tracking seen jti values.
+
// This prevents an attacker from reusing a captured DPoP proof within the validity window.
+
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks.
+
type NonceCache struct {
+
seen map[string]time.Time // jti -> expiration time
+
stopCh chan struct{}
+
maxAge time.Duration // How long to keep entries
+
cleanup time.Duration // How often to clean up expired entries
+
mu sync.RWMutex
+
}
+
+
// NewNonceCache creates a new nonce cache for DPoP replay protection.
+
// maxAge should match or exceed DPoPVerifier.MaxProofAge.
+
func NewNonceCache(maxAge time.Duration) *NonceCache {
+
nc := &NonceCache{
+
seen: make(map[string]time.Time),
+
maxAge: maxAge,
+
cleanup: maxAge / 2, // Clean up at half the max age
+
stopCh: make(chan struct{}),
+
}
+
+
// Start background cleanup goroutine
+
go nc.cleanupLoop()
+
+
return nc
+
}
+
+
// CheckAndStore checks if a jti has been seen before and stores it if not.
+
// Returns true if the jti is fresh (not a replay), false if it's a replay.
+
func (nc *NonceCache) CheckAndStore(jti string) bool {
+
nc.mu.Lock()
+
defer nc.mu.Unlock()
+
+
now := time.Now()
+
expiry := now.Add(nc.maxAge)
+
+
// Check if already seen
+
if existingExpiry, seen := nc.seen[jti]; seen {
+
// Still valid (not expired) - this is a replay
+
if existingExpiry.After(now) {
+
return false
+
}
+
// Expired entry - allow reuse and update expiry
+
}
+
+
// Store the new jti
+
nc.seen[jti] = expiry
+
return true
+
}
+
+
// cleanupLoop periodically removes expired entries from the cache
+
func (nc *NonceCache) cleanupLoop() {
+
ticker := time.NewTicker(nc.cleanup)
+
defer ticker.Stop()
+
+
for {
+
select {
+
case <-ticker.C:
+
nc.cleanupExpired()
+
case <-nc.stopCh:
+
return
+
}
+
}
+
}
+
+
// cleanupExpired removes expired entries from the cache
+
func (nc *NonceCache) cleanupExpired() {
+
nc.mu.Lock()
+
defer nc.mu.Unlock()
+
+
now := time.Now()
+
for jti, expiry := range nc.seen {
+
if expiry.Before(now) {
+
delete(nc.seen, jti)
+
}
+
}
+
}
+
+
// Stop stops the cleanup goroutine. Call this when done with the cache.
+
func (nc *NonceCache) Stop() {
+
close(nc.stopCh)
+
}
+
+
// Size returns the number of entries in the cache (for testing/monitoring)
+
func (nc *NonceCache) Size() int {
+
nc.mu.RLock()
+
defer nc.mu.RUnlock()
+
return len(nc.seen)
+
}
+
+
// DPoPClaims represents the claims in a DPoP proof JWT (RFC 9449)
+
type DPoPClaims struct {
+
jwt.RegisteredClaims
+
+
// HTTP method of the request (e.g., "GET", "POST")
+
HTTPMethod string `json:"htm"`
+
+
// HTTP URI of the request (without query and fragment parts)
+
HTTPURI string `json:"htu"`
+
+
// Access token hash (optional, for token binding)
+
AccessTokenHash string `json:"ath,omitempty"`
+
}
+
+
// DPoPProof represents a parsed and verified DPoP proof
+
type DPoPProof struct {
+
RawPublicJWK map[string]interface{}
+
Claims *DPoPClaims
+
PublicKey interface{} // *ecdsa.PublicKey or similar
+
Thumbprint string // JWK thumbprint (base64url)
+
}
+
+
// DPoPVerifier verifies DPoP proofs for OAuth token binding
+
type DPoPVerifier struct {
+
// Optional: custom nonce validation function (for server-issued nonces)
+
ValidateNonce func(nonce string) bool
+
+
// NonceCache for replay protection (optional but recommended)
+
// If nil, jti replay protection is disabled
+
NonceCache *NonceCache
+
+
// Maximum allowed clock skew for timestamp validation
+
MaxClockSkew time.Duration
+
+
// Maximum age of DPoP proof (prevents replay with old proofs)
+
MaxProofAge time.Duration
+
}
+
+
// NewDPoPVerifier creates a DPoP verifier with sensible defaults including replay protection
+
func NewDPoPVerifier() *DPoPVerifier {
+
maxProofAge := 5 * time.Minute
+
return &DPoPVerifier{
+
MaxClockSkew: 30 * time.Second,
+
MaxProofAge: maxProofAge,
+
NonceCache: NewNonceCache(maxProofAge),
+
}
+
}
+
+
// NewDPoPVerifierWithoutReplayProtection creates a DPoP verifier without replay protection.
+
// This should only be used in testing or when replay protection is handled externally.
+
func NewDPoPVerifierWithoutReplayProtection() *DPoPVerifier {
+
return &DPoPVerifier{
+
MaxClockSkew: 30 * time.Second,
+
MaxProofAge: 5 * time.Minute,
+
NonceCache: nil, // No replay protection
+
}
+
}
+
+
// Stop stops background goroutines. Call this when shutting down.
+
func (v *DPoPVerifier) Stop() {
+
if v.NonceCache != nil {
+
v.NonceCache.Stop()
+
}
+
}
+
+
// VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof
+
func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) {
+
// Parse the DPoP JWT without verification first to extract the header
+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+
token, _, err := parser.ParseUnverified(dpopProof, &DPoPClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse DPoP proof: %w", err)
+
}
+
+
// Extract and validate the header
+
header, ok := token.Header["typ"].(string)
+
if !ok || header != "dpop+jwt" {
+
return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", header)
+
}
+
+
alg, ok := token.Header["alg"].(string)
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: missing alg header")
+
}
+
+
// Extract the JWK from the header
+
jwkRaw, ok := token.Header["jwk"]
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: missing jwk header")
+
}
+
+
jwkMap, ok := jwkRaw.(map[string]interface{})
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object")
+
}
+
+
// Parse the public key from JWK
+
publicKey, err := parseJWKToPublicKey(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err)
+
}
+
+
// Calculate the JWK thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
+
}
+
+
// Now verify the signature
+
verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) {
+
// Verify the signing method matches what we expect
+
switch alg {
+
case "ES256":
+
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
case "ES384", "ES512":
+
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
+
// RSA methods - we primarily support ES256 for atproto
+
return nil, fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg)
+
default:
+
return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg)
+
}
+
return publicKey, nil
+
})
+
if err != nil {
+
return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err)
+
}
+
+
claims, ok := verifiedToken.Claims.(*DPoPClaims)
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP claims type")
+
}
+
+
// Validate the claims
+
if err := v.validateDPoPClaims(claims, httpMethod, httpURI); err != nil {
+
return nil, err
+
}
+
+
return &DPoPProof{
+
Claims: claims,
+
PublicKey: publicKey,
+
Thumbprint: thumbprint,
+
RawPublicJWK: jwkMap,
+
}, nil
+
}
+
+
// validateDPoPClaims validates the DPoP proof claims
+
func (v *DPoPVerifier) validateDPoPClaims(claims *DPoPClaims, expectedMethod, expectedURI string) error {
+
// Validate jti (unique identifier) is present
+
if claims.ID == "" {
+
return fmt.Errorf("DPoP proof missing jti claim")
+
}
+
+
// Validate htm (HTTP method)
+
if !strings.EqualFold(claims.HTTPMethod, expectedMethod) {
+
return fmt.Errorf("DPoP proof htm mismatch: expected %s, got %s", expectedMethod, claims.HTTPMethod)
+
}
+
+
// Validate htu (HTTP URI) - compare without query/fragment
+
expectedURIBase := stripQueryFragment(expectedURI)
+
claimURIBase := stripQueryFragment(claims.HTTPURI)
+
if expectedURIBase != claimURIBase {
+
return fmt.Errorf("DPoP proof htu mismatch: expected %s, got %s", expectedURIBase, claimURIBase)
+
}
+
+
// Validate iat (issued at) is present and recent
+
if claims.IssuedAt == nil {
+
return fmt.Errorf("DPoP proof missing iat claim")
+
}
+
+
now := time.Now()
+
iat := claims.IssuedAt.Time
+
+
// Check clock skew (not too far in the future)
+
if iat.After(now.Add(v.MaxClockSkew)) {
+
return fmt.Errorf("DPoP proof iat is in the future")
+
}
+
+
// Check proof age (not too old)
+
if now.Sub(iat) > v.MaxProofAge {
+
return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)
+
}
+
+
// SECURITY: Check for replay attack using jti
+
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks
+
if v.NonceCache != nil {
+
if !v.NonceCache.CheckAndStore(claims.ID) {
+
return fmt.Errorf("DPoP proof replay detected: jti %s already used", claims.ID)
+
}
+
}
+
+
return nil
+
}
+
+
// VerifyTokenBinding verifies that the DPoP proof binds to the access token
+
// by comparing the proof's thumbprint to the token's cnf.jkt claim
+
func (v *DPoPVerifier) VerifyTokenBinding(proof *DPoPProof, expectedThumbprint string) error {
+
if proof.Thumbprint != expectedThumbprint {
+
return fmt.Errorf("DPoP proof thumbprint mismatch: token expects %s, proof has %s",
+
expectedThumbprint, proof.Thumbprint)
+
}
+
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) {
+
kty, ok := jwk["kty"].(string)
+
if !ok {
+
return "", fmt.Errorf("JWK missing kty")
+
}
+
+
// Build the canonical JWK representation based on key type
+
// Per RFC 7638, only specific members are included, in lexicographic order
+
var canonical map[string]string
+
+
switch kty {
+
case "EC":
+
crv, ok := jwk["crv"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing crv")
+
}
+
x, ok := jwk["x"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing x")
+
}
+
y, ok := jwk["y"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing y")
+
}
+
// Lexicographic order: crv, kty, x, y
+
canonical = map[string]string{
+
"crv": crv,
+
"kty": kty,
+
"x": x,
+
"y": y,
+
}
+
case "RSA":
+
e, ok := jwk["e"].(string)
+
if !ok {
+
return "", fmt.Errorf("RSA JWK missing e")
+
}
+
n, ok := jwk["n"].(string)
+
if !ok {
+
return "", fmt.Errorf("RSA JWK missing n")
+
}
+
// Lexicographic order: e, kty, n
+
canonical = map[string]string{
+
"e": e,
+
"kty": kty,
+
"n": n,
+
}
+
case "OKP":
+
crv, ok := jwk["crv"].(string)
+
if !ok {
+
return "", fmt.Errorf("OKP JWK missing crv")
+
}
+
x, ok := jwk["x"].(string)
+
if !ok {
+
return "", fmt.Errorf("OKP JWK missing x")
+
}
+
// Lexicographic order: crv, kty, x
+
canonical = map[string]string{
+
"crv": crv,
+
"kty": kty,
+
"x": x,
+
}
+
default:
+
return "", fmt.Errorf("unsupported JWK key type: %s", kty)
+
}
+
+
// Serialize to JSON (Go's json.Marshal produces lexicographically ordered keys for map[string]string)
+
canonicalJSON, err := json.Marshal(canonical)
+
if err != nil {
+
return "", fmt.Errorf("failed to serialize canonical JWK: %w", err)
+
}
+
+
// SHA-256 hash
+
hash := sha256.Sum256(canonicalJSON)
+
+
// Base64url encode (no padding)
+
thumbprint := base64.RawURLEncoding.EncodeToString(hash[:])
+
+
return thumbprint, nil
+
}
+
+
// parseJWKToPublicKey parses a JWK map to a Go public key
+
func parseJWKToPublicKey(jwkMap map[string]interface{}) (interface{}, error) {
+
// Convert map to JSON bytes for indigo's parser
+
jwkBytes, err := json.Marshal(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("failed to serialize JWK: %w", err)
+
}
+
+
// Try to parse with indigo's crypto package
+
pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse JWK: %w", err)
+
}
+
+
// Convert indigo's PublicKey to Go's ecdsa.PublicKey
+
jwk, err := pubKey.JWK()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get JWK from public key: %w", err)
+
}
+
+
// Use our existing conversion function
+
return atcryptoJWKToECDSAFromIndigoJWK(jwk)
+
}
+
+
// atcryptoJWKToECDSAFromIndigoJWK converts an indigo JWK to Go ecdsa.PublicKey
+
func atcryptoJWKToECDSAFromIndigoJWK(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) {
+
if jwk.KeyType != "EC" {
+
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
}
+
+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK X coordinate: %w", err)
+
}
+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err)
+
}
+
+
var curve ecdsa.PublicKey
+
switch jwk.Curve {
+
case "P-256":
+
curve.Curve = ecdsaP256Curve()
+
case "P-384":
+
curve.Curve = ecdsaP384Curve()
+
case "P-521":
+
curve.Curve = ecdsaP521Curve()
+
default:
+
return nil, fmt.Errorf("unsupported curve: %s", jwk.Curve)
+
}
+
+
curve.X = new(big.Int).SetBytes(xBytes)
+
curve.Y = new(big.Int).SetBytes(yBytes)
+
+
return &curve, nil
+
}
+
+
// Helper functions for elliptic curves
+
func ecdsaP256Curve() elliptic.Curve { return elliptic.P256() }
+
func ecdsaP384Curve() elliptic.Curve { return elliptic.P384() }
+
func ecdsaP521Curve() elliptic.Curve { return elliptic.P521() }
+
+
// stripQueryFragment removes query and fragment from a URI
+
func stripQueryFragment(uri string) string {
+
if idx := strings.Index(uri, "?"); idx != -1 {
+
uri = uri[:idx]
+
}
+
if idx := strings.Index(uri, "#"); idx != -1 {
+
uri = uri[:idx]
+
}
+
return uri
+
}
+
+
// ExtractCnfJkt extracts the cnf.jkt (confirmation key thumbprint) from JWT claims
+
func ExtractCnfJkt(claims *Claims) (string, error) {
+
if claims.Confirmation == nil {
+
return "", fmt.Errorf("token missing cnf claim (no DPoP binding)")
+
}
+
+
jkt, ok := claims.Confirmation["jkt"].(string)
+
if !ok || jkt == "" {
+
return "", fmt.Errorf("token cnf claim missing jkt (DPoP key thumbprint)")
+
}
+
+
return jkt, nil
+
}
+921
internal/atproto/auth/dpop_test.go
···
+
package auth
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/golang-jwt/jwt/v5"
+
"github.com/google/uuid"
+
)
+
+
// === Test Helpers ===
+
+
// testECKey holds a test ES256 key pair
+
type testECKey struct {
+
privateKey *ecdsa.PrivateKey
+
publicKey *ecdsa.PublicKey
+
jwk map[string]interface{}
+
thumbprint string
+
}
+
+
// generateTestES256Key generates a test ES256 key pair and JWK
+
func generateTestES256Key(t *testing.T) *testECKey {
+
t.Helper()
+
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("Failed to generate test key: %v", err)
+
}
+
+
// Encode public key coordinates as base64url
+
xBytes := privateKey.PublicKey.X.Bytes()
+
yBytes := privateKey.PublicKey.Y.Bytes()
+
+
// P-256 coordinates must be 32 bytes (pad if needed)
+
xBytes = padTo32Bytes(xBytes)
+
yBytes = padTo32Bytes(yBytes)
+
+
x := base64.RawURLEncoding.EncodeToString(xBytes)
+
y := base64.RawURLEncoding.EncodeToString(yBytes)
+
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": x,
+
"y": y,
+
}
+
+
// Calculate thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Failed to calculate thumbprint: %v", err)
+
}
+
+
return &testECKey{
+
privateKey: privateKey,
+
publicKey: &privateKey.PublicKey,
+
jwk: jwk,
+
thumbprint: thumbprint,
+
}
+
}
+
+
// padTo32Bytes pads a byte slice to 32 bytes (required for P-256 coordinates)
+
func padTo32Bytes(b []byte) []byte {
+
if len(b) >= 32 {
+
return b
+
}
+
padded := make([]byte, 32)
+
copy(padded[32-len(b):], b)
+
return padded
+
}
+
+
// createDPoPProof creates a DPoP proof JWT for testing
+
func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {
+
t.Helper()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
tokenString, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create DPoP proof: %v", err)
+
}
+
+
return tokenString
+
}
+
+
// === JWK Thumbprint Tests (RFC 7638) ===
+
+
func TestCalculateJWKThumbprint_EC_P256(t *testing.T) {
+
// Test with known values from RFC 7638 Appendix A (adapted for P-256)
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis",
+
"y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint")
+
}
+
+
// Verify it's valid base64url
+
_, err = base64.RawURLEncoding.DecodeString(thumbprint)
+
if err != nil {
+
t.Errorf("Thumbprint is not valid base64url: %v", err)
+
}
+
+
// Verify length (SHA-256 produces 32 bytes = 43 base64url chars)
+
if len(thumbprint) != 43 {
+
t.Errorf("Expected thumbprint length 43, got %d", len(thumbprint))
+
}
+
}
+
+
func TestCalculateJWKThumbprint_Deterministic(t *testing.T) {
+
// Same key should produce same thumbprint
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "test-x-coordinate",
+
"y": "test-y-coordinate",
+
}
+
+
thumbprint1, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("First CalculateJWKThumbprint failed: %v", err)
+
}
+
+
thumbprint2, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Second CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint1 != thumbprint2 {
+
t.Errorf("Thumbprints are not deterministic: %s != %s", thumbprint1, thumbprint2)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_DifferentKeys(t *testing.T) {
+
// Different keys should produce different thumbprints
+
jwk1 := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "coordinate-x-1",
+
"y": "coordinate-y-1",
+
}
+
+
jwk2 := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "coordinate-x-2",
+
"y": "coordinate-y-2",
+
}
+
+
thumbprint1, err := CalculateJWKThumbprint(jwk1)
+
if err != nil {
+
t.Fatalf("First CalculateJWKThumbprint failed: %v", err)
+
}
+
+
thumbprint2, err := CalculateJWKThumbprint(jwk2)
+
if err != nil {
+
t.Fatalf("Second CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint1 == thumbprint2 {
+
t.Error("Different keys produced same thumbprint (collision)")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_MissingKty(t *testing.T) {
+
jwk := map[string]interface{}{
+
"crv": "P-256",
+
"x": "test-x",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing kty, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing kty") {
+
t.Errorf("Expected error about missing kty, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingCrv(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"x": "test-x",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing crv, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing crv") {
+
t.Errorf("Expected error about missing crv, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingX(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing x, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing x") {
+
t.Errorf("Expected error about missing x, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingY(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "test-x",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing y, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing y") {
+
t.Errorf("Expected error about missing y, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_RSA(t *testing.T) {
+
// Test RSA key thumbprint calculation
+
jwk := map[string]interface{}{
+
"kty": "RSA",
+
"e": "AQAB",
+
"n": "test-modulus",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for RSA: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for RSA key")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_OKP(t *testing.T) {
+
// Test OKP (Octet Key Pair) thumbprint calculation
+
jwk := map[string]interface{}{
+
"kty": "OKP",
+
"crv": "Ed25519",
+
"x": "test-x-coordinate",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for OKP: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for OKP key")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_UnsupportedKeyType(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "UNKNOWN",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for unsupported key type, got nil")
+
}
+
if err != nil && !contains(err.Error(), "unsupported JWK key type") {
+
t.Errorf("Expected error about unsupported key type, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_CanonicalJSON(t *testing.T) {
+
// RFC 7638 requires lexicographic ordering of keys in canonical JSON
+
// This test verifies that the canonical JSON is correctly ordered
+
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "x-coord",
+
"y": "y-coord",
+
}
+
+
// The canonical JSON should be: {"crv":"P-256","kty":"EC","x":"x-coord","y":"y-coord"}
+
// (lexicographically ordered: crv, kty, x, y)
+
+
canonical := map[string]string{
+
"crv": "P-256",
+
"kty": "EC",
+
"x": "x-coord",
+
"y": "y-coord",
+
}
+
+
canonicalJSON, err := json.Marshal(canonical)
+
if err != nil {
+
t.Fatalf("Failed to marshal canonical JSON: %v", err)
+
}
+
+
expectedHash := sha256.Sum256(canonicalJSON)
+
expectedThumbprint := base64.RawURLEncoding.EncodeToString(expectedHash[:])
+
+
actualThumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if actualThumbprint != expectedThumbprint {
+
t.Errorf("Thumbprint doesn't match expected canonical JSON hash\nExpected: %s\nGot: %s",
+
expectedThumbprint, actualThumbprint)
+
}
+
}
+
+
// === DPoP Proof Verification Tests ===
+
+
func TestVerifyDPoPProof_Valid(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for valid proof: %v", err)
+
}
+
+
if result == nil {
+
t.Fatal("Expected non-nil proof result")
+
}
+
+
if result.Claims.HTTPMethod != method {
+
t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod)
+
}
+
+
if result.Claims.HTTPURI != uri {
+
t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI)
+
}
+
+
if result.Claims.ID != jti {
+
t.Errorf("Expected jti %s, got %s", jti, result.Claims.ID)
+
}
+
+
if result.Thumbprint != key.thumbprint {
+
t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint)
+
}
+
}
+
+
func TestVerifyDPoPProof_InvalidSignature(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
wrongKey := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
// Create proof with one key
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
// Parse and modify to use wrong key's JWK in header (signature won't match)
+
parts := splitJWT(proof)
+
header := parseJWTHeader(t, parts[0])
+
header["jwk"] = wrongKey.jwk
+
modifiedHeader := encodeJSON(t, header)
+
tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2]
+
+
_, err := verifier.VerifyDPoPProof(tamperedProof, method, uri)
+
if err == nil {
+
t.Error("Expected error for invalid signature, got nil")
+
}
+
if err != nil && !contains(err.Error(), "signature verification failed") {
+
t.Errorf("Expected signature verification error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongHTTPMethod(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
wrongMethod := "GET"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, wrongMethod, uri)
+
if err == nil {
+
t.Error("Expected error for HTTP method mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "htm mismatch") {
+
t.Errorf("Expected htm mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongURI(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
wrongURI := "https://api.example.com/different"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, wrongURI)
+
if err == nil {
+
t.Error("Expected error for URI mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "htu mismatch") {
+
t.Errorf("Expected htu mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_URIWithQuery(t *testing.T) {
+
// URI comparison should strip query and fragment
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
baseURI := "https://api.example.com/resource"
+
uriWithQuery := baseURI + "?param=value"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, baseURI, iat, jti)
+
+
// Should succeed because query is stripped
+
_, err := verifier.VerifyDPoPProof(proof, method, uriWithQuery)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for URI with query: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_URIWithFragment(t *testing.T) {
+
// URI comparison should strip query and fragment
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
baseURI := "https://api.example.com/resource"
+
uriWithFragment := baseURI + "#section"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, baseURI, iat, jti)
+
+
// Should succeed because fragment is stripped
+
_, err := verifier.VerifyDPoPProof(proof, method, uriWithFragment)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for URI with fragment: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_ExpiredProof(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 10 minutes ago (exceeds default MaxProofAge of 5 minutes)
+
iat := time.Now().Add(-10 * time.Minute)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for expired proof, got nil")
+
}
+
if err != nil && !contains(err.Error(), "too old") {
+
t.Errorf("Expected 'too old' error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_FutureProof(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 1 minute in the future (exceeds MaxClockSkew)
+
iat := time.Now().Add(1 * time.Minute)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for future proof, got nil")
+
}
+
if err != nil && !contains(err.Error(), "in the future") {
+
t.Errorf("Expected 'in the future' error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WithinClockSkew(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 15 seconds in the future (within MaxClockSkew of 30s)
+
iat := time.Now().Add(15 * time.Second)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for proof within clock skew: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingJti(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
// No ID (jti)
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing jti, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jti") {
+
t.Errorf("Expected missing jti error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingTypHeader(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
// Don't set typ header
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing typ header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") {
+
t.Errorf("Expected typ header error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongTypHeader(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "JWT" // Wrong typ
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for wrong typ header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") {
+
t.Errorf("Expected typ header error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingJWK(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
// Don't include JWK
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing jwk header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jwk") {
+
t.Errorf("Expected missing jwk error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_CustomTimeSettings(t *testing.T) {
+
verifier := &DPoPVerifier{
+
MaxClockSkew: 1 * time.Minute,
+
MaxProofAge: 10 * time.Minute,
+
}
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 50 seconds in the future (within custom MaxClockSkew)
+
iat := time.Now().Add(50 * time.Second)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed with custom time settings: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_HTTPMethodCaseInsensitive(t *testing.T) {
+
// HTTP method comparison should be case-insensitive per spec
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "post"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
// Verify with uppercase method
+
_, err := verifier.VerifyDPoPProof(proof, "POST", uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for case-insensitive method: %v", err)
+
}
+
}
+
+
// === Token Binding Verification Tests ===
+
+
func TestVerifyTokenBinding_Matching(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed: %v", err)
+
}
+
+
// Verify token binding with matching thumbprint
+
err = verifier.VerifyTokenBinding(result, key.thumbprint)
+
if err != nil {
+
t.Fatalf("VerifyTokenBinding failed for matching thumbprint: %v", err)
+
}
+
}
+
+
func TestVerifyTokenBinding_Mismatch(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
wrongKey := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed: %v", err)
+
}
+
+
// Verify token binding with wrong thumbprint
+
err = verifier.VerifyTokenBinding(result, wrongKey.thumbprint)
+
if err == nil {
+
t.Error("Expected error for thumbprint mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "thumbprint mismatch") {
+
t.Errorf("Expected thumbprint mismatch error, got: %v", err)
+
}
+
}
+
+
// === ExtractCnfJkt Tests ===
+
+
func TestExtractCnfJkt_Valid(t *testing.T) {
+
expectedJkt := "test-thumbprint-123"
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": expectedJkt,
+
},
+
}
+
+
jkt, err := ExtractCnfJkt(claims)
+
if err != nil {
+
t.Fatalf("ExtractCnfJkt failed for valid claims: %v", err)
+
}
+
+
if jkt != expectedJkt {
+
t.Errorf("Expected jkt %s, got %s", expectedJkt, jkt)
+
}
+
}
+
+
func TestExtractCnfJkt_MissingCnf(t *testing.T) {
+
claims := &Claims{
+
// No Confirmation
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for missing cnf, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing cnf claim") {
+
t.Errorf("Expected missing cnf error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_NilCnf(t *testing.T) {
+
claims := &Claims{
+
Confirmation: nil,
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for nil cnf, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing cnf claim") {
+
t.Errorf("Expected missing cnf error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_MissingJkt(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"other": "value",
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for missing jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_EmptyJkt(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": "",
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for empty jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_WrongType(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": 123, // Not a string
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for wrong type jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
// === Helper Functions for Tests ===
+
+
// splitJWT splits a JWT into its three parts
+
func splitJWT(token string) []string {
+
return []string{
+
token[:strings.IndexByte(token, '.')],
+
token[strings.IndexByte(token, '.')+1 : strings.LastIndexByte(token, '.')],
+
token[strings.LastIndexByte(token, '.')+1:],
+
}
+
}
+
+
// parseJWTHeader parses a base64url-encoded JWT header
+
func parseJWTHeader(t *testing.T, encoded string) map[string]interface{} {
+
t.Helper()
+
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
+
if err != nil {
+
t.Fatalf("Failed to decode header: %v", err)
+
}
+
+
var header map[string]interface{}
+
if err := json.Unmarshal(decoded, &header); err != nil {
+
t.Fatalf("Failed to unmarshal header: %v", err)
+
}
+
+
return header
+
}
+
+
// encodeJSON encodes a value to base64url-encoded JSON
+
func encodeJSON(t *testing.T, v interface{}) string {
+
t.Helper()
+
data, err := json.Marshal(v)
+
if err != nil {
+
t.Fatalf("Failed to marshal JSON: %v", err)
+
}
+
return base64.RawURLEncoding.EncodeToString(data)
+
}
+416
internal/api/middleware/auth_test.go
···
package middleware
import (
+
"Coves/internal/atproto/auth"
"context"
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
···
"time"
"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)
+
+
if proof != nil {
+
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)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
// Calculate JWK thumbprint for cnf.jkt
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
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)
+
claims := auth.Claims{
+
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()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
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
+
+
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,
+
},
+
}
+
+
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
+
+
handlerCalled := false
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// No DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// With skipVerify=true, DPoP is not checked, so this should succeed
+
if !handlerCalled {
+
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
+
claims := auth.Claims{
+
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()),
+
},
+
Scope: "atproto",
+
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!",
+
GetUserDID(r))
+
}))
+
+
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)
+
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()
+
+
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)
+
if err != nil {
+
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
+
middleware.Stop()
+
+
// 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)
+
claims := auth.Claims{
+
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()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
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()
+
+
handlerCalled := false
+
var capturedDID string
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
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)
+
if !handlerCalled {
+
t.Error("handler should be called")
+
}
+
+
// But since JWT verification fails, user should not be authenticated
+
if capturedDID != "" {
+
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()
+
+
handlerCalled := false
+
var capturedDID string
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// No DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
if !handlerCalled {
+
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)
+
defer cache.Stop()
+
+
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(),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
// 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
+
var crv string
+
switch pubKey.Curve {
+
case elliptic.P256():
+
crv = "P-256"
+
case elliptic.P384():
+
crv = "P-384"
+
case elliptic.P521():
+
crv = "P-521"
+
default:
+
panic("unsupported curve")
+
}
+
+
// Encode coordinates
+
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{}{
+
"kty": "EC",
+
"crv": crv,
+
"x": base64.RawURLEncoding.EncodeToString(xPadded),
+
"y": base64.RawURLEncoding.EncodeToString(yPadded),
+
}
+
}
+134 -2
internal/atproto/auth/README.md
···
5. Find matching key by `kid` from JWT header
6. Cache the JWKS for 1 hour
+
## DPoP Token Binding
+
+
DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.
+
+
### What is DPoP?
+
+
DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:
+
+
1. Access token contains `cnf.jkt` claim (JWK thumbprint of client's public key)
+
2. Client creates a DPoP proof JWT signed with their private key
+
3. Server verifies the proof signature and checks it matches the token's `cnf.jkt`
+
+
### CRITICAL: DPoP Security Model
+
+
> โš ๏ธ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**
+
+
The correct verification order is:
+
1. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)
+
2. **If the verified token has `cnf.jkt`, REQUIRE valid DPoP proof**
+
3. **NEVER use DPoP as a fallback when signature verification fails**
+
+
**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.
+
+
### How DPoP Works
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ Client โ”‚ โ”‚ Server โ”‚
+
โ”‚ โ”‚ โ”‚ (Coves) โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚ โ”‚
+
โ”‚ 1. Authorization: Bearer <token> โ”‚
+
โ”‚ DPoP: <proof-jwt> โ”‚
+
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 2. VERIFY token signature
+
โ”‚ โ”‚ (REQUIRED - no fallback!)
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 3. If token has cnf.jkt:
+
โ”‚ โ”‚ - Verify DPoP proof
+
โ”‚ โ”‚ - Check thumbprint match
+
โ”‚ โ”‚
+
โ”‚ 200 OK โ”‚
+
โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
+
```
+
+
### When DPoP is Required
+
+
DPoP verification is **REQUIRED** when:
+
- Access token signature has been verified AND
+
- Access token contains `cnf.jkt` claim (DPoP-bound)
+
+
If the token has `cnf.jkt` but no DPoP header is present, the request is **REJECTED**.
+
+
### Replay Protection
+
+
DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:
+
+
```go
+
// Create a verifier with replay protection (default)
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop() // Stop cleanup goroutine on shutdown
+
+
// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)
+
```
+
+
### DPoP Implementation
+
+
The `dpop.go` module provides:
+
+
```go
+
// Create a verifier with replay protection
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop()
+
+
// Verify the DPoP proof
+
proof, err := verifier.VerifyDPoPProof(dpopHeader, "POST", "https://coves.social/xrpc/...")
+
if err != nil {
+
// Invalid proof (includes replay detection)
+
}
+
+
// Verify it binds to the VERIFIED access token
+
expectedThumbprint, err := auth.ExtractCnfJkt(claims)
+
if err != nil {
+
// Token not DPoP-bound
+
}
+
+
if err := verifier.VerifyTokenBinding(proof, expectedThumbprint); err != nil {
+
// Proof doesn't match token
+
}
+
```
+
+
### DPoP Proof Format
+
+
The DPoP header contains a JWT with:
+
+
**Header**:
+
- `typ`: `"dpop+jwt"` (required)
+
- `alg`: `"ES256"` (or other supported algorithm)
+
- `jwk`: Client's public key (JWK format)
+
+
**Claims**:
+
- `jti`: Unique proof identifier (tracked for replay protection)
+
- `htm`: HTTP method (e.g., `"POST"`)
+
- `htu`: HTTP URI (without query/fragment)
+
- `iat`: Timestamp (must be recent, within 5 minutes)
+
+
**Example**:
+
```json
+
{
+
"typ": "dpop+jwt",
+
"alg": "ES256",
+
"jwk": {
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "...",
+
"y": "..."
+
}
+
}
+
{
+
"jti": "unique-id-123",
+
"htm": "POST",
+
"htu": "https://coves.social/xrpc/social.coves.community.create",
+
"iat": 1700000000
+
}
+
```
+
## Security Considerations
### โœ… Implemented
···
- Required claims validation (sub, iss)
- Key caching with TTL
- Secure error messages (no internal details leaked)
+
- **DPoP proof verification** (proof-of-possession for token binding)
+
- **DPoP thumbprint validation** (prevents token theft attacks)
+
- **DPoP freshness checks** (5-minute proof validity window)
+
- **DPoP replay protection** (jti tracking with in-memory cache)
+
- **Secure DPoP model** (DPoP required AFTER signature verification, never as fallback)
### โš ๏ธ Not Yet Implemented
-
- DPoP validation (for replay attack prevention)
+
- Server-issued DPoP nonces (additional replay protection)
- Scope validation (checking `scope` claim)
- Audience validation (checking `aud` claim)
- Rate limiting per DID
···
## Future Enhancements
-
- [ ] DPoP proof validation
+
- [ ] DPoP nonce validation (server-managed nonce for additional replay protection)
- [ ] Scope-based authorization
- [ ] Audience claim validation
- [ ] Token revocation support
+4 -1
.gitignore
···
# Build artifacts
/validate-lexicon
-
/bin/
+
/bin/
+
+
# Go build cache
+
.cache/
+5 -6
go.mod
···
module Coves
-
go 1.24.0
+
go 1.25
require (
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
···
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/lib/pq v1.10.9
github.com/pressly/goose/v3 v3.22.1
-
github.com/stretchr/testify v1.9.0
+
github.com/stretchr/testify v1.10.0
+
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/net v0.46.0
golang.org/x/time v0.3.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
-
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
···
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
-
github.com/stretchr/objx v0.5.2 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
-
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
+6 -8
go.sum
···
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
-
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
-
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 h1:Vc+l4sltxQfBT8qC3dm87PRYInmxlGyF1dmpjaW0WkU=
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
···
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
+
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
···
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
···
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=