···3838PDS_ADMIN_PASSWORD=admin
39394040# Handle domains (users will get handles like alice.local.coves.dev)
4141-# Communities will use .community.coves.social (singular per atProto conventions)
4242-PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social
4141+# Communities will use c-{name}.coves.social (3-level format with c- prefix)
4242+PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.coves.social
43434444# PLC Rotation Key (k256 private key in hex format - for local dev only)
4545# This is a randomly generated key for testing - DO NOT use in production
···133133PDS_INSTANCE_HANDLE=testuser123.local.coves.dev
134134PDS_INSTANCE_PASSWORD=test-password-123
135135136136-# Kagi News Aggregator DID (for trusted thumbnail URLs)
137137-KAGI_AGGREGATOR_DID=did:plc:yyf34padpfjknejyutxtionr
136136+# Trusted Aggregator DIDs (bypasses community authorization check)
137137+# Comma-separated list of DIDs
138138+# - did:plc:yyf34padpfjknejyutxtionr = kagi-news.coves.social (production)
139139+# - did:plc:igjbg5cex7poojsniebvmafb = test-aggregator.local.coves.dev (dev)
140140+TRUSTED_AGGREGATOR_DIDS=did:plc:yyf34padpfjknejyutxtionr,did:plc:igjbg5cex7poojsniebvmafb
138141139142# =============================================================================
140143# Development Settings
···11-# Aggregator Identity (pre-created account credentials)
22-AGGREGATOR_HANDLE=kagi-news.local.coves.dev
33-AGGREGATOR_PASSWORD=your-secure-password-here
11+# Coves API Key (get from https://coves.social after OAuth login)
22+COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4354# Optional: Override Coves API URL (defaults to config.yaml)
65# COVES_API_URL=http://localhost:3001
+2
aggregators/kagi-news/config.example.yaml
···2233# Coves API endpoint
44coves_api_url: "https://coves.social"
55+# API key is loaded from COVES_API_KEY environment variable
66+# Get your API key from https://coves.social after OAuth login
5768# Feed-to-community mappings
79# Handle format: c-{name}.{instance} (e.g., c-worldnews.coves.social)
···11+package aggregator
22+33+import (
44+ "errors"
55+ "log"
66+ "net/http"
77+88+ "Coves/internal/api/middleware"
99+ "Coves/internal/core/aggregators"
1010+)
1111+1212+// CreateAPIKeyHandler handles API key creation for aggregators
1313+type CreateAPIKeyHandler struct {
1414+ apiKeyService aggregators.APIKeyServiceInterface
1515+ aggregatorService aggregators.Service
1616+}
1717+1818+// NewCreateAPIKeyHandler creates a new handler for API key creation
1919+func NewCreateAPIKeyHandler(apiKeyService aggregators.APIKeyServiceInterface, aggregatorService aggregators.Service) *CreateAPIKeyHandler {
2020+ return &CreateAPIKeyHandler{
2121+ apiKeyService: apiKeyService,
2222+ aggregatorService: aggregatorService,
2323+ }
2424+}
2525+2626+// CreateAPIKeyResponse represents the response when creating an API key
2727+type CreateAPIKeyResponse struct {
2828+ Key string `json:"key"` // The plain-text key (shown ONCE)
2929+ KeyPrefix string `json:"keyPrefix"` // First 12 chars for identification
3030+ DID string `json:"did"` // Aggregator DID
3131+ CreatedAt string `json:"createdAt"` // ISO8601 timestamp
3232+}
3333+3434+// HandleCreateAPIKey handles POST /xrpc/social.coves.aggregator.createApiKey
3535+// This endpoint requires OAuth authentication and is only available to registered aggregators.
3636+// The API key is returned ONCE and cannot be retrieved again.
3737+//
3838+// Key Replacement: If an aggregator already has an API key, calling this endpoint will
3939+// generate a new key and replace the existing one. The old key will be immediately
4040+// invalidated and all future requests using the old key will fail authentication.
4141+func (h *CreateAPIKeyHandler) HandleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
4242+ if r.Method != http.MethodPost {
4343+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
4444+ return
4545+ }
4646+4747+ // Get authenticated DID from context (set by RequireAuth middleware)
4848+ userDID := middleware.GetUserDID(r)
4949+ if userDID == "" {
5050+ writeError(w, http.StatusUnauthorized, "AuthenticationRequired", "Must be authenticated to create API key")
5151+ return
5252+ }
5353+5454+ // Verify the caller is a registered aggregator
5555+ isAggregator, err := h.aggregatorService.IsAggregator(r.Context(), userDID)
5656+ if err != nil {
5757+ log.Printf("ERROR: Failed to check aggregator status: %v", err)
5858+ writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to verify aggregator status")
5959+ return
6060+ }
6161+ if !isAggregator {
6262+ writeError(w, http.StatusForbidden, "AggregatorRequired", "Only registered aggregators can create API keys")
6363+ return
6464+ }
6565+6666+ // Get the OAuth session from context
6767+ oauthSession := middleware.GetOAuthSession(r)
6868+ if oauthSession == nil {
6969+ writeError(w, http.StatusUnauthorized, "OAuthSessionRequired", "OAuth session required to create API key")
7070+ return
7171+ }
7272+7373+ // Generate the API key
7474+ plainKey, keyPrefix, err := h.apiKeyService.GenerateKey(r.Context(), userDID, oauthSession)
7575+ if err != nil {
7676+ log.Printf("ERROR: Failed to generate API key for %s: %v", userDID, err)
7777+7878+ // Differentiate error types for appropriate HTTP status codes
7979+ switch {
8080+ case aggregators.IsNotFound(err):
8181+ // Aggregator not found in database - should not happen if IsAggregator check passed
8282+ writeError(w, http.StatusForbidden, "AggregatorRequired", "User is not a registered aggregator")
8383+ case errors.Is(err, aggregators.ErrOAuthSessionMismatch):
8484+ // OAuth session DID doesn't match the requested aggregator DID
8585+ writeError(w, http.StatusBadRequest, "SessionMismatch", "OAuth session does not match the requested aggregator")
8686+ default:
8787+ // All other errors are internal server errors
8888+ writeError(w, http.StatusInternalServerError, "KeyGenerationFailed", "Failed to generate API key")
8989+ }
9090+ return
9191+ }
9292+9393+ // Return the key (shown ONCE only)
9494+ response := CreateAPIKeyResponse{
9595+ Key: plainKey,
9696+ KeyPrefix: keyPrefix,
9797+ DID: userDID,
9898+ CreatedAt: formatTimestamp(),
9999+ }
100100+101101+ writeJSONResponse(w, http.StatusOK, response)
102102+}
+28-6
internal/api/handlers/aggregator/errors.go
···33import (
44 "Coves/internal/core/aggregators"
55 "Coves/internal/core/communities"
66+ "bytes"
67 "encoding/json"
78 "log"
89 "net/http"
···1415 Message string `json:"message"`
1516}
16171717-// writeError writes a JSON error response
1818-func writeError(w http.ResponseWriter, statusCode int, errorType, message string) {
1818+// writeJSONResponse buffers the JSON encoding before sending headers.
1919+// This ensures that encoding failures don't result in partial responses
2020+// with already-sent headers. Returns true if the response was written
2121+// successfully, false otherwise.
2222+func writeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) bool {
2323+ // Buffer the JSON first to detect encoding errors before sending headers
2424+ var buf bytes.Buffer
2525+ if err := json.NewEncoder(&buf).Encode(data); err != nil {
2626+ log.Printf("ERROR: Failed to encode JSON response: %v", err)
2727+ // Send a proper error response since we haven't sent headers yet
2828+ w.Header().Set("Content-Type", "application/json")
2929+ w.WriteHeader(http.StatusInternalServerError)
3030+ _, _ = w.Write([]byte(`{"error":"InternalServerError","message":"Failed to encode response"}`))
3131+ return false
3232+ }
3333+1934 w.Header().Set("Content-Type", "application/json")
2035 w.WriteHeader(statusCode)
2121- if err := json.NewEncoder(w).Encode(ErrorResponse{
3636+ if _, err := w.Write(buf.Bytes()); err != nil {
3737+ log.Printf("ERROR: Failed to write response body: %v", err)
3838+ return false
3939+ }
4040+ return true
4141+}
4242+4343+// writeError writes a JSON error response with proper buffering
4444+func writeError(w http.ResponseWriter, statusCode int, errorType, message string) {
4545+ writeJSONResponse(w, statusCode, ErrorResponse{
2246 Error: errorType,
2347 Message: message,
2424- }); err != nil {
2525- log.Printf("ERROR: Failed to encode error response: %v", err)
2626- }
4848+ })
2749}
28502951// handleServiceError maps service errors to HTTP responses
+109
internal/api/handlers/aggregator/get_api_key.go
···11+package aggregator
22+33+import (
44+ "log"
55+ "net/http"
66+77+ "Coves/internal/api/middleware"
88+ "Coves/internal/core/aggregators"
99+)
1010+1111+// GetAPIKeyHandler handles API key info retrieval for aggregators
1212+type GetAPIKeyHandler struct {
1313+ apiKeyService aggregators.APIKeyServiceInterface
1414+ aggregatorService aggregators.Service
1515+}
1616+1717+// NewGetAPIKeyHandler creates a new handler for API key info retrieval
1818+func NewGetAPIKeyHandler(apiKeyService aggregators.APIKeyServiceInterface, aggregatorService aggregators.Service) *GetAPIKeyHandler {
1919+ return &GetAPIKeyHandler{
2020+ apiKeyService: apiKeyService,
2121+ aggregatorService: aggregatorService,
2222+ }
2323+}
2424+2525+// APIKeyView represents the nested key metadata (matches social.coves.aggregator.defs#apiKeyView)
2626+type APIKeyView struct {
2727+ Prefix string `json:"prefix"` // First 12 chars for identification
2828+ CreatedAt string `json:"createdAt"` // ISO8601 timestamp when key was created
2929+ LastUsedAt *string `json:"lastUsedAt,omitempty"` // ISO8601 timestamp when key was last used
3030+ IsRevoked bool `json:"isRevoked"` // Whether the key has been revoked
3131+ RevokedAt *string `json:"revokedAt,omitempty"` // ISO8601 timestamp when key was revoked
3232+}
3333+3434+// GetAPIKeyResponse represents the response when getting API key info
3535+type GetAPIKeyResponse struct {
3636+ HasKey bool `json:"hasKey"` // Whether the aggregator has an API key
3737+ KeyInfo *APIKeyView `json:"keyInfo,omitempty"` // Key metadata (only present if hasKey is true)
3838+}
3939+4040+// HandleGetAPIKey handles GET /xrpc/social.coves.aggregator.getApiKey
4141+// This endpoint requires OAuth authentication and returns info about the aggregator's API key.
4242+// NOTE: The actual key value is NEVER returned - only metadata about the key.
4343+func (h *GetAPIKeyHandler) HandleGetAPIKey(w http.ResponseWriter, r *http.Request) {
4444+ if r.Method != http.MethodGet {
4545+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
4646+ return
4747+ }
4848+4949+ // Get authenticated DID from context (set by RequireAuth middleware)
5050+ userDID := middleware.GetUserDID(r)
5151+ if userDID == "" {
5252+ writeError(w, http.StatusUnauthorized, "AuthenticationRequired", "Must be authenticated to get API key info")
5353+ return
5454+ }
5555+5656+ // Verify the caller is a registered aggregator
5757+ isAggregator, err := h.aggregatorService.IsAggregator(r.Context(), userDID)
5858+ if err != nil {
5959+ log.Printf("ERROR: Failed to check aggregator status: %v", err)
6060+ writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to verify aggregator status")
6161+ return
6262+ }
6363+ if !isAggregator {
6464+ writeError(w, http.StatusForbidden, "AggregatorRequired", "Only registered aggregators can get API key info")
6565+ return
6666+ }
6767+6868+ // Get API key info
6969+ keyInfo, err := h.apiKeyService.GetAPIKeyInfo(r.Context(), userDID)
7070+ if err != nil {
7171+ if aggregators.IsNotFound(err) {
7272+ writeError(w, http.StatusNotFound, "AggregatorNotFound", "Aggregator not found")
7373+ return
7474+ }
7575+ log.Printf("ERROR: Failed to get API key info for %s: %v", userDID, err)
7676+ writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to get API key info")
7777+ return
7878+ }
7979+8080+ // Build response
8181+ response := GetAPIKeyResponse{
8282+ HasKey: keyInfo.HasKey,
8383+ }
8484+8585+ if keyInfo.HasKey {
8686+ view := &APIKeyView{
8787+ Prefix: keyInfo.KeyPrefix,
8888+ IsRevoked: keyInfo.IsRevoked,
8989+ }
9090+9191+ if keyInfo.CreatedAt != nil {
9292+ view.CreatedAt = keyInfo.CreatedAt.Format("2006-01-02T15:04:05.000Z")
9393+ }
9494+9595+ if keyInfo.LastUsedAt != nil {
9696+ ts := keyInfo.LastUsedAt.Format("2006-01-02T15:04:05.000Z")
9797+ view.LastUsedAt = &ts
9898+ }
9999+100100+ if keyInfo.RevokedAt != nil {
101101+ ts := keyInfo.RevokedAt.Format("2006-01-02T15:04:05.000Z")
102102+ view.RevokedAt = &ts
103103+ }
104104+105105+ response.KeyInfo = view
106106+ }
107107+108108+ writeJSONResponse(w, http.StatusOK, response)
109109+}
+42
internal/api/handlers/aggregator/metrics.go
···11+package aggregator
22+33+import (
44+ "net/http"
55+66+ "Coves/internal/core/aggregators"
77+)
88+99+// MetricsHandler provides API key service metrics for monitoring
1010+type MetricsHandler struct {
1111+ apiKeyService aggregators.APIKeyServiceInterface
1212+}
1313+1414+// NewMetricsHandler creates a new metrics handler
1515+func NewMetricsHandler(apiKeyService aggregators.APIKeyServiceInterface) *MetricsHandler {
1616+ return &MetricsHandler{
1717+ apiKeyService: apiKeyService,
1818+ }
1919+}
2020+2121+// MetricsResponse contains API key service operational metrics
2222+type MetricsResponse struct {
2323+ FailedLastUsedUpdates int64 `json:"failedLastUsedUpdates"`
2424+ FailedNonceUpdates int64 `json:"failedNonceUpdates"`
2525+}
2626+2727+// HandleMetrics handles GET /xrpc/social.coves.aggregator.getMetrics
2828+// Returns operational metrics for the API key service.
2929+// This endpoint is intended for internal monitoring and health checks.
3030+func (h *MetricsHandler) HandleMetrics(w http.ResponseWriter, r *http.Request) {
3131+ if r.Method != http.MethodGet {
3232+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
3333+ return
3434+ }
3535+3636+ response := MetricsResponse{
3737+ FailedLastUsedUpdates: h.apiKeyService.GetFailedLastUsedUpdates(),
3838+ FailedNonceUpdates: h.apiKeyService.GetFailedNonceUpdates(),
3939+ }
4040+4141+ writeJSONResponse(w, http.StatusOK, response)
4242+}
···3333const (
3434 AuthMethodOAuth = "oauth"
3535 AuthMethodServiceJWT = "service_jwt"
3636+ AuthMethodAPIKey = "api_key"
3637)
3838+3939+// API key prefix constant
4040+const APIKeyPrefix = "ckapi_"
37413842// SessionUnsealer is an interface for unsealing session tokens
3943// This allows for mocking in tests
···4953// ServiceAuthValidator is an interface for validating service JWTs
5054type ServiceAuthValidator interface {
5155 Validate(ctx context.Context, tokenString string, lexMethod *syntax.NSID) (syntax.DID, error)
5656+}
5757+5858+// APIKeyValidator is an interface for validating API keys (used by aggregators)
5959+type APIKeyValidator interface {
6060+ // ValidateKey validates an API key and returns the aggregator DID if valid
6161+ ValidateKey(ctx context.Context, plainKey string) (aggregatorDID string, err error)
6262+ // RefreshTokensIfNeeded refreshes OAuth tokens for the aggregator if they are expired
6363+ RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error
5264}
53655466// OAuthAuthMiddleware enforces OAuth authentication using sealed session tokens.
···329341 }
330342}
331343332332-// DualAuthMiddleware enforces authentication using either OAuth sealed tokens (for users)
333333-// or PDS service JWTs (for aggregators only).
344344+// DualAuthMiddleware enforces authentication using either OAuth sealed tokens (for users),
345345+// PDS service JWTs (for aggregators), or API keys (for aggregators).
334346type DualAuthMiddleware struct {
335347 unsealer SessionUnsealer
336348 store oauthlib.ClientAuthStore
337349 serviceValidator ServiceAuthValidator
338350 aggregatorChecker AggregatorChecker
351351+ apiKeyValidator APIKeyValidator // Optional: if nil, API key auth is disabled
339352}
340353341354// NewDualAuthMiddleware creates a new dual auth middleware that supports both OAuth and service JWT authentication.
···353366 }
354367}
355368356356-// RequireAuth middleware ensures the user is authenticated via either OAuth or service JWT.
369369+// WithAPIKeyValidator adds API key validation support to the middleware.
370370+// Returns the middleware for method chaining.
371371+func (m *DualAuthMiddleware) WithAPIKeyValidator(validator APIKeyValidator) *DualAuthMiddleware {
372372+ m.apiKeyValidator = validator
373373+ return m
374374+}
375375+376376+// RequireAuth middleware ensures the user is authenticated via either OAuth, service JWT, or API key.
357377// Supports:
378378+// - API keys via Authorization: Bearer ckapi_... (aggregators only, checked first)
358379// - OAuth sealed session tokens via Authorization: Bearer <sealed_token> or Cookie: coves_session=<sealed_token>
359380// - Service JWTs via Authorization: Bearer <jwt>
360381//
361361-// SECURITY: Service JWT authentication is RESTRICTED to registered aggregators only.
362362-// Non-aggregator DIDs will be rejected even with valid JWT signatures.
363363-// This enforcement happens in handleServiceAuth() via aggregatorChecker.IsAggregator().
382382+// SECURITY: Service JWT and API key authentication are RESTRICTED to registered aggregators only.
383383+// Non-aggregator DIDs will be rejected even with valid JWT signatures or API keys.
384384+// This enforcement happens in handleServiceAuth() via aggregatorChecker.IsAggregator() and
385385+// in handleAPIKeyAuth() via apiKeyValidator.ValidateKey().
364386//
365387// If not authenticated, returns 401.
366388// If authenticated, injects user DID and auth method into context.
···398420 log.Printf("[AUTH_TRACE] ip=%s method=%s path=%s token_source=%s",
399421 r.RemoteAddr, r.Method, r.URL.Path, tokenSource)
400422423423+ // Check for API key first (before JWT/OAuth routing)
424424+ // API keys start with "ckapi_" prefix
425425+ if strings.HasPrefix(token, APIKeyPrefix) {
426426+ m.handleAPIKeyAuth(w, r, next, token)
427427+ return
428428+ }
429429+401430 // Detect token type and route to appropriate handler
402431 if isJWTFormat(token) {
403432 m.handleServiceAuth(w, r, next, token)
···411440func (m *DualAuthMiddleware) handleServiceAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) {
412441 // Validate the service JWT
413442 // Note: lexMethod is nil, which allows any lexicon method (endpoint-agnostic validation).
414414- // The ServiceAuthValidator skips the lexicon method check when nil (see indigo/atproto/auth/jwt.go:86-88).
443443+ // The ServiceAuthValidator skips the lexicon method check when lexMethod is nil.
415444 // This is intentional - we want aggregators to authenticate globally, not per-endpoint.
416445 did, err := m.serviceValidator.Validate(r.Context(), token, nil)
417446 if err != nil {
···447476 ctx := context.WithValue(r.Context(), UserDIDKey, didStr)
448477 ctx = context.WithValue(ctx, IsAggregatorAuthKey, true)
449478 ctx = context.WithValue(ctx, AuthMethodKey, AuthMethodServiceJWT)
479479+480480+ // Call next handler
481481+ next.ServeHTTP(w, r.WithContext(ctx))
482482+}
483483+484484+// handleAPIKeyAuth handles authentication using Coves API keys (aggregators only)
485485+func (m *DualAuthMiddleware) handleAPIKeyAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) {
486486+ // Check if API key validation is enabled
487487+ if m.apiKeyValidator == nil {
488488+ log.Printf("[AUTH_FAILURE] type=api_key_disabled ip=%s method=%s path=%s",
489489+ r.RemoteAddr, r.Method, r.URL.Path)
490490+ writeAuthError(w, "API key authentication is not enabled")
491491+ return
492492+ }
493493+494494+ // Validate the API key
495495+ aggregatorDID, err := m.apiKeyValidator.ValidateKey(r.Context(), token)
496496+ if err != nil {
497497+ log.Printf("[AUTH_FAILURE] type=api_key_invalid ip=%s method=%s path=%s error=%v",
498498+ r.RemoteAddr, r.Method, r.URL.Path, err)
499499+ writeAuthError(w, "Invalid or revoked API key")
500500+ return
501501+ }
502502+503503+ // Refresh OAuth tokens if needed (for PDS operations)
504504+ if err := m.apiKeyValidator.RefreshTokensIfNeeded(r.Context(), aggregatorDID); err != nil {
505505+ log.Printf("[AUTH_FAILURE] type=token_refresh_failed ip=%s method=%s path=%s did=%s error=%v",
506506+ r.RemoteAddr, r.Method, r.URL.Path, aggregatorDID, err)
507507+ // Token refresh failure means the aggregator cannot perform authenticated PDS operations
508508+ // This is a critical failure - reject the request so the aggregator knows to re-authenticate
509509+ writeAuthError(w, "API key authentication failed: unable to refresh OAuth tokens. Please re-authenticate.")
510510+ return
511511+ }
512512+513513+ log.Printf("[AUTH_SUCCESS] type=api_key ip=%s method=%s path=%s did=%s",
514514+ r.RemoteAddr, r.Method, r.URL.Path, aggregatorDID)
515515+516516+ // Inject DID and auth method into context
517517+ ctx := context.WithValue(r.Context(), UserDIDKey, aggregatorDID)
518518+ ctx = context.WithValue(ctx, IsAggregatorAuthKey, true)
519519+ ctx = context.WithValue(ctx, AuthMethodKey, AuthMethodAPIKey)
450520451521 // Call next handler
452522 next.ServeHTTP(w, r.WithContext(ctx))
+206
internal/api/middleware/auth_test.go
···16911691 })
16921692 }
16931693}
16941694+16951695+// Mock APIKeyValidator for testing
16961696+type mockAPIKeyValidator struct {
16971697+ aggregators map[string]string // key -> DID
16981698+ shouldFail bool
16991699+ refreshCalled bool
17001700+}
17011701+17021702+func (m *mockAPIKeyValidator) ValidateKey(ctx context.Context, plainKey string) (string, error) {
17031703+ if m.shouldFail {
17041704+ return "", fmt.Errorf("invalid API key")
17051705+ }
17061706+ // Extract DID from key for testing (real implementation would hash and look up)
17071707+ // Test format: ckapi_<did_suffix>_rest
17081708+ if len(plainKey) < 12 {
17091709+ return "", fmt.Errorf("invalid key format")
17101710+ }
17111711+ // For testing, assume valid keys return a known aggregator DID
17121712+ if aggregatorDID, ok := m.aggregators[plainKey]; ok {
17131713+ return aggregatorDID, nil
17141714+ }
17151715+ return "", fmt.Errorf("unknown API key")
17161716+}
17171717+17181718+func (m *mockAPIKeyValidator) RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error {
17191719+ m.refreshCalled = true
17201720+ return nil
17211721+}
17221722+17231723+// TestDualAuthMiddleware_APIKey_Valid tests API key authentication
17241724+func TestDualAuthMiddleware_APIKey_Valid(t *testing.T) {
17251725+ client := newMockOAuthClient()
17261726+ store := newMockOAuthStore()
17271727+ validator := &mockServiceAuthValidator{}
17281728+ aggregatorChecker := &mockAggregatorChecker{
17291729+ aggregators: make(map[string]bool),
17301730+ }
17311731+17321732+ apiKeyValidator := &mockAPIKeyValidator{
17331733+ aggregators: map[string]string{
17341734+ "ckapi_test1234567890123456789012345678": "did:plc:aggregator123",
17351735+ },
17361736+ }
17371737+17381738+ middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker).
17391739+ WithAPIKeyValidator(apiKeyValidator)
17401740+17411741+ handlerCalled := false
17421742+ handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17431743+ handlerCalled = true
17441744+17451745+ // Verify DID was extracted
17461746+ extractedDID := GetUserDID(r)
17471747+ if extractedDID != "did:plc:aggregator123" {
17481748+ t.Errorf("expected DID 'did:plc:aggregator123', got %s", extractedDID)
17491749+ }
17501750+17511751+ // Verify it's marked as aggregator auth
17521752+ if !IsAggregatorAuth(r) {
17531753+ t.Error("expected IsAggregatorAuth to be true")
17541754+ }
17551755+17561756+ // Verify auth method
17571757+ authMethod := GetAuthMethod(r)
17581758+ if authMethod != AuthMethodAPIKey {
17591759+ t.Errorf("expected auth method %s, got %s", AuthMethodAPIKey, authMethod)
17601760+ }
17611761+17621762+ w.WriteHeader(http.StatusOK)
17631763+ }))
17641764+17651765+ req := httptest.NewRequest("GET", "/test", nil)
17661766+ req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678")
17671767+ w := httptest.NewRecorder()
17681768+17691769+ handler.ServeHTTP(w, req)
17701770+17711771+ if !handlerCalled {
17721772+ t.Error("handler was not called")
17731773+ }
17741774+17751775+ if w.Code != http.StatusOK {
17761776+ t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
17771777+ }
17781778+17791779+ // Verify token refresh was attempted
17801780+ if !apiKeyValidator.refreshCalled {
17811781+ t.Error("expected token refresh to be called")
17821782+ }
17831783+}
17841784+17851785+// TestDualAuthMiddleware_APIKey_Invalid tests API key authentication with invalid key
17861786+func TestDualAuthMiddleware_APIKey_Invalid(t *testing.T) {
17871787+ client := newMockOAuthClient()
17881788+ store := newMockOAuthStore()
17891789+ validator := &mockServiceAuthValidator{}
17901790+ aggregatorChecker := &mockAggregatorChecker{
17911791+ aggregators: make(map[string]bool),
17921792+ }
17931793+17941794+ apiKeyValidator := &mockAPIKeyValidator{
17951795+ shouldFail: true,
17961796+ }
17971797+17981798+ middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker).
17991799+ WithAPIKeyValidator(apiKeyValidator)
18001800+18011801+ handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18021802+ t.Error("handler should not be called for invalid API key")
18031803+ }))
18041804+18051805+ req := httptest.NewRequest("GET", "/test", nil)
18061806+ req.Header.Set("Authorization", "Bearer ckapi_invalid_key_12345678901234567")
18071807+ w := httptest.NewRecorder()
18081808+18091809+ handler.ServeHTTP(w, req)
18101810+18111811+ if w.Code != http.StatusUnauthorized {
18121812+ t.Errorf("expected status 401, got %d", w.Code)
18131813+ }
18141814+18151815+ var response map[string]string
18161816+ _ = json.Unmarshal(w.Body.Bytes(), &response)
18171817+ if response["message"] != "Invalid or revoked API key" {
18181818+ t.Errorf("unexpected error message: %s", response["message"])
18191819+ }
18201820+}
18211821+18221822+// TestDualAuthMiddleware_APIKey_Disabled tests API key auth when validator is not configured
18231823+func TestDualAuthMiddleware_APIKey_Disabled(t *testing.T) {
18241824+ client := newMockOAuthClient()
18251825+ store := newMockOAuthStore()
18261826+ validator := &mockServiceAuthValidator{}
18271827+ aggregatorChecker := &mockAggregatorChecker{
18281828+ aggregators: make(map[string]bool),
18291829+ }
18301830+18311831+ // No API key validator configured
18321832+ middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker)
18331833+18341834+ handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18351835+ t.Error("handler should not be called when API key auth is disabled")
18361836+ }))
18371837+18381838+ req := httptest.NewRequest("GET", "/test", nil)
18391839+ req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678")
18401840+ w := httptest.NewRecorder()
18411841+18421842+ handler.ServeHTTP(w, req)
18431843+18441844+ if w.Code != http.StatusUnauthorized {
18451845+ t.Errorf("expected status 401, got %d", w.Code)
18461846+ }
18471847+18481848+ var response map[string]string
18491849+ _ = json.Unmarshal(w.Body.Bytes(), &response)
18501850+ if response["message"] != "API key authentication is not enabled" {
18511851+ t.Errorf("unexpected error message: %s", response["message"])
18521852+ }
18531853+}
18541854+18551855+// TestDualAuthMiddleware_APIKey_PrecedenceOverOAuth tests that API keys are detected before OAuth
18561856+func TestDualAuthMiddleware_APIKey_PrecedenceOverOAuth(t *testing.T) {
18571857+ client := newMockOAuthClient()
18581858+ store := newMockOAuthStore()
18591859+ validator := &mockServiceAuthValidator{}
18601860+ aggregatorChecker := &mockAggregatorChecker{
18611861+ aggregators: make(map[string]bool),
18621862+ }
18631863+18641864+ apiKeyValidator := &mockAPIKeyValidator{
18651865+ aggregators: map[string]string{
18661866+ "ckapi_test1234567890123456789012345678": "did:plc:apikey_aggregator",
18671867+ },
18681868+ }
18691869+18701870+ middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker).
18711871+ WithAPIKeyValidator(apiKeyValidator)
18721872+18731873+ handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18741874+ // Verify API key auth was used
18751875+ authMethod := GetAuthMethod(r)
18761876+ if authMethod != AuthMethodAPIKey {
18771877+ t.Errorf("expected API key auth method, got %s", authMethod)
18781878+ }
18791879+18801880+ // Verify DID from API key (not OAuth)
18811881+ did := GetUserDID(r)
18821882+ if did != "did:plc:apikey_aggregator" {
18831883+ t.Errorf("expected API key aggregator DID, got %s", did)
18841884+ }
18851885+18861886+ w.WriteHeader(http.StatusOK)
18871887+ }))
18881888+18891889+ // Use API key format token (starts with ckapi_)
18901890+ req := httptest.NewRequest("GET", "/test", nil)
18911891+ req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678")
18921892+ w := httptest.NewRecorder()
18931893+18941894+ handler.ServeHTTP(w, req)
18951895+18961896+ if w.Code != http.StatusOK {
18971897+ t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
18981898+ }
18991899+}
+37
internal/api/routes/aggregator.go
···5757 // POST /xrpc/social.coves.aggregator.disable (requires auth + moderator)
5858 // POST /xrpc/social.coves.aggregator.updateConfig (requires auth + moderator)
5959}
6060+6161+// RegisterAggregatorAPIKeyRoutes registers API key management endpoints for aggregators.
6262+// These endpoints require OAuth authentication and are only available to registered aggregators.
6363+// Call this function AFTER setting up the auth middleware.
6464+func RegisterAggregatorAPIKeyRoutes(
6565+ r chi.Router,
6666+ authMiddleware middleware.AuthMiddleware,
6767+ apiKeyService aggregators.APIKeyServiceInterface,
6868+ aggregatorService aggregators.Service,
6969+) {
7070+ // Create API key handlers
7171+ createAPIKeyHandler := aggregator.NewCreateAPIKeyHandler(apiKeyService, aggregatorService)
7272+ getAPIKeyHandler := aggregator.NewGetAPIKeyHandler(apiKeyService, aggregatorService)
7373+ revokeAPIKeyHandler := aggregator.NewRevokeAPIKeyHandler(apiKeyService, aggregatorService)
7474+ metricsHandler := aggregator.NewMetricsHandler(apiKeyService)
7575+7676+ // API key management endpoints (require OAuth authentication)
7777+ // POST /xrpc/social.coves.aggregator.createApiKey
7878+ // Creates a new API key for the authenticated aggregator
7979+ r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.aggregator.createApiKey",
8080+ createAPIKeyHandler.HandleCreateAPIKey)
8181+8282+ // GET /xrpc/social.coves.aggregator.getApiKey
8383+ // Gets info about the authenticated aggregator's API key (not the key itself)
8484+ r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.aggregator.getApiKey",
8585+ getAPIKeyHandler.HandleGetAPIKey)
8686+8787+ // POST /xrpc/social.coves.aggregator.revokeApiKey
8888+ // Revokes the authenticated aggregator's API key
8989+ r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.aggregator.revokeApiKey",
9090+ revokeAPIKeyHandler.HandleRevokeAPIKey)
9191+9292+ // GET /xrpc/social.coves.aggregator.getMetrics
9393+ // Returns operational metrics for the API key service (internal monitoring endpoint)
9494+ // No authentication required - metrics are non-sensitive operational data
9595+ r.Get("/xrpc/social.coves.aggregator.getMetrics", metricsHandler.HandleMetrics)
9696+}
···11+{
22+ "lexicon": 1,
33+ "id": "social.coves.aggregator.revokeApiKey",
44+ "defs": {
55+ "main": {
66+ "type": "procedure",
77+ "description": "Revoke the authenticated aggregator's API key. After revocation, the aggregator must complete OAuth flow again to create a new API key. This action cannot be undone.",
88+ "input": {
99+ "encoding": "application/json",
1010+ "schema": {
1111+ "type": "object",
1212+ "description": "No input required. Revokes the key for the authenticated aggregator.",
1313+ "properties": {}
1414+ }
1515+ },
1616+ "output": {
1717+ "encoding": "application/json",
1818+ "schema": {
1919+ "type": "object",
2020+ "required": ["revokedAt"],
2121+ "properties": {
2222+ "revokedAt": {
2323+ "type": "string",
2424+ "format": "datetime",
2525+ "description": "ISO8601 timestamp when the key was revoked"
2626+ }
2727+ }
2828+ }
2929+ },
3030+ "errors": [
3131+ {
3232+ "name": "AuthenticationRequired",
3333+ "description": "Authentication is required to revoke an API key"
3434+ },
3535+ {
3636+ "name": "AggregatorRequired",
3737+ "description": "Only registered aggregators can revoke API keys"
3838+ },
3939+ {
4040+ "name": "AggregatorNotFound",
4141+ "description": "Aggregator not found"
4242+ },
4343+ {
4444+ "name": "ApiKeyNotFound",
4545+ "description": "No API key exists to revoke"
4646+ },
4747+ {
4848+ "name": "ApiKeyAlreadyRevoked",
4949+ "description": "API key has already been revoked"
5050+ },
5151+ {
5252+ "name": "RevocationFailed",
5353+ "description": "Failed to revoke the API key"
5454+ }
5555+ ]
5656+ }
5757+ }
5858+}
+102-13
internal/core/aggregators/aggregator.go
···66// Aggregators are autonomous services that can post content to communities after authorization
77// Following Bluesky's pattern: app.bsky.feed.generator and app.bsky.labeler.service
88type Aggregator struct {
99- CreatedAt time.Time `json:"createdAt" db:"created_at"`
1010- IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
1111- AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"`
1212- DID string `json:"did" db:"did"`
1313- MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"`
1414- SourceURL string `json:"sourceUrl,omitempty" db:"source_url"`
1515- Description string `json:"description,omitempty" db:"description"`
1616- DisplayName string `json:"displayName" db:"display_name"`
1717- RecordURI string `json:"recordUri,omitempty" db:"record_uri"`
1818- RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
1919- ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"`
2020- CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"`
2121- PostsCreated int `json:"postsCreated" db:"posts_created"`
99+ // Core timestamps
1010+ CreatedAt time.Time `json:"createdAt" db:"created_at"`
1111+ IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
1212+1313+ // Identity and display
1414+ DID string `json:"did" db:"did"`
1515+ DisplayName string `json:"displayName" db:"display_name"`
1616+ Description string `json:"description,omitempty" db:"description"`
1717+ AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"`
1818+1919+ // Metadata
2020+ MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"`
2121+ SourceURL string `json:"sourceUrl,omitempty" db:"source_url"`
2222+ RecordURI string `json:"recordUri,omitempty" db:"record_uri"`
2323+ RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
2424+ ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"`
2525+2626+ // Stats
2727+ CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"`
2828+ PostsCreated int `json:"postsCreated" db:"posts_created"`
2929+}
3030+3131+// OAuthCredentials holds OAuth session data for aggregator authentication
3232+// Used when setting up or refreshing API key authentication
3333+type OAuthCredentials struct {
3434+ AccessToken string
3535+ RefreshToken string
3636+ TokenExpiresAt time.Time
3737+ PDSURL string
3838+ AuthServerIss string
3939+ AuthServerTokenEndpoint string
4040+ DPoPPrivateKeyMultibase string
4141+ DPoPAuthServerNonce string
4242+ DPoPPDSNonce string
4343+}
4444+4545+// Validate checks that all required OAuthCredentials fields are present and valid.
4646+// Returns an error describing the first validation failure, or nil if valid.
4747+func (c *OAuthCredentials) Validate() error {
4848+ if c.AccessToken == "" {
4949+ return NewValidationError("accessToken", "access token is required")
5050+ }
5151+ if c.RefreshToken == "" {
5252+ return NewValidationError("refreshToken", "refresh token is required")
5353+ }
5454+ if c.TokenExpiresAt.IsZero() {
5555+ return NewValidationError("tokenExpiresAt", "token expiry time is required")
5656+ }
5757+ if c.PDSURL == "" {
5858+ return NewValidationError("pdsUrl", "PDS URL is required")
5959+ }
6060+ if c.AuthServerIss == "" {
6161+ return NewValidationError("authServerIss", "auth server issuer is required")
6262+ }
6363+ if c.AuthServerTokenEndpoint == "" {
6464+ return NewValidationError("authServerTokenEndpoint", "auth server token endpoint is required")
6565+ }
6666+ if c.DPoPPrivateKeyMultibase == "" {
6767+ return NewValidationError("dpopPrivateKey", "DPoP private key is required")
6868+ }
6969+ return nil
7070+}
7171+7272+// AggregatorCredentials holds sensitive authentication data for aggregators.
7373+// This is the preferred type for authentication operations - separates concerns
7474+// from the public Aggregator type and prevents credential leakage.
7575+type AggregatorCredentials struct {
7676+ DID string `db:"did"`
7777+7878+ // API Key Authentication
7979+ APIKeyPrefix string `db:"api_key_prefix"`
8080+ APIKeyHash string `db:"api_key_hash"`
8181+ APIKeyCreatedAt *time.Time `db:"api_key_created_at"`
8282+ APIKeyRevokedAt *time.Time `db:"api_key_revoked_at"`
8383+ APIKeyLastUsed *time.Time `db:"api_key_last_used_at"`
8484+8585+ // OAuth Session Credentials
8686+ OAuthAccessToken string `db:"oauth_access_token"`
8787+ OAuthRefreshToken string `db:"oauth_refresh_token"`
8888+ OAuthTokenExpiresAt *time.Time `db:"oauth_token_expires_at"`
8989+ OAuthPDSURL string `db:"oauth_pds_url"`
9090+ OAuthAuthServerIss string `db:"oauth_auth_server_iss"`
9191+ OAuthAuthServerTokenEndpoint string `db:"oauth_auth_server_token_endpoint"`
9292+ OAuthDPoPPrivateKeyMultibase string `db:"oauth_dpop_private_key_multibase"`
9393+ OAuthDPoPAuthServerNonce string `db:"oauth_dpop_authserver_nonce"`
9494+ OAuthDPoPPDSNonce string `db:"oauth_dpop_pds_nonce"`
9595+}
9696+9797+// HasActiveAPIKey returns true if the credentials have an active (non-revoked) API key.
9898+// An active key has a non-empty hash and has not been revoked.
9999+func (c *AggregatorCredentials) HasActiveAPIKey() bool {
100100+ return c.APIKeyHash != "" && c.APIKeyRevokedAt == nil
101101+}
102102+103103+// IsOAuthTokenExpired returns true if the OAuth access token has expired or will expire soon.
104104+// Uses a 5-minute buffer before actual expiry to allow proactive token refresh,
105105+// accounting for clock skew and network latency during refresh operations.
106106+func (c *AggregatorCredentials) IsOAuthTokenExpired() bool {
107107+ if c.OAuthTokenExpiresAt == nil {
108108+ return true
109109+ }
110110+ return time.Now().Add(5 * time.Minute).After(*c.OAuthTokenExpiresAt)
22111}
2311224113// Authorization represents a community's authorization for an aggregator
+373
internal/core/aggregators/apikey_service.go
···11+package aggregators
22+33+import (
44+ "context"
55+ "crypto/rand"
66+ "crypto/sha256"
77+ "encoding/hex"
88+ "errors"
99+ "fmt"
1010+ "log/slog"
1111+ "sync/atomic"
1212+ "time"
1313+1414+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1515+ "github.com/bluesky-social/indigo/atproto/syntax"
1616+)
1717+1818+const (
1919+ // APIKeyPrefix is the prefix for all Coves API keys
2020+ APIKeyPrefix = "ckapi_"
2121+ // APIKeyRandomBytes is the number of random bytes in the key (32 bytes = 256 bits)
2222+ APIKeyRandomBytes = 32
2323+ // APIKeyTotalLength is the total length of the API key including prefix
2424+ // 6 (prefix "ckapi_") + 64 (32 bytes hex-encoded) = 70
2525+ APIKeyTotalLength = 70
2626+ // TokenRefreshBuffer is how long before expiry we should refresh tokens
2727+ TokenRefreshBuffer = 5 * time.Minute
2828+ // DefaultSessionID is used for API key sessions since aggregators have a single session
2929+ DefaultSessionID = "apikey"
3030+)
3131+3232+// APIKeyService handles API key generation, validation, and OAuth token management
3333+// for aggregator authentication.
3434+type APIKeyService struct {
3535+ repo Repository
3636+ oauthApp *oauth.ClientApp // For resuming sessions and refreshing tokens
3737+3838+ // failedLastUsedUpdates tracks the number of failed API key last_used timestamp updates.
3939+ // This counter provides visibility into persistent DB issues that would otherwise be hidden
4040+ // since the update is done asynchronously. Use GetFailedLastUsedUpdates() to read.
4141+ failedLastUsedUpdates atomic.Int64
4242+4343+ // failedNonceUpdates tracks the number of failed OAuth nonce updates.
4444+ // Nonce failures may indicate DB issues and could lead to DPoP replay protection issues.
4545+ // Use GetFailedNonceUpdates() to read.
4646+ failedNonceUpdates atomic.Int64
4747+}
4848+4949+// NewAPIKeyService creates a new API key service.
5050+// Panics if repo or oauthApp are nil, as these are required dependencies.
5151+func NewAPIKeyService(repo Repository, oauthApp *oauth.ClientApp) *APIKeyService {
5252+ if repo == nil {
5353+ panic("aggregators.NewAPIKeyService: repo cannot be nil")
5454+ }
5555+ if oauthApp == nil {
5656+ panic("aggregators.NewAPIKeyService: oauthApp cannot be nil")
5757+ }
5858+ return &APIKeyService{
5959+ repo: repo,
6060+ oauthApp: oauthApp,
6161+ }
6262+}
6363+6464+// GenerateKey creates a new API key for an aggregator.
6565+// The aggregator must have completed OAuth authentication first.
6666+// Returns the plain-text key (only shown once) and the key prefix for reference.
6767+func (s *APIKeyService) GenerateKey(ctx context.Context, aggregatorDID string, oauthSession *oauth.ClientSessionData) (plainKey string, keyPrefix string, err error) {
6868+ // Validate aggregator exists
6969+ aggregator, err := s.repo.GetAggregator(ctx, aggregatorDID)
7070+ if err != nil {
7171+ return "", "", fmt.Errorf("failed to get aggregator: %w", err)
7272+ }
7373+7474+ // Validate OAuth session matches the aggregator
7575+ if oauthSession.AccountDID.String() != aggregatorDID {
7676+ return "", "", ErrOAuthSessionMismatch
7777+ }
7878+7979+ // Generate random key
8080+ randomBytes := make([]byte, APIKeyRandomBytes)
8181+ if _, err := rand.Read(randomBytes); err != nil {
8282+ return "", "", fmt.Errorf("failed to generate random key: %w", err)
8383+ }
8484+ randomHex := hex.EncodeToString(randomBytes)
8585+ plainKey = APIKeyPrefix + randomHex
8686+8787+ // Create key prefix (first 12 chars including prefix for identification)
8888+ keyPrefix = plainKey[:12]
8989+9090+ // Hash the key for storage (SHA-256)
9191+ keyHash := hashAPIKey(plainKey)
9292+9393+ // Extract OAuth credentials from session
9494+ // Note: ClientSessionData doesn't store token expiry from the OAuth response.
9595+ // We use a 1-hour default which matches typical OAuth access token lifetimes.
9696+ // Token refresh happens proactively before expiry via RefreshTokensIfNeeded.
9797+ tokenExpiry := time.Now().Add(1 * time.Hour)
9898+ oauthCreds := &OAuthCredentials{
9999+ AccessToken: oauthSession.AccessToken,
100100+ RefreshToken: oauthSession.RefreshToken,
101101+ TokenExpiresAt: tokenExpiry,
102102+ PDSURL: oauthSession.HostURL,
103103+ AuthServerIss: oauthSession.AuthServerURL,
104104+ AuthServerTokenEndpoint: oauthSession.AuthServerTokenEndpoint,
105105+ DPoPPrivateKeyMultibase: oauthSession.DPoPPrivateKeyMultibase,
106106+ DPoPAuthServerNonce: oauthSession.DPoPAuthServerNonce,
107107+ DPoPPDSNonce: oauthSession.DPoPHostNonce,
108108+ }
109109+110110+ // Validate OAuth credentials before proceeding
111111+ if err := oauthCreds.Validate(); err != nil {
112112+ return "", "", fmt.Errorf("invalid OAuth credentials: %w", err)
113113+ }
114114+115115+ // Store the OAuth session in the store FIRST (before API key)
116116+ // This prevents a race condition where the API key exists but can't refresh tokens.
117117+ // Order: OAuth session → API key (if session fails, no dangling API key)
118118+ apiKeySession := *oauthSession // Copy session data
119119+ apiKeySession.SessionID = DefaultSessionID
120120+ if err := s.oauthApp.Store.SaveSession(ctx, apiKeySession); err != nil {
121121+ slog.Error("failed to store OAuth session for API key - aborting key creation",
122122+ "did", aggregatorDID,
123123+ "error", err,
124124+ )
125125+ return "", "", fmt.Errorf("failed to store OAuth session for token refresh: %w", err)
126126+ }
127127+128128+ // Now store key hash and OAuth credentials in aggregators table
129129+ // If this fails, we have an orphaned OAuth session, but that's less problematic
130130+ // than having an API key that can't refresh tokens.
131131+ if err := s.repo.SetAPIKey(ctx, aggregatorDID, keyPrefix, keyHash, oauthCreds); err != nil {
132132+ // Best effort cleanup of the OAuth session we just stored
133133+ if deleteErr := s.oauthApp.Store.DeleteSession(ctx, oauthSession.AccountDID, DefaultSessionID); deleteErr != nil {
134134+ slog.Warn("failed to cleanup OAuth session after API key storage failure",
135135+ "did", aggregatorDID,
136136+ "error", deleteErr,
137137+ )
138138+ }
139139+ return "", "", fmt.Errorf("failed to store API key: %w", err)
140140+ }
141141+142142+ slog.Info("API key generated for aggregator",
143143+ "did", aggregatorDID,
144144+ "display_name", aggregator.DisplayName,
145145+ "key_prefix", keyPrefix,
146146+ )
147147+148148+ return plainKey, keyPrefix, nil
149149+}
150150+151151+// ValidateKey validates an API key and returns the associated aggregator credentials.
152152+// Returns ErrAPIKeyInvalid if the key is not found or revoked.
153153+func (s *APIKeyService) ValidateKey(ctx context.Context, plainKey string) (*AggregatorCredentials, error) {
154154+ // Validate key format - log invalid attempts for security monitoring
155155+ if len(plainKey) != APIKeyTotalLength || plainKey[:6] != APIKeyPrefix {
156156+ // Log for security monitoring (potential brute-force detection)
157157+ // Don't log the full key, just metadata about the attempt
158158+ slog.Warn("[SECURITY] invalid API key format attempt",
159159+ "key_length", len(plainKey),
160160+ "has_valid_prefix", len(plainKey) >= 6 && plainKey[:6] == APIKeyPrefix,
161161+ )
162162+ return nil, ErrAPIKeyInvalid
163163+ }
164164+165165+ // Hash the provided key
166166+ keyHash := hashAPIKey(plainKey)
167167+168168+ // Look up aggregator credentials by hash
169169+ creds, err := s.repo.GetCredentialsByAPIKeyHash(ctx, keyHash)
170170+ if err != nil {
171171+ if IsNotFound(err) {
172172+ return nil, ErrAPIKeyInvalid
173173+ }
174174+ // Check for revoked API key (returned by repo when api_key_revoked_at is set)
175175+ if errors.Is(err, ErrAPIKeyRevoked) {
176176+ slog.Warn("revoked API key used",
177177+ "key_hash_prefix", keyHash[:8],
178178+ )
179179+ return nil, ErrAPIKeyRevoked
180180+ }
181181+ return nil, fmt.Errorf("failed to lookup API key: %w", err)
182182+ }
183183+184184+ // Update last used timestamp (async, don't block on error)
185185+ // Use a bounded timeout to prevent goroutine accumulation if DB is slow/down
186186+ // Extract trace info from context before spawning goroutine for log correlation
187187+ aggregatorDID := creds.DID // capture for goroutine
188188+ go func() {
189189+ updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
190190+ defer cancel()
191191+192192+ if updateErr := s.repo.UpdateAPIKeyLastUsed(updateCtx, aggregatorDID); updateErr != nil {
193193+ // Increment failure counter for monitoring visibility
194194+ failCount := s.failedLastUsedUpdates.Add(1)
195195+ slog.Error("failed to update API key last used",
196196+ "did", aggregatorDID,
197197+ "error", updateErr,
198198+ "total_failures", failCount,
199199+ )
200200+ }
201201+ }()
202202+203203+ return creds, nil
204204+}
205205+206206+// RefreshTokensIfNeeded checks if the OAuth tokens are expired or expiring soon,
207207+// and refreshes them if necessary.
208208+func (s *APIKeyService) RefreshTokensIfNeeded(ctx context.Context, creds *AggregatorCredentials) error {
209209+ // Check if tokens need refresh
210210+ if creds.OAuthTokenExpiresAt != nil {
211211+ if time.Until(*creds.OAuthTokenExpiresAt) > TokenRefreshBuffer {
212212+ // Tokens still valid
213213+ return nil
214214+ }
215215+ }
216216+217217+ // Need to refresh tokens
218218+ slog.Info("refreshing OAuth tokens for aggregator",
219219+ "did", creds.DID,
220220+ "expires_at", creds.OAuthTokenExpiresAt,
221221+ )
222222+223223+ // Parse DID
224224+ did, err := syntax.ParseDID(creds.DID)
225225+ if err != nil {
226226+ return fmt.Errorf("failed to parse aggregator DID: %w", err)
227227+ }
228228+229229+ // Resume the OAuth session from the store
230230+ // The session was stored when the aggregator created their API key
231231+ session, err := s.oauthApp.ResumeSession(ctx, did, DefaultSessionID)
232232+ if err != nil {
233233+ slog.Error("failed to resume OAuth session for token refresh",
234234+ "did", creds.DID,
235235+ "error", err,
236236+ )
237237+ return fmt.Errorf("failed to resume session: %w", err)
238238+ }
239239+240240+ // Refresh tokens using indigo's OAuth library
241241+ newAccessToken, err := session.RefreshTokens(ctx)
242242+ if err != nil {
243243+ slog.Error("failed to refresh OAuth tokens",
244244+ "did", creds.DID,
245245+ "error", err,
246246+ )
247247+ return fmt.Errorf("failed to refresh tokens: %w", err)
248248+ }
249249+250250+ // Note: ClientSessionData doesn't store token expiry from the OAuth response.
251251+ // We use a 1-hour default which matches typical OAuth access token lifetimes.
252252+ newExpiry := time.Now().Add(1 * time.Hour)
253253+254254+ // Update tokens in database
255255+ if err := s.repo.UpdateOAuthTokens(ctx, creds.DID, newAccessToken, session.Data.RefreshToken, newExpiry); err != nil {
256256+ return fmt.Errorf("failed to update tokens: %w", err)
257257+ }
258258+259259+ // Update nonces in our database as a secondary copy for visibility/backup.
260260+ // The authoritative nonces are in indigo's OAuth store (via SaveSession above).
261261+ // Session resumption uses s.oauthApp.ResumeSession which reads from indigo's store,
262262+ // so this failure is non-critical - hence warning level, not error.
263263+ if err := s.repo.UpdateOAuthNonces(ctx, creds.DID, session.Data.DPoPAuthServerNonce, session.Data.DPoPHostNonce); err != nil {
264264+ failCount := s.failedNonceUpdates.Add(1)
265265+ slog.Warn("failed to update OAuth nonces in aggregators table",
266266+ "did", creds.DID,
267267+ "error", err,
268268+ "total_failures", failCount,
269269+ )
270270+ }
271271+272272+ // Update credentials in memory
273273+ creds.OAuthAccessToken = newAccessToken
274274+ creds.OAuthRefreshToken = session.Data.RefreshToken
275275+ creds.OAuthTokenExpiresAt = &newExpiry
276276+ creds.OAuthDPoPAuthServerNonce = session.Data.DPoPAuthServerNonce
277277+ creds.OAuthDPoPPDSNonce = session.Data.DPoPHostNonce
278278+279279+ slog.Info("OAuth tokens refreshed for aggregator",
280280+ "did", creds.DID,
281281+ "new_expires_at", newExpiry,
282282+ )
283283+284284+ return nil
285285+}
286286+287287+// GetAccessToken returns a valid access token for the aggregator,
288288+// refreshing if necessary.
289289+func (s *APIKeyService) GetAccessToken(ctx context.Context, creds *AggregatorCredentials) (string, error) {
290290+ // Ensure tokens are fresh
291291+ if err := s.RefreshTokensIfNeeded(ctx, creds); err != nil {
292292+ return "", fmt.Errorf("failed to ensure fresh tokens: %w", err)
293293+ }
294294+295295+ return creds.OAuthAccessToken, nil
296296+}
297297+298298+// RevokeKey revokes an API key for an aggregator.
299299+// After revocation, the aggregator must complete OAuth flow again to get a new key.
300300+func (s *APIKeyService) RevokeKey(ctx context.Context, aggregatorDID string) error {
301301+ if err := s.repo.RevokeAPIKey(ctx, aggregatorDID); err != nil {
302302+ return fmt.Errorf("failed to revoke API key: %w", err)
303303+ }
304304+305305+ slog.Info("API key revoked for aggregator",
306306+ "did", aggregatorDID,
307307+ )
308308+309309+ return nil
310310+}
311311+312312+// GetAggregator retrieves the public aggregator information by DID.
313313+// For credential/authentication data, use GetAggregatorCredentials instead.
314314+func (s *APIKeyService) GetAggregator(ctx context.Context, aggregatorDID string) (*Aggregator, error) {
315315+ return s.repo.GetAggregator(ctx, aggregatorDID)
316316+}
317317+318318+// GetAggregatorCredentials retrieves credentials for an aggregator by DID.
319319+func (s *APIKeyService) GetAggregatorCredentials(ctx context.Context, aggregatorDID string) (*AggregatorCredentials, error) {
320320+ return s.repo.GetAggregatorCredentials(ctx, aggregatorDID)
321321+}
322322+323323+// GetAPIKeyInfo returns information about an aggregator's API key (without the actual key).
324324+func (s *APIKeyService) GetAPIKeyInfo(ctx context.Context, aggregatorDID string) (*APIKeyInfo, error) {
325325+ creds, err := s.repo.GetAggregatorCredentials(ctx, aggregatorDID)
326326+ if err != nil {
327327+ return nil, err
328328+ }
329329+330330+ if creds.APIKeyHash == "" {
331331+ return &APIKeyInfo{
332332+ HasKey: false,
333333+ }, nil
334334+ }
335335+336336+ return &APIKeyInfo{
337337+ HasKey: true,
338338+ KeyPrefix: creds.APIKeyPrefix,
339339+ CreatedAt: creds.APIKeyCreatedAt,
340340+ LastUsedAt: creds.APIKeyLastUsed,
341341+ IsRevoked: creds.APIKeyRevokedAt != nil,
342342+ RevokedAt: creds.APIKeyRevokedAt,
343343+ }, nil
344344+}
345345+346346+// APIKeyInfo contains non-sensitive information about an API key
347347+type APIKeyInfo struct {
348348+ HasKey bool
349349+ KeyPrefix string
350350+ CreatedAt *time.Time
351351+ LastUsedAt *time.Time
352352+ IsRevoked bool
353353+ RevokedAt *time.Time
354354+}
355355+356356+// hashAPIKey creates a SHA-256 hash of the API key for storage
357357+func hashAPIKey(plainKey string) string {
358358+ hash := sha256.Sum256([]byte(plainKey))
359359+ return hex.EncodeToString(hash[:])
360360+}
361361+362362+// GetFailedLastUsedUpdates returns the count of failed API key last_used timestamp updates.
363363+// This is useful for monitoring and alerting on persistent database issues.
364364+func (s *APIKeyService) GetFailedLastUsedUpdates() int64 {
365365+ return s.failedLastUsedUpdates.Load()
366366+}
367367+368368+// GetFailedNonceUpdates returns the count of failed OAuth nonce updates.
369369+// This is useful for monitoring and alerting on persistent database issues
370370+// that could affect DPoP replay protection.
371371+func (s *APIKeyService) GetFailedNonceUpdates() int64 {
372372+ return s.failedNonceUpdates.Load()
373373+}
+1143
internal/core/aggregators/apikey_service_test.go
···11+package aggregators
22+33+import (
44+ "context"
55+ "crypto/sha256"
66+ "encoding/hex"
77+ "errors"
88+ "testing"
99+ "time"
1010+1111+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1212+ "github.com/bluesky-social/indigo/atproto/syntax"
1313+)
1414+1515+// ptrTime returns a pointer to a time.Time (current time)
1616+func ptrTime() *time.Time {
1717+ t := time.Now()
1818+ return &t
1919+}
2020+2121+// ptrTimeOffset returns a pointer to a time.Time offset from now
2222+func ptrTimeOffset(d time.Duration) *time.Time {
2323+ t := time.Now().Add(d)
2424+ return &t
2525+}
2626+2727+// newTestAPIKeyService creates an APIKeyService with mock dependencies for testing.
2828+// This helper ensures tests don't panic from nil checks added in constructor validation.
2929+func newTestAPIKeyService(repo Repository) *APIKeyService {
3030+ mockStore := &mockOAuthStore{}
3131+ mockApp := &oauth.ClientApp{Store: mockStore}
3232+ return NewAPIKeyService(repo, mockApp)
3333+}
3434+3535+// mockRepository implements Repository interface for testing
3636+type mockRepository struct {
3737+ getAggregatorFunc func(ctx context.Context, did string) (*Aggregator, error)
3838+ getByAPIKeyHashFunc func(ctx context.Context, keyHash string) (*Aggregator, error)
3939+ getCredentialsByAPIKeyHashFunc func(ctx context.Context, keyHash string) (*AggregatorCredentials, error)
4040+ getAggregatorCredentialsFunc func(ctx context.Context, did string) (*AggregatorCredentials, error)
4141+ setAPIKeyFunc func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error
4242+ updateOAuthTokensFunc func(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error
4343+ updateOAuthNoncesFunc func(ctx context.Context, did, authServerNonce, pdsNonce string) error
4444+ updateAPIKeyLastUsedFunc func(ctx context.Context, did string) error
4545+ revokeAPIKeyFunc func(ctx context.Context, did string) error
4646+}
4747+4848+func (m *mockRepository) GetAggregator(ctx context.Context, did string) (*Aggregator, error) {
4949+ if m.getAggregatorFunc != nil {
5050+ return m.getAggregatorFunc(ctx, did)
5151+ }
5252+ return &Aggregator{DID: did, DisplayName: "Test Aggregator"}, nil
5353+}
5454+5555+func (m *mockRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*Aggregator, error) {
5656+ if m.getByAPIKeyHashFunc != nil {
5757+ return m.getByAPIKeyHashFunc(ctx, keyHash)
5858+ }
5959+ return nil, ErrAggregatorNotFound
6060+}
6161+6262+func (m *mockRepository) SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error {
6363+ if m.setAPIKeyFunc != nil {
6464+ return m.setAPIKeyFunc(ctx, did, keyPrefix, keyHash, oauthCreds)
6565+ }
6666+ return nil
6767+}
6868+6969+func (m *mockRepository) UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error {
7070+ if m.updateOAuthTokensFunc != nil {
7171+ return m.updateOAuthTokensFunc(ctx, did, accessToken, refreshToken, expiresAt)
7272+ }
7373+ return nil
7474+}
7575+7676+func (m *mockRepository) UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error {
7777+ if m.updateOAuthNoncesFunc != nil {
7878+ return m.updateOAuthNoncesFunc(ctx, did, authServerNonce, pdsNonce)
7979+ }
8080+ return nil
8181+}
8282+8383+func (m *mockRepository) UpdateAPIKeyLastUsed(ctx context.Context, did string) error {
8484+ if m.updateAPIKeyLastUsedFunc != nil {
8585+ return m.updateAPIKeyLastUsedFunc(ctx, did)
8686+ }
8787+ return nil
8888+}
8989+9090+func (m *mockRepository) RevokeAPIKey(ctx context.Context, did string) error {
9191+ if m.revokeAPIKeyFunc != nil {
9292+ return m.revokeAPIKeyFunc(ctx, did)
9393+ }
9494+ return nil
9595+}
9696+9797+// Stub implementations for Repository interface methods not used in APIKeyService tests
9898+func (m *mockRepository) CreateAggregator(ctx context.Context, aggregator *Aggregator) error {
9999+ return nil
100100+}
101101+102102+func (m *mockRepository) GetAggregatorsByDIDs(ctx context.Context, dids []string) ([]*Aggregator, error) {
103103+ return nil, nil
104104+}
105105+106106+func (m *mockRepository) UpdateAggregator(ctx context.Context, aggregator *Aggregator) error {
107107+ return nil
108108+}
109109+110110+func (m *mockRepository) DeleteAggregator(ctx context.Context, did string) error {
111111+ return nil
112112+}
113113+114114+func (m *mockRepository) ListAggregators(ctx context.Context, limit, offset int) ([]*Aggregator, error) {
115115+ return nil, nil
116116+}
117117+118118+func (m *mockRepository) IsAggregator(ctx context.Context, did string) (bool, error) {
119119+ return false, nil
120120+}
121121+122122+func (m *mockRepository) CreateAuthorization(ctx context.Context, auth *Authorization) error {
123123+ return nil
124124+}
125125+126126+func (m *mockRepository) GetAuthorization(ctx context.Context, aggregatorDID, communityDID string) (*Authorization, error) {
127127+ return nil, nil
128128+}
129129+130130+func (m *mockRepository) GetAuthorizationByURI(ctx context.Context, recordURI string) (*Authorization, error) {
131131+ return nil, nil
132132+}
133133+134134+func (m *mockRepository) UpdateAuthorization(ctx context.Context, auth *Authorization) error {
135135+ return nil
136136+}
137137+138138+func (m *mockRepository) DeleteAuthorization(ctx context.Context, aggregatorDID, communityDID string) error {
139139+ return nil
140140+}
141141+142142+func (m *mockRepository) DeleteAuthorizationByURI(ctx context.Context, recordURI string) error {
143143+ return nil
144144+}
145145+146146+func (m *mockRepository) ListAuthorizationsForAggregator(ctx context.Context, aggregatorDID string, enabledOnly bool, limit, offset int) ([]*Authorization, error) {
147147+ return nil, nil
148148+}
149149+150150+func (m *mockRepository) ListAuthorizationsForCommunity(ctx context.Context, communityDID string, enabledOnly bool, limit, offset int) ([]*Authorization, error) {
151151+ return nil, nil
152152+}
153153+154154+func (m *mockRepository) IsAuthorized(ctx context.Context, aggregatorDID, communityDID string) (bool, error) {
155155+ return false, nil
156156+}
157157+158158+func (m *mockRepository) RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error {
159159+ return nil
160160+}
161161+162162+func (m *mockRepository) CountRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) (int, error) {
163163+ return 0, nil
164164+}
165165+166166+func (m *mockRepository) GetRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) ([]*AggregatorPost, error) {
167167+ return nil, nil
168168+}
169169+170170+func (m *mockRepository) GetAggregatorCredentials(ctx context.Context, did string) (*AggregatorCredentials, error) {
171171+ if m.getAggregatorCredentialsFunc != nil {
172172+ return m.getAggregatorCredentialsFunc(ctx, did)
173173+ }
174174+ return &AggregatorCredentials{DID: did}, nil
175175+}
176176+177177+func (m *mockRepository) GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*AggregatorCredentials, error) {
178178+ if m.getCredentialsByAPIKeyHashFunc != nil {
179179+ return m.getCredentialsByAPIKeyHashFunc(ctx, keyHash)
180180+ }
181181+ return nil, ErrAggregatorNotFound
182182+}
183183+184184+func TestHashAPIKey(t *testing.T) {
185185+ plainKey := "ckapi_abcdef1234567890abcdef1234567890"
186186+187187+ // Hash the key
188188+ hash := hashAPIKey(plainKey)
189189+190190+ // Verify it's a valid hex string
191191+ if len(hash) != 64 {
192192+ t.Errorf("Expected 64 character hash, got %d", len(hash))
193193+ }
194194+195195+ // Verify it's consistent
196196+ hash2 := hashAPIKey(plainKey)
197197+ if hash != hash2 {
198198+ t.Error("Hash function should be deterministic")
199199+ }
200200+201201+ // Verify different keys produce different hashes
202202+ differentKey := "ckapi_different1234567890abcdef12"
203203+ differentHash := hashAPIKey(differentKey)
204204+ if hash == differentHash {
205205+ t.Error("Different keys should produce different hashes")
206206+ }
207207+208208+ // Verify manually
209209+ expectedHash := sha256.Sum256([]byte(plainKey))
210210+ expectedHex := hex.EncodeToString(expectedHash[:])
211211+ if hash != expectedHex {
212212+ t.Errorf("Expected %s, got %s", expectedHex, hash)
213213+ }
214214+}
215215+216216+func TestAPIKeyConstants(t *testing.T) {
217217+ // Verify the key prefix length assumption
218218+ if len(APIKeyPrefix) != 6 {
219219+ t.Errorf("Expected APIKeyPrefix to be 6 chars, got %d", len(APIKeyPrefix))
220220+ }
221221+222222+ // Verify total length calculation
223223+ // Random bytes are hex-encoded, so they double in length (32 bytes -> 64 chars)
224224+ expectedLength := len(APIKeyPrefix) + (APIKeyRandomBytes * 2)
225225+ if APIKeyTotalLength != expectedLength {
226226+ t.Errorf("APIKeyTotalLength should be %d (prefix + hex-encoded random), got %d", expectedLength, APIKeyTotalLength)
227227+ }
228228+229229+ // Verify expected values explicitly
230230+ if APIKeyTotalLength != 70 {
231231+ t.Errorf("APIKeyTotalLength should be 70 (6 prefix + 64 hex chars), got %d", APIKeyTotalLength)
232232+ }
233233+}
234234+235235+func TestValidateKey_FormatValidation(t *testing.T) {
236236+ // We can't test the full ValidateKey without mocking, but we can verify
237237+ // the format validation logic by checking the constants
238238+ // 32 random bytes hex-encoded = 64 characters
239239+ testKey := "ckapi_" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
240240+ if len(testKey) != APIKeyTotalLength {
241241+ t.Errorf("Test key length mismatch: expected %d, got %d", APIKeyTotalLength, len(testKey))
242242+ }
243243+244244+ // Test key should start with prefix
245245+ if testKey[:6] != APIKeyPrefix {
246246+ t.Errorf("Test key should start with %s", APIKeyPrefix)
247247+ }
248248+249249+ // Verify key length is 70 characters
250250+ if len(testKey) != 70 {
251251+ t.Errorf("Test key should be 70 characters, got %d", len(testKey))
252252+ }
253253+}
254254+255255+// =============================================================================
256256+// AggregatorCredentials Tests
257257+// =============================================================================
258258+259259+func TestAggregatorCredentials_HasActiveAPIKey(t *testing.T) {
260260+ tests := []struct {
261261+ name string
262262+ creds AggregatorCredentials
263263+ wantActive bool
264264+ }{
265265+ {
266266+ name: "no key hash",
267267+ creds: AggregatorCredentials{},
268268+ wantActive: false,
269269+ },
270270+ {
271271+ name: "has key hash, not revoked",
272272+ creds: AggregatorCredentials{APIKeyHash: "somehash"},
273273+ wantActive: true,
274274+ },
275275+ {
276276+ name: "has key hash, revoked",
277277+ creds: AggregatorCredentials{
278278+ APIKeyHash: "somehash",
279279+ APIKeyRevokedAt: ptrTime(),
280280+ },
281281+ wantActive: false,
282282+ },
283283+ }
284284+285285+ for _, tt := range tests {
286286+ t.Run(tt.name, func(t *testing.T) {
287287+ got := tt.creds.HasActiveAPIKey()
288288+ if got != tt.wantActive {
289289+ t.Errorf("HasActiveAPIKey() = %v, want %v", got, tt.wantActive)
290290+ }
291291+ })
292292+ }
293293+}
294294+295295+func TestAggregatorCredentials_IsOAuthTokenExpired(t *testing.T) {
296296+ tests := []struct {
297297+ name string
298298+ creds AggregatorCredentials
299299+ wantExpired bool
300300+ }{
301301+ {
302302+ name: "nil expiry",
303303+ creds: AggregatorCredentials{},
304304+ wantExpired: true,
305305+ },
306306+ {
307307+ name: "expired in the past",
308308+ creds: AggregatorCredentials{
309309+ OAuthTokenExpiresAt: ptrTimeOffset(-1 * time.Hour),
310310+ },
311311+ wantExpired: true,
312312+ },
313313+ {
314314+ name: "within 5 minute buffer (4 minutes remaining)",
315315+ creds: AggregatorCredentials{
316316+ OAuthTokenExpiresAt: ptrTimeOffset(4 * time.Minute),
317317+ },
318318+ wantExpired: true, // Should be expired because within buffer
319319+ },
320320+ {
321321+ name: "exactly at 5 minute buffer",
322322+ creds: AggregatorCredentials{
323323+ OAuthTokenExpiresAt: ptrTimeOffset(5 * time.Minute),
324324+ },
325325+ wantExpired: true, // Edge case - at exactly buffer time
326326+ },
327327+ {
328328+ name: "beyond 5 minute buffer (6 minutes remaining)",
329329+ creds: AggregatorCredentials{
330330+ OAuthTokenExpiresAt: ptrTimeOffset(6 * time.Minute),
331331+ },
332332+ wantExpired: false, // Should not be expired
333333+ },
334334+ {
335335+ name: "well beyond buffer (1 hour remaining)",
336336+ creds: AggregatorCredentials{
337337+ OAuthTokenExpiresAt: ptrTimeOffset(1 * time.Hour),
338338+ },
339339+ wantExpired: false,
340340+ },
341341+ }
342342+343343+ for _, tt := range tests {
344344+ t.Run(tt.name, func(t *testing.T) {
345345+ got := tt.creds.IsOAuthTokenExpired()
346346+ if got != tt.wantExpired {
347347+ t.Errorf("IsOAuthTokenExpired() = %v, want %v", got, tt.wantExpired)
348348+ }
349349+ })
350350+ }
351351+}
352352+353353+// =============================================================================
354354+// ValidateKey Tests
355355+// =============================================================================
356356+357357+func TestAPIKeyService_ValidateKey_InvalidFormat(t *testing.T) {
358358+ repo := &mockRepository{}
359359+ service := newTestAPIKeyService(repo)
360360+361361+ tests := []struct {
362362+ name string
363363+ key string
364364+ wantErr error
365365+ }{
366366+ {
367367+ name: "empty key",
368368+ key: "",
369369+ wantErr: ErrAPIKeyInvalid,
370370+ },
371371+ {
372372+ name: "too short",
373373+ key: "ckapi_short",
374374+ wantErr: ErrAPIKeyInvalid,
375375+ },
376376+ {
377377+ name: "wrong prefix",
378378+ key: "wrong_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
379379+ wantErr: ErrAPIKeyInvalid,
380380+ },
381381+ {
382382+ name: "correct length but wrong prefix",
383383+ key: "badpfx0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
384384+ wantErr: ErrAPIKeyInvalid,
385385+ },
386386+ }
387387+388388+ for _, tt := range tests {
389389+ t.Run(tt.name, func(t *testing.T) {
390390+ _, err := service.ValidateKey(context.Background(), tt.key)
391391+ if !errors.Is(err, tt.wantErr) {
392392+ t.Errorf("ValidateKey() error = %v, want %v", err, tt.wantErr)
393393+ }
394394+ })
395395+ }
396396+}
397397+398398+func TestAPIKeyService_ValidateKey_NotFound(t *testing.T) {
399399+ repo := &mockRepository{
400400+ getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) {
401401+ return nil, ErrAggregatorNotFound
402402+ },
403403+ }
404404+ service := newTestAPIKeyService(repo)
405405+406406+ // Valid format but key not in database
407407+ validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
408408+ _, err := service.ValidateKey(context.Background(), validKey)
409409+ if !errors.Is(err, ErrAPIKeyInvalid) {
410410+ t.Errorf("ValidateKey() error = %v, want %v", err, ErrAPIKeyInvalid)
411411+ }
412412+}
413413+414414+func TestAPIKeyService_ValidateKey_Revoked(t *testing.T) {
415415+ // The current implementation expects the repository to return ErrAPIKeyRevoked
416416+ // when the API key has been revoked. This is done at the repository layer.
417417+ repo := &mockRepository{
418418+ getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) {
419419+ // Repository returns error for revoked keys
420420+ return nil, ErrAPIKeyRevoked
421421+ },
422422+ }
423423+ service := newTestAPIKeyService(repo)
424424+425425+ validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
426426+ _, err := service.ValidateKey(context.Background(), validKey)
427427+ if !errors.Is(err, ErrAPIKeyRevoked) {
428428+ t.Errorf("ValidateKey() error = %v, want %v", err, ErrAPIKeyRevoked)
429429+ }
430430+}
431431+432432+func TestAPIKeyService_ValidateKey_Success(t *testing.T) {
433433+ expectedDID := "did:plc:aggregator123"
434434+ lastUsedChan := make(chan struct{})
435435+436436+ repo := &mockRepository{
437437+ getCredentialsByAPIKeyHashFunc: func(ctx context.Context, keyHash string) (*AggregatorCredentials, error) {
438438+ return &AggregatorCredentials{
439439+ DID: expectedDID,
440440+ APIKeyHash: keyHash,
441441+ APIKeyPrefix: "ckapi_0123",
442442+ }, nil
443443+ },
444444+ updateAPIKeyLastUsedFunc: func(ctx context.Context, did string) error {
445445+ close(lastUsedChan)
446446+ return nil
447447+ },
448448+ }
449449+ service := newTestAPIKeyService(repo)
450450+451451+ validKey := "ckapi_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
452452+ creds, err := service.ValidateKey(context.Background(), validKey)
453453+ if err != nil {
454454+ t.Fatalf("ValidateKey() unexpected error: %v", err)
455455+ }
456456+457457+ if creds.DID != expectedDID {
458458+ t.Errorf("ValidateKey() DID = %s, want %s", creds.DID, expectedDID)
459459+ }
460460+461461+ // Wait for async update with timeout using channel-based synchronization
462462+ select {
463463+ case <-lastUsedChan:
464464+ // Success - UpdateAPIKeyLastUsed was called
465465+ case <-time.After(1 * time.Second):
466466+ t.Error("Expected UpdateAPIKeyLastUsed to be called (timeout)")
467467+ }
468468+}
469469+470470+// =============================================================================
471471+// GenerateKey Tests
472472+// =============================================================================
473473+474474+func TestAPIKeyService_GenerateKey_AggregatorNotFound(t *testing.T) {
475475+ repo := &mockRepository{
476476+ getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) {
477477+ return nil, ErrAggregatorNotFound
478478+ },
479479+ }
480480+ service := newTestAPIKeyService(repo)
481481+482482+ did, _ := syntax.ParseDID("did:plc:test123")
483483+ session := &oauth.ClientSessionData{
484484+ AccountDID: did,
485485+ AccessToken: "test_token",
486486+ }
487487+488488+ _, _, err := service.GenerateKey(context.Background(), "did:plc:test123", session)
489489+ if err == nil {
490490+ t.Error("GenerateKey() expected error, got nil")
491491+ }
492492+}
493493+494494+func TestAPIKeyService_GenerateKey_DIDMismatch(t *testing.T) {
495495+ repo := &mockRepository{
496496+ getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) {
497497+ return &Aggregator{DID: did}, nil
498498+ },
499499+ }
500500+ service := newTestAPIKeyService(repo)
501501+502502+ // Session DID doesn't match requested aggregator DID
503503+ sessionDID, _ := syntax.ParseDID("did:plc:different")
504504+ session := &oauth.ClientSessionData{
505505+ AccountDID: sessionDID,
506506+ AccessToken: "test_token",
507507+ }
508508+509509+ _, _, err := service.GenerateKey(context.Background(), "did:plc:aggregator123", session)
510510+ if err == nil {
511511+ t.Error("GenerateKey() expected DID mismatch error, got nil")
512512+ }
513513+ if !errors.Is(err, nil) && err.Error() == "" {
514514+ // Just check there's an error for DID mismatch
515515+ }
516516+}
517517+518518+func TestAPIKeyService_GenerateKey_SetAPIKeyError(t *testing.T) {
519519+ expectedError := errors.New("database error")
520520+ repo := &mockRepository{
521521+ getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) {
522522+ return &Aggregator{DID: did, DisplayName: "Test"}, nil
523523+ },
524524+ setAPIKeyFunc: func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error {
525525+ return expectedError
526526+ },
527527+ }
528528+529529+ // Create a minimal mock OAuth store
530530+ mockStore := &mockOAuthStore{}
531531+ mockApp := &oauth.ClientApp{Store: mockStore}
532532+533533+ service := NewAPIKeyService(repo, mockApp)
534534+535535+ did, _ := syntax.ParseDID("did:plc:aggregator123")
536536+ session := &oauth.ClientSessionData{
537537+ AccountDID: did,
538538+ AccessToken: "test_token",
539539+ }
540540+541541+ _, _, err := service.GenerateKey(context.Background(), "did:plc:aggregator123", session)
542542+ if err == nil {
543543+ t.Error("GenerateKey() expected error, got nil")
544544+ }
545545+}
546546+547547+func TestAPIKeyService_GenerateKey_Success(t *testing.T) {
548548+ aggregatorDID := "did:plc:aggregator123"
549549+ var storedKeyPrefix, storedKeyHash string
550550+ var storedOAuthCreds *OAuthCredentials
551551+ var savedSession *oauth.ClientSessionData
552552+553553+ repo := &mockRepository{
554554+ getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) {
555555+ if did != aggregatorDID {
556556+ return nil, ErrAggregatorNotFound
557557+ }
558558+ return &Aggregator{
559559+ DID: did,
560560+ DisplayName: "Test Aggregator",
561561+ }, nil
562562+ },
563563+ setAPIKeyFunc: func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error {
564564+ storedKeyPrefix = keyPrefix
565565+ storedKeyHash = keyHash
566566+ storedOAuthCreds = oauthCreds
567567+ return nil
568568+ },
569569+ }
570570+571571+ // Create mock OAuth store that tracks saved sessions
572572+ mockStore := &mockOAuthStore{
573573+ saveSessionFunc: func(ctx context.Context, session oauth.ClientSessionData) error {
574574+ savedSession = &session
575575+ return nil
576576+ },
577577+ }
578578+ mockApp := &oauth.ClientApp{Store: mockStore}
579579+580580+ service := NewAPIKeyService(repo, mockApp)
581581+582582+ // Create OAuth session
583583+ did, _ := syntax.ParseDID(aggregatorDID)
584584+ session := &oauth.ClientSessionData{
585585+ AccountDID: did,
586586+ SessionID: "original_session",
587587+ AccessToken: "test_access_token",
588588+ RefreshToken: "test_refresh_token",
589589+ HostURL: "https://pds.example.com",
590590+ AuthServerURL: "https://auth.example.com",
591591+ AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
592592+ DPoPPrivateKeyMultibase: "z1234567890",
593593+ DPoPAuthServerNonce: "auth_nonce_123",
594594+ DPoPHostNonce: "host_nonce_456",
595595+ }
596596+597597+ plainKey, keyPrefix, err := service.GenerateKey(context.Background(), aggregatorDID, session)
598598+ if err != nil {
599599+ t.Fatalf("GenerateKey() unexpected error: %v", err)
600600+ }
601601+602602+ // Verify key format
603603+ if len(plainKey) != APIKeyTotalLength {
604604+ t.Errorf("GenerateKey() plainKey length = %d, want %d", len(plainKey), APIKeyTotalLength)
605605+ }
606606+ if plainKey[:6] != APIKeyPrefix {
607607+ t.Errorf("GenerateKey() plainKey prefix = %s, want %s", plainKey[:6], APIKeyPrefix)
608608+ }
609609+610610+ // Verify key prefix is first 12 chars
611611+ if keyPrefix != plainKey[:12] {
612612+ t.Errorf("GenerateKey() keyPrefix = %s, want %s", keyPrefix, plainKey[:12])
613613+ }
614614+615615+ // Verify hash was stored (SHA-256 produces 64 hex chars)
616616+ if len(storedKeyHash) != 64 {
617617+ t.Errorf("GenerateKey() stored hash length = %d, want 64", len(storedKeyHash))
618618+ }
619619+620620+ // Verify hash matches the key
621621+ expectedHash := hashAPIKey(plainKey)
622622+ if storedKeyHash != expectedHash {
623623+ t.Errorf("GenerateKey() stored hash doesn't match key hash")
624624+ }
625625+626626+ // Verify stored key prefix matches returned prefix
627627+ if storedKeyPrefix != keyPrefix {
628628+ t.Errorf("GenerateKey() stored keyPrefix = %s, want %s", storedKeyPrefix, keyPrefix)
629629+ }
630630+631631+ // Verify OAuth credentials were saved
632632+ if storedOAuthCreds == nil {
633633+ t.Fatal("GenerateKey() OAuth credentials not stored")
634634+ }
635635+ if storedOAuthCreds.AccessToken != session.AccessToken {
636636+ t.Errorf("GenerateKey() stored AccessToken = %s, want %s", storedOAuthCreds.AccessToken, session.AccessToken)
637637+ }
638638+ if storedOAuthCreds.RefreshToken != session.RefreshToken {
639639+ t.Errorf("GenerateKey() stored RefreshToken = %s, want %s", storedOAuthCreds.RefreshToken, session.RefreshToken)
640640+ }
641641+ if storedOAuthCreds.PDSURL != session.HostURL {
642642+ t.Errorf("GenerateKey() stored PDSURL = %s, want %s", storedOAuthCreds.PDSURL, session.HostURL)
643643+ }
644644+ if storedOAuthCreds.AuthServerIss != session.AuthServerURL {
645645+ t.Errorf("GenerateKey() stored AuthServerIss = %s, want %s", storedOAuthCreds.AuthServerIss, session.AuthServerURL)
646646+ }
647647+ if storedOAuthCreds.DPoPPrivateKeyMultibase != session.DPoPPrivateKeyMultibase {
648648+ t.Errorf("GenerateKey() stored DPoPPrivateKeyMultibase mismatch")
649649+ }
650650+ if storedOAuthCreds.DPoPAuthServerNonce != session.DPoPAuthServerNonce {
651651+ t.Errorf("GenerateKey() stored DPoPAuthServerNonce = %s, want %s", storedOAuthCreds.DPoPAuthServerNonce, session.DPoPAuthServerNonce)
652652+ }
653653+ if storedOAuthCreds.DPoPPDSNonce != session.DPoPHostNonce {
654654+ t.Errorf("GenerateKey() stored DPoPPDSNonce = %s, want %s", storedOAuthCreds.DPoPPDSNonce, session.DPoPHostNonce)
655655+ }
656656+657657+ // Verify session was saved to OAuth store
658658+ if savedSession == nil {
659659+ t.Fatal("GenerateKey() session not saved to OAuth store")
660660+ }
661661+ if savedSession.SessionID != DefaultSessionID {
662662+ t.Errorf("GenerateKey() saved session ID = %s, want %s", savedSession.SessionID, DefaultSessionID)
663663+ }
664664+ if savedSession.AccessToken != session.AccessToken {
665665+ t.Errorf("GenerateKey() saved session AccessToken mismatch")
666666+ }
667667+}
668668+669669+func TestAPIKeyService_GenerateKey_OAuthStoreSaveError(t *testing.T) {
670670+ // Test that OAuth session save failure aborts key creation early
671671+ // With the new ordering (OAuth session first, then API key), if OAuth save fails,
672672+ // we abort immediately without creating an API key.
673673+ aggregatorDID := "did:plc:aggregator123"
674674+ setAPIKeyCalled := false
675675+676676+ repo := &mockRepository{
677677+ getAggregatorFunc: func(ctx context.Context, did string) (*Aggregator, error) {
678678+ return &Aggregator{DID: did, DisplayName: "Test"}, nil
679679+ },
680680+ setAPIKeyFunc: func(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error {
681681+ setAPIKeyCalled = true
682682+ return nil
683683+ },
684684+ }
685685+686686+ // Create mock OAuth store that fails on save
687687+ mockStore := &mockOAuthStore{
688688+ saveSessionFunc: func(ctx context.Context, session oauth.ClientSessionData) error {
689689+ return errors.New("failed to save session")
690690+ },
691691+ }
692692+ mockApp := &oauth.ClientApp{Store: mockStore}
693693+694694+ service := NewAPIKeyService(repo, mockApp)
695695+696696+ did, _ := syntax.ParseDID(aggregatorDID)
697697+ session := &oauth.ClientSessionData{
698698+ AccountDID: did,
699699+ AccessToken: "test_token",
700700+ }
701701+702702+ _, _, err := service.GenerateKey(context.Background(), aggregatorDID, session)
703703+ if err == nil {
704704+ t.Error("GenerateKey() expected error when OAuth store save fails, got nil")
705705+ }
706706+707707+ // Verify SetAPIKey was NOT called - we should abort before storing the key
708708+ // This prevents the race condition where an API key exists but can't refresh tokens
709709+ if setAPIKeyCalled {
710710+ t.Error("GenerateKey() should NOT call SetAPIKey when OAuth session save fails")
711711+ }
712712+}
713713+714714+// mockOAuthStore implements oauth.ClientAuthStore for testing
715715+type mockOAuthStore struct {
716716+ getSessionFunc func(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error)
717717+ saveSessionFunc func(ctx context.Context, session oauth.ClientSessionData) error
718718+ deleteSessionFunc func(ctx context.Context, did syntax.DID, sessionID string) error
719719+ getAuthRequestInfoFunc func(ctx context.Context, state string) (*oauth.AuthRequestData, error)
720720+ saveAuthRequestInfoFunc func(ctx context.Context, info oauth.AuthRequestData) error
721721+ deleteAuthRequestInfoFunc func(ctx context.Context, state string) error
722722+}
723723+724724+func (m *mockOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
725725+ if m.getSessionFunc != nil {
726726+ return m.getSessionFunc(ctx, did, sessionID)
727727+ }
728728+ return nil, errors.New("session not found")
729729+}
730730+731731+func (m *mockOAuthStore) SaveSession(ctx context.Context, session oauth.ClientSessionData) error {
732732+ if m.saveSessionFunc != nil {
733733+ return m.saveSessionFunc(ctx, session)
734734+ }
735735+ return nil
736736+}
737737+738738+func (m *mockOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
739739+ if m.deleteSessionFunc != nil {
740740+ return m.deleteSessionFunc(ctx, did, sessionID)
741741+ }
742742+ return nil
743743+}
744744+745745+func (m *mockOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
746746+ if m.getAuthRequestInfoFunc != nil {
747747+ return m.getAuthRequestInfoFunc(ctx, state)
748748+ }
749749+ return nil, errors.New("not found")
750750+}
751751+752752+func (m *mockOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
753753+ if m.saveAuthRequestInfoFunc != nil {
754754+ return m.saveAuthRequestInfoFunc(ctx, info)
755755+ }
756756+ return nil
757757+}
758758+759759+func (m *mockOAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
760760+ if m.deleteAuthRequestInfoFunc != nil {
761761+ return m.deleteAuthRequestInfoFunc(ctx, state)
762762+ }
763763+ return nil
764764+}
765765+766766+// =============================================================================
767767+// RevokeKey Tests
768768+// =============================================================================
769769+770770+func TestAPIKeyService_RevokeKey_Success(t *testing.T) {
771771+ revokeCalled := false
772772+ revokedDID := ""
773773+774774+ repo := &mockRepository{
775775+ revokeAPIKeyFunc: func(ctx context.Context, did string) error {
776776+ revokeCalled = true
777777+ revokedDID = did
778778+ return nil
779779+ },
780780+ }
781781+ service := newTestAPIKeyService(repo)
782782+783783+ err := service.RevokeKey(context.Background(), "did:plc:aggregator123")
784784+ if err != nil {
785785+ t.Fatalf("RevokeKey() unexpected error: %v", err)
786786+ }
787787+788788+ if !revokeCalled {
789789+ t.Error("Expected RevokeAPIKey to be called on repository")
790790+ }
791791+ if revokedDID != "did:plc:aggregator123" {
792792+ t.Errorf("RevokeKey() called with DID = %s, want did:plc:aggregator123", revokedDID)
793793+ }
794794+}
795795+796796+func TestAPIKeyService_RevokeKey_Error(t *testing.T) {
797797+ expectedError := errors.New("database error")
798798+ repo := &mockRepository{
799799+ revokeAPIKeyFunc: func(ctx context.Context, did string) error {
800800+ return expectedError
801801+ },
802802+ }
803803+ service := newTestAPIKeyService(repo)
804804+805805+ err := service.RevokeKey(context.Background(), "did:plc:aggregator123")
806806+ if err == nil {
807807+ t.Error("RevokeKey() expected error, got nil")
808808+ }
809809+}
810810+811811+// =============================================================================
812812+// GetAPIKeyInfo Tests
813813+// =============================================================================
814814+815815+func TestAPIKeyService_GetAPIKeyInfo_NoKey(t *testing.T) {
816816+ repo := &mockRepository{
817817+ getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) {
818818+ return &AggregatorCredentials{
819819+ DID: did,
820820+ APIKeyHash: "", // No key
821821+ }, nil
822822+ },
823823+ }
824824+ service := newTestAPIKeyService(repo)
825825+826826+ info, err := service.GetAPIKeyInfo(context.Background(), "did:plc:aggregator123")
827827+ if err != nil {
828828+ t.Fatalf("GetAPIKeyInfo() unexpected error: %v", err)
829829+ }
830830+831831+ if info.HasKey {
832832+ t.Error("GetAPIKeyInfo() HasKey = true, want false")
833833+ }
834834+}
835835+836836+func TestAPIKeyService_GetAPIKeyInfo_HasActiveKey(t *testing.T) {
837837+ createdAt := time.Now().Add(-24 * time.Hour)
838838+ lastUsed := time.Now().Add(-1 * time.Hour)
839839+840840+ repo := &mockRepository{
841841+ getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) {
842842+ return &AggregatorCredentials{
843843+ DID: did,
844844+ APIKeyHash: "somehash",
845845+ APIKeyPrefix: "ckapi_test12",
846846+ APIKeyCreatedAt: &createdAt,
847847+ APIKeyLastUsed: &lastUsed,
848848+ }, nil
849849+ },
850850+ }
851851+ service := newTestAPIKeyService(repo)
852852+853853+ info, err := service.GetAPIKeyInfo(context.Background(), "did:plc:aggregator123")
854854+ if err != nil {
855855+ t.Fatalf("GetAPIKeyInfo() unexpected error: %v", err)
856856+ }
857857+858858+ if !info.HasKey {
859859+ t.Error("GetAPIKeyInfo() HasKey = false, want true")
860860+ }
861861+ if info.KeyPrefix != "ckapi_test12" {
862862+ t.Errorf("GetAPIKeyInfo() KeyPrefix = %s, want ckapi_test12", info.KeyPrefix)
863863+ }
864864+ if info.IsRevoked {
865865+ t.Error("GetAPIKeyInfo() IsRevoked = true, want false")
866866+ }
867867+ if info.CreatedAt == nil || !info.CreatedAt.Equal(createdAt) {
868868+ t.Error("GetAPIKeyInfo() CreatedAt mismatch")
869869+ }
870870+ if info.LastUsedAt == nil || !info.LastUsedAt.Equal(lastUsed) {
871871+ t.Error("GetAPIKeyInfo() LastUsedAt mismatch")
872872+ }
873873+}
874874+875875+func TestAPIKeyService_GetAPIKeyInfo_RevokedKey(t *testing.T) {
876876+ revokedAt := time.Now().Add(-1 * time.Hour)
877877+878878+ repo := &mockRepository{
879879+ getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) {
880880+ return &AggregatorCredentials{
881881+ DID: did,
882882+ APIKeyHash: "somehash",
883883+ APIKeyPrefix: "ckapi_test12",
884884+ APIKeyRevokedAt: &revokedAt,
885885+ }, nil
886886+ },
887887+ }
888888+ service := newTestAPIKeyService(repo)
889889+890890+ info, err := service.GetAPIKeyInfo(context.Background(), "did:plc:aggregator123")
891891+ if err != nil {
892892+ t.Fatalf("GetAPIKeyInfo() unexpected error: %v", err)
893893+ }
894894+895895+ if !info.HasKey {
896896+ t.Error("GetAPIKeyInfo() HasKey = false, want true (revoked keys still exist)")
897897+ }
898898+ if !info.IsRevoked {
899899+ t.Error("GetAPIKeyInfo() IsRevoked = false, want true")
900900+ }
901901+ if info.RevokedAt == nil || !info.RevokedAt.Equal(revokedAt) {
902902+ t.Error("GetAPIKeyInfo() RevokedAt mismatch")
903903+ }
904904+}
905905+906906+func TestAPIKeyService_GetAPIKeyInfo_NotFound(t *testing.T) {
907907+ repo := &mockRepository{
908908+ getAggregatorCredentialsFunc: func(ctx context.Context, did string) (*AggregatorCredentials, error) {
909909+ return nil, ErrAggregatorNotFound
910910+ },
911911+ }
912912+ service := newTestAPIKeyService(repo)
913913+914914+ _, err := service.GetAPIKeyInfo(context.Background(), "did:plc:nonexistent")
915915+ if !errors.Is(err, ErrAggregatorNotFound) {
916916+ t.Errorf("GetAPIKeyInfo() error = %v, want ErrAggregatorNotFound", err)
917917+ }
918918+}
919919+920920+// =============================================================================
921921+// RefreshTokensIfNeeded Tests
922922+// =============================================================================
923923+924924+func TestAPIKeyService_RefreshTokensIfNeeded_TokensStillValid(t *testing.T) {
925925+ // Tokens expire in 1 hour - well beyond the 5 minute buffer
926926+ expiresAt := time.Now().Add(1 * time.Hour)
927927+928928+ creds := &AggregatorCredentials{
929929+ DID: "did:plc:aggregator123",
930930+ OAuthTokenExpiresAt: &expiresAt,
931931+ }
932932+933933+ repo := &mockRepository{}
934934+ service := newTestAPIKeyService(repo)
935935+936936+ err := service.RefreshTokensIfNeeded(context.Background(), creds)
937937+ if err != nil {
938938+ t.Fatalf("RefreshTokensIfNeeded() unexpected error: %v", err)
939939+ }
940940+941941+ // No refresh should have happened - we can't easily verify this without
942942+ // more complex mocking, but the absence of error is the key indicator
943943+}
944944+945945+func TestAPIKeyService_RefreshTokensIfNeeded_WithinBuffer(t *testing.T) {
946946+ // Token expires in 4 minutes - within the 5 minute buffer, so needs refresh
947947+ // This test verifies that when tokens are within the buffer, the service
948948+ // attempts to refresh them.
949949+ //
950950+ // Note: Full integration testing of token refresh requires a real OAuth app.
951951+ // This test is intentionally skipped as it would require extensive mocking
952952+ // of the indigo OAuth library internals.
953953+ t.Skip("RefreshTokensIfNeeded requires fully configured OAuth app - covered by integration tests")
954954+}
955955+956956+func TestAPIKeyService_RefreshTokensIfNeeded_ExpiredNilTokens(t *testing.T) {
957957+ // When OAuthTokenExpiresAt is nil, tokens need refresh
958958+ // This should also attempt to refresh (and fail with nil OAuth app)
959959+ t.Skip("RefreshTokensIfNeeded requires fully configured OAuth app - covered by integration tests")
960960+}
961961+962962+// =============================================================================
963963+// GetAccessToken Tests
964964+// =============================================================================
965965+966966+func TestAPIKeyService_GetAccessToken_ValidAggregatorTokensNotExpired(t *testing.T) {
967967+ // Tokens expire in 1 hour - well beyond the 5 minute buffer
968968+ expiresAt := time.Now().Add(1 * time.Hour)
969969+ expectedToken := "valid_access_token_123"
970970+971971+ creds := &AggregatorCredentials{
972972+ DID: "did:plc:aggregator123",
973973+ OAuthAccessToken: expectedToken,
974974+ OAuthTokenExpiresAt: &expiresAt,
975975+ }
976976+977977+ repo := &mockRepository{}
978978+ service := newTestAPIKeyService(repo)
979979+980980+ token, err := service.GetAccessToken(context.Background(), creds)
981981+ if err != nil {
982982+ t.Fatalf("GetAccessToken() unexpected error: %v", err)
983983+ }
984984+985985+ if token != expectedToken {
986986+ t.Errorf("GetAccessToken() = %s, want %s", token, expectedToken)
987987+ }
988988+}
989989+990990+func TestAPIKeyService_GetAccessToken_ExpiredTokens(t *testing.T) {
991991+ // Tokens expired 1 hour ago - requires refresh
992992+ // Since refresh requires a real OAuth app, this test verifies the error path
993993+ expiresAt := time.Now().Add(-1 * time.Hour)
994994+995995+ creds := &AggregatorCredentials{
996996+ DID: "did:plc:aggregator123",
997997+ OAuthAccessToken: "expired_token",
998998+ OAuthRefreshToken: "refresh_token",
999999+ OAuthTokenExpiresAt: &expiresAt,
10001000+ }
10011001+10021002+ repo := &mockRepository{}
10031003+ // Service has nil OAuth app, so refresh will fail
10041004+ service := newTestAPIKeyService(repo)
10051005+10061006+ _, err := service.GetAccessToken(context.Background(), creds)
10071007+ if err == nil {
10081008+ t.Error("GetAccessToken() expected error when tokens are expired and no OAuth app configured, got nil")
10091009+ }
10101010+}
10111011+10121012+func TestAPIKeyService_GetAccessToken_NilExpiry(t *testing.T) {
10131013+ // Nil expiry means tokens need refresh
10141014+ creds := &AggregatorCredentials{
10151015+ DID: "did:plc:aggregator123",
10161016+ OAuthAccessToken: "some_token",
10171017+ OAuthTokenExpiresAt: nil, // nil means needs refresh
10181018+ }
10191019+10201020+ repo := &mockRepository{}
10211021+ service := newTestAPIKeyService(repo)
10221022+10231023+ _, err := service.GetAccessToken(context.Background(), creds)
10241024+ if err == nil {
10251025+ t.Error("GetAccessToken() expected error when expiry is nil and no OAuth app configured, got nil")
10261026+ }
10271027+}
10281028+10291029+func TestAPIKeyService_GetAccessToken_WithinExpiryBuffer(t *testing.T) {
10301030+ // Tokens expire in 4 minutes - within the 5 minute buffer, so needs refresh
10311031+ expiresAt := time.Now().Add(4 * time.Minute)
10321032+10331033+ creds := &AggregatorCredentials{
10341034+ DID: "did:plc:aggregator123",
10351035+ OAuthAccessToken: "soon_to_expire_token",
10361036+ OAuthRefreshToken: "refresh_token",
10371037+ OAuthTokenExpiresAt: &expiresAt,
10381038+ }
10391039+10401040+ repo := &mockRepository{}
10411041+ service := newTestAPIKeyService(repo)
10421042+10431043+ // Should attempt refresh and fail since no OAuth app is configured
10441044+ _, err := service.GetAccessToken(context.Background(), creds)
10451045+ if err == nil {
10461046+ t.Error("GetAccessToken() expected error when tokens are within buffer and no OAuth app configured, got nil")
10471047+ }
10481048+}
10491049+10501050+func TestAPIKeyService_GetAccessToken_RevokedKey(t *testing.T) {
10511051+ // Test behavior when aggregator has a revoked key
10521052+ // The API key check happens in ValidateKey, but GetAccessToken should still work
10531053+ // if called directly with a valid aggregator (before revocation is detected)
10541054+ expiresAt := time.Now().Add(1 * time.Hour)
10551055+ revokedAt := time.Now().Add(-30 * time.Minute)
10561056+ expectedToken := "valid_access_token"
10571057+10581058+ creds := &AggregatorCredentials{
10591059+ DID: "did:plc:aggregator123",
10601060+ APIKeyRevokedAt: &revokedAt, // Key is revoked
10611061+ OAuthAccessToken: expectedToken,
10621062+ OAuthTokenExpiresAt: &expiresAt,
10631063+ }
10641064+10651065+ repo := &mockRepository{}
10661066+ service := newTestAPIKeyService(repo)
10671067+10681068+ // GetAccessToken doesn't check revocation - that's done at ValidateKey level
10691069+ // It just returns the token if valid
10701070+ token, err := service.GetAccessToken(context.Background(), creds)
10711071+ if err != nil {
10721072+ t.Fatalf("GetAccessToken() unexpected error: %v", err)
10731073+ }
10741074+10751075+ if token != expectedToken {
10761076+ t.Errorf("GetAccessToken() = %s, want %s", token, expectedToken)
10771077+ }
10781078+}
10791079+10801080+func TestAPIKeyService_FailureCounters_InitiallyZero(t *testing.T) {
10811081+ repo := &mockRepository{}
10821082+ service := newTestAPIKeyService(repo)
10831083+10841084+ if got := service.GetFailedLastUsedUpdates(); got != 0 {
10851085+ t.Errorf("GetFailedLastUsedUpdates() = %d, want 0", got)
10861086+ }
10871087+10881088+ if got := service.GetFailedNonceUpdates(); got != 0 {
10891089+ t.Errorf("GetFailedNonceUpdates() = %d, want 0", got)
10901090+ }
10911091+}
10921092+10931093+func TestAPIKeyService_FailedLastUsedUpdates_IncrementsOnError(t *testing.T) {
10941094+ // Create a valid API key
10951095+ plainKey := APIKeyPrefix + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
10961096+ keyHash := hashAPIKey(plainKey)
10971097+10981098+ updateCalled := make(chan struct{}, 1)
10991099+ repo := &mockRepository{
11001100+ getCredentialsByAPIKeyHashFunc: func(ctx context.Context, hash string) (*AggregatorCredentials, error) {
11011101+ if hash == keyHash {
11021102+ return &AggregatorCredentials{
11031103+ DID: "did:plc:aggregator123",
11041104+ APIKeyHash: keyHash,
11051105+ }, nil
11061106+ }
11071107+ return nil, ErrAPIKeyInvalid
11081108+ },
11091109+ updateAPIKeyLastUsedFunc: func(ctx context.Context, did string) error {
11101110+ defer func() { updateCalled <- struct{}{} }()
11111111+ return errors.New("database connection failed")
11121112+ },
11131113+ }
11141114+11151115+ service := newTestAPIKeyService(repo)
11161116+11171117+ // Initial count should be 0
11181118+ if got := service.GetFailedLastUsedUpdates(); got != 0 {
11191119+ t.Errorf("GetFailedLastUsedUpdates() initial = %d, want 0", got)
11201120+ }
11211121+11221122+ // Validate the key (triggers async last_used update)
11231123+ _, err := service.ValidateKey(context.Background(), plainKey)
11241124+ if err != nil {
11251125+ t.Fatalf("ValidateKey() unexpected error: %v", err)
11261126+ }
11271127+11281128+ // Wait for async update to complete
11291129+ select {
11301130+ case <-updateCalled:
11311131+ // Update was called
11321132+ case <-time.After(2 * time.Second):
11331133+ t.Fatal("timeout waiting for async UpdateAPIKeyLastUsed call")
11341134+ }
11351135+11361136+ // Give a moment for the counter to be incremented
11371137+ time.Sleep(10 * time.Millisecond)
11381138+11391139+ // Counter should now be 1
11401140+ if got := service.GetFailedLastUsedUpdates(); got != 1 {
11411141+ t.Errorf("GetFailedLastUsedUpdates() after failure = %d, want 1", got)
11421142+ }
11431143+}
+22-1
internal/core/aggregators/errors.go
···1616 ErrConfigSchemaValidation = errors.New("configuration does not match aggregator's schema")
1717 ErrNotModerator = errors.New("user is not a moderator of this community")
1818 ErrNotImplemented = errors.New("feature not yet implemented") // For Phase 2 write-forward operations
1919+2020+ // API Key authentication errors
2121+ ErrAPIKeyRevoked = errors.New("API key has been revoked")
2222+ ErrAPIKeyInvalid = errors.New("invalid API key")
2323+ ErrAPIKeyNotFound = errors.New("API key not found for this aggregator")
2424+ ErrOAuthTokenExpired = errors.New("OAuth token has expired and needs refresh")
2525+ ErrOAuthRefreshFailed = errors.New("failed to refresh OAuth token")
2626+ ErrOAuthSessionMismatch = errors.New("OAuth session DID does not match aggregator DID")
1927)
20282129// ValidationError represents a validation error with field details
···38463947// Error classification helpers for handlers to map to HTTP status codes
4048func IsNotFound(err error) bool {
4141- return errors.Is(err, ErrAggregatorNotFound) || errors.Is(err, ErrAuthorizationNotFound)
4949+ return errors.Is(err, ErrAggregatorNotFound) ||
5050+ errors.Is(err, ErrAuthorizationNotFound) ||
5151+ errors.Is(err, ErrAPIKeyNotFound)
4252}
43534454func IsValidationError(err error) bool {
···6171func IsNotImplemented(err error) bool {
6272 return errors.Is(err, ErrNotImplemented)
6373}
7474+7575+func IsAPIKeyError(err error) bool {
7676+ return errors.Is(err, ErrAPIKeyRevoked) ||
7777+ errors.Is(err, ErrAPIKeyInvalid) ||
7878+ errors.Is(err, ErrAPIKeyNotFound)
7979+}
8080+8181+func IsOAuthError(err error) bool {
8282+ return errors.Is(err, ErrOAuthTokenExpired) ||
8383+ errors.Is(err, ErrOAuthRefreshFailed)
8484+}
+43
internal/core/aggregators/interfaces.go
···33import (
44 "context"
55 "time"
66+77+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
68)
79810// Repository defines the interface for aggregator data persistence
···3436 RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error
3537 CountRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) (int, error)
3638 GetRecentPosts(ctx context.Context, aggregatorDID, communityDID string, since time.Time) ([]*AggregatorPost, error)
3939+4040+ // API Key Authentication
4141+ // GetByAPIKeyHash looks up an aggregator by their API key hash for authentication
4242+ GetByAPIKeyHash(ctx context.Context, keyHash string) (*Aggregator, error)
4343+ // GetAggregatorCredentials retrieves only the credential fields for an aggregator.
4444+ // Used by APIKeyService for authentication operations where full aggregator is not needed.
4545+ GetAggregatorCredentials(ctx context.Context, did string) (*AggregatorCredentials, error)
4646+ // GetCredentialsByAPIKeyHash looks up aggregator credentials by their API key hash.
4747+ // Returns ErrAPIKeyRevoked if the key has been revoked.
4848+ // Returns ErrAPIKeyInvalid if no aggregator found with that hash.
4949+ GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*AggregatorCredentials, error)
5050+ // SetAPIKey stores API key credentials and OAuth session for an aggregator
5151+ SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *OAuthCredentials) error
5252+ // UpdateOAuthTokens updates OAuth tokens after a refresh operation
5353+ UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error
5454+ // UpdateOAuthNonces updates DPoP nonces after token operations
5555+ UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error
5656+ // UpdateAPIKeyLastUsed updates the last_used_at timestamp for audit purposes
5757+ UpdateAPIKeyLastUsed(ctx context.Context, did string) error
5858+ // RevokeAPIKey marks an API key as revoked (sets api_key_revoked_at)
5959+ RevokeAPIKey(ctx context.Context, did string) error
3760}
38613962// Service defines the interface for aggregator business logic
···6083 // Post tracking (called after successful post creation)
6184 RecordAggregatorPost(ctx context.Context, aggregatorDID, communityDID, postURI, postCID string) error
6285}
8686+8787+// APIKeyServiceInterface defines the interface for API key operations used by handlers.
8888+// This interface enables easier testing by allowing mock implementations.
8989+type APIKeyServiceInterface interface {
9090+ // GenerateKey creates a new API key for an aggregator.
9191+ // Returns the plain-text key (only shown once) and the key prefix for reference.
9292+ GenerateKey(ctx context.Context, aggregatorDID string, oauthSession *oauth.ClientSessionData) (plainKey string, keyPrefix string, err error)
9393+9494+ // GetAPIKeyInfo returns information about an aggregator's API key (without the actual key).
9595+ GetAPIKeyInfo(ctx context.Context, aggregatorDID string) (*APIKeyInfo, error)
9696+9797+ // RevokeKey revokes an API key for an aggregator.
9898+ RevokeKey(ctx context.Context, aggregatorDID string) error
9999+100100+ // GetFailedLastUsedUpdates returns the count of failed last_used timestamp updates.
101101+ GetFailedLastUsedUpdates() int64
102102+103103+ // GetFailedNonceUpdates returns the count of failed OAuth nonce updates.
104104+ GetFailedNonceUpdates() int64
105105+}
+25-11
internal/core/posts/service.go
···1010 "log"
1111 "net/http"
1212 "os"
1313+ "strings"
1314 "time"
14151516 "Coves/internal/api/middleware"
···8384 return nil, fmt.Errorf("authenticated DID does not match author DID")
8485 }
85868686- // 3. Determine actor type: Kagi aggregator, other aggregator, or regular user
8787- kagiAggregatorDID := os.Getenv("KAGI_AGGREGATOR_DID")
8888- isTrustedKagi := kagiAggregatorDID != "" && req.AuthorDID == kagiAggregatorDID
8787+ // 3. Determine actor type: trusted aggregator, other aggregator, or regular user
8888+ // Check against comma-separated list of trusted aggregator DIDs
8989+ trustedDIDs := os.Getenv("TRUSTED_AGGREGATOR_DIDS")
9090+ if trustedDIDs == "" {
9191+ // Fallback to legacy single DID env var
9292+ trustedDIDs = os.Getenv("KAGI_AGGREGATOR_DID")
9393+ }
9494+ isTrustedAggregator := false
9595+ if trustedDIDs != "" {
9696+ for _, did := range strings.Split(trustedDIDs, ",") {
9797+ if strings.TrimSpace(did) == req.AuthorDID {
9898+ isTrustedAggregator = true
9999+ break
100100+ }
101101+ }
102102+ }
891039090- // Check if this is a non-Kagi aggregator (requires database lookup)
104104+ // Check if this is a non-trusted aggregator (requires database lookup)
91105 var isOtherAggregator bool
92106 var err error
9393- if !isTrustedKagi && s.aggregatorService != nil {
107107+ if !isTrustedAggregator && s.aggregatorService != nil {
94108 isOtherAggregator, err = s.aggregatorService.IsAggregator(ctx, req.AuthorDID)
95109 if err != nil {
96110 log.Printf("[POST-CREATE] Warning: failed to check if DID is aggregator: %v", err)
···138152 }
139153140154 // 7. Apply validation based on actor type (aggregator vs user)
141141- if isTrustedKagi {
155155+ if isTrustedAggregator {
142156 // TRUSTED AGGREGATOR VALIDATION FLOW
143143- // Kagi aggregator is authorized via KAGI_AGGREGATOR_DID env var (temporary)
157157+ // Trusted aggregators are authorized via TRUSTED_AGGREGATOR_DIDS env var (temporary)
144158 // TODO: Replace with proper XRPC aggregator authorization endpoint
145145- log.Printf("[POST-CREATE] Trusted Kagi aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID)
159159+ log.Printf("[POST-CREATE] Trusted aggregator detected: %s posting to community: %s", req.AuthorDID, communityDID)
146160 // Aggregators skip membership checks and visibility restrictions
147161 // They are authorized services, not community members
148162 } else if isOtherAggregator {
···219233220234 // TRUSTED AGGREGATOR: Allow Kagi aggregator to provide thumbnail URLs directly
221235 // This bypasses unfurl for more accurate RSS-sourced thumbnails
222222- if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedKagi {
236236+ if req.ThumbnailURL != nil && *req.ThumbnailURL != "" && isTrustedAggregator {
223237 log.Printf("[AGGREGATOR-THUMB] Trusted aggregator provided thumbnail: %s", *req.ThumbnailURL)
224238225239 if s.blobService != nil {
···239253240254 // Unfurl enhancement (optional, only if URL is supported)
241255 // Skip unfurl for trusted aggregators - they provide their own metadata
242242- if !isTrustedKagi {
256256+ if !isTrustedAggregator {
243257 if uri, ok := external["uri"].(string); ok && uri != "" {
244258 // Check if we support unfurling this URL
245259 if s.unfurlService != nil && s.unfurlService.IsSupported(uri) {
···313327314328 // 13. Return response (AppView will index via Jetstream consumer)
315329 log.Printf("[POST-CREATE] Author: %s (trustedKagi=%v, otherAggregator=%v), Community: %s, URI: %s",
316316- req.AuthorDID, isTrustedKagi, isOtherAggregator, communityDID, uri)
330330+ req.AuthorDID, isTrustedAggregator, isOtherAggregator, communityDID, uri)
317331318332 return &CreatePostResponse{
319333 URI: uri,
···11+-- +goose Up
22+-- Add API key authentication and OAuth credential storage for aggregators
33+-- This enables aggregators to authenticate using API keys backed by OAuth sessions
44+55+-- ============================================================================
66+-- Add API key columns to aggregators table
77+-- ============================================================================
88+ALTER TABLE aggregators
99+ -- API key identification (prefix for log correlation, hash for auth)
1010+ ADD COLUMN api_key_prefix VARCHAR(12),
1111+ ADD COLUMN api_key_hash VARCHAR(64) UNIQUE,
1212+1313+ -- OAuth credentials (encrypted at application layer before storage)
1414+ -- SECURITY: These columns contain sensitive OAuth tokens
1515+ ADD COLUMN oauth_access_token TEXT,
1616+ ADD COLUMN oauth_refresh_token TEXT,
1717+ ADD COLUMN oauth_token_expires_at TIMESTAMPTZ,
1818+1919+ -- OAuth session metadata for token refresh
2020+ ADD COLUMN oauth_pds_url TEXT,
2121+ ADD COLUMN oauth_auth_server_iss TEXT,
2222+ ADD COLUMN oauth_auth_server_token_endpoint TEXT,
2323+2424+ -- DPoP keys and nonces for token refresh (multibase encoded)
2525+ -- SECURITY: Contains private key material
2626+ ADD COLUMN oauth_dpop_private_key_multibase TEXT,
2727+ ADD COLUMN oauth_dpop_authserver_nonce TEXT,
2828+ ADD COLUMN oauth_dpop_pds_nonce TEXT,
2929+3030+ -- API key lifecycle timestamps
3131+ ADD COLUMN api_key_created_at TIMESTAMPTZ,
3232+ ADD COLUMN api_key_revoked_at TIMESTAMPTZ,
3333+ ADD COLUMN api_key_last_used_at TIMESTAMPTZ;
3434+3535+-- Index for API key lookup during authentication
3636+-- Partial index excludes NULL values since not all aggregators have API keys
3737+CREATE INDEX idx_aggregators_api_key_hash
3838+ ON aggregators(api_key_hash)
3939+ WHERE api_key_hash IS NOT NULL;
4040+4141+-- ============================================================================
4242+-- Security comments on sensitive columns
4343+-- ============================================================================
4444+COMMENT ON COLUMN aggregators.api_key_prefix IS 'First 12 characters of API key for identification in logs (not secret)';
4545+COMMENT ON COLUMN aggregators.api_key_hash IS 'SHA-256 hash of full API key for authentication lookup';
4646+COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: Encrypted OAuth access token for PDS operations';
4747+COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: Encrypted OAuth refresh token for session renewal';
4848+COMMENT ON COLUMN aggregators.oauth_token_expires_at IS 'When the OAuth access token expires (triggers refresh)';
4949+COMMENT ON COLUMN aggregators.oauth_pds_url IS 'PDS URL for this aggregators OAuth session';
5050+COMMENT ON COLUMN aggregators.oauth_auth_server_iss IS 'OAuth authorization server issuer URL';
5151+COMMENT ON COLUMN aggregators.oauth_auth_server_token_endpoint IS 'OAuth token refresh endpoint URL';
5252+COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh';
5353+COMMENT ON COLUMN aggregators.oauth_dpop_authserver_nonce IS 'Latest DPoP nonce from authorization server';
5454+COMMENT ON COLUMN aggregators.oauth_dpop_pds_nonce IS 'Latest DPoP nonce from PDS';
5555+COMMENT ON COLUMN aggregators.api_key_created_at IS 'When the API key was generated';
5656+COMMENT ON COLUMN aggregators.api_key_revoked_at IS 'When the API key was revoked (NULL = active)';
5757+COMMENT ON COLUMN aggregators.api_key_last_used_at IS 'Last successful authentication using this API key';
5858+5959+-- +goose Down
6060+-- Remove API key columns from aggregators table
6161+DROP INDEX IF EXISTS idx_aggregators_api_key_hash;
6262+6363+ALTER TABLE aggregators
6464+ DROP COLUMN IF EXISTS api_key_prefix,
6565+ DROP COLUMN IF EXISTS api_key_hash,
6666+ DROP COLUMN IF EXISTS oauth_access_token,
6767+ DROP COLUMN IF EXISTS oauth_refresh_token,
6868+ DROP COLUMN IF EXISTS oauth_token_expires_at,
6969+ DROP COLUMN IF EXISTS oauth_pds_url,
7070+ DROP COLUMN IF EXISTS oauth_auth_server_iss,
7171+ DROP COLUMN IF EXISTS oauth_auth_server_token_endpoint,
7272+ DROP COLUMN IF EXISTS oauth_dpop_private_key_multibase,
7373+ DROP COLUMN IF EXISTS oauth_dpop_authserver_nonce,
7474+ DROP COLUMN IF EXISTS oauth_dpop_pds_nonce,
7575+ DROP COLUMN IF EXISTS api_key_created_at,
7676+ DROP COLUMN IF EXISTS api_key_revoked_at,
7777+ DROP COLUMN IF EXISTS api_key_last_used_at;
···11+-- +goose Up
22+-- Encrypt aggregator OAuth tokens at rest using pgp_sym_encrypt
33+-- This addresses the security issue where OAuth tokens were stored in plaintext
44+-- despite migration 024 claiming "encrypted at application layer before storage"
55+66+-- +goose StatementBegin
77+88+-- Step 1: Add new encrypted columns for OAuth tokens and DPoP private key
99+ALTER TABLE aggregators
1010+ ADD COLUMN oauth_access_token_encrypted BYTEA,
1111+ ADD COLUMN oauth_refresh_token_encrypted BYTEA,
1212+ ADD COLUMN oauth_dpop_private_key_encrypted BYTEA;
1313+1414+-- Step 2: Migrate existing plaintext data to encrypted columns
1515+-- Uses the same encryption key table as community credentials (migration 006)
1616+UPDATE aggregators
1717+SET
1818+ oauth_access_token_encrypted = CASE
1919+ WHEN oauth_access_token IS NOT NULL AND oauth_access_token != ''
2020+ THEN pgp_sym_encrypt(oauth_access_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
2121+ ELSE NULL
2222+ END,
2323+ oauth_refresh_token_encrypted = CASE
2424+ WHEN oauth_refresh_token IS NOT NULL AND oauth_refresh_token != ''
2525+ THEN pgp_sym_encrypt(oauth_refresh_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
2626+ ELSE NULL
2727+ END,
2828+ oauth_dpop_private_key_encrypted = CASE
2929+ WHEN oauth_dpop_private_key_multibase IS NOT NULL AND oauth_dpop_private_key_multibase != ''
3030+ THEN pgp_sym_encrypt(oauth_dpop_private_key_multibase, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
3131+ ELSE NULL
3232+ END
3333+WHERE oauth_access_token IS NOT NULL
3434+ OR oauth_refresh_token IS NOT NULL
3535+ OR oauth_dpop_private_key_multibase IS NOT NULL;
3636+3737+-- Step 3: Drop the old plaintext columns
3838+ALTER TABLE aggregators
3939+ DROP COLUMN oauth_access_token,
4040+ DROP COLUMN oauth_refresh_token,
4141+ DROP COLUMN oauth_dpop_private_key_multibase;
4242+4343+-- Step 4: Add security comments
4444+COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: Encrypted OAuth access token (pgp_sym_encrypt) for PDS operations';
4545+COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: Encrypted OAuth refresh token (pgp_sym_encrypt) for session renewal';
4646+COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: Encrypted DPoP private key (pgp_sym_encrypt) for token refresh';
4747+4848+-- +goose StatementEnd
4949+5050+-- +goose Down
5151+-- +goose StatementBegin
5252+5353+-- Restore plaintext columns
5454+ALTER TABLE aggregators
5555+ ADD COLUMN oauth_access_token TEXT,
5656+ ADD COLUMN oauth_refresh_token TEXT,
5757+ ADD COLUMN oauth_dpop_private_key_multibase TEXT;
5858+5959+-- Decrypt data back to plaintext (for rollback)
6060+UPDATE aggregators
6161+SET
6262+ oauth_access_token = CASE
6363+ WHEN oauth_access_token_encrypted IS NOT NULL
6464+ THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
6565+ ELSE NULL
6666+ END,
6767+ oauth_refresh_token = CASE
6868+ WHEN oauth_refresh_token_encrypted IS NOT NULL
6969+ THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
7070+ ELSE NULL
7171+ END,
7272+ oauth_dpop_private_key_multibase = CASE
7373+ WHEN oauth_dpop_private_key_encrypted IS NOT NULL
7474+ THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
7575+ ELSE NULL
7676+ END
7777+WHERE oauth_access_token_encrypted IS NOT NULL
7878+ OR oauth_refresh_token_encrypted IS NOT NULL
7979+ OR oauth_dpop_private_key_encrypted IS NOT NULL;
8080+8181+-- Drop encrypted columns
8282+ALTER TABLE aggregators
8383+ DROP COLUMN oauth_access_token_encrypted,
8484+ DROP COLUMN oauth_refresh_token_encrypted,
8585+ DROP COLUMN oauth_dpop_private_key_encrypted;
8686+8787+-- Restore comments
8888+COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: OAuth access token for PDS operations';
8989+COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: OAuth refresh token for session renewal';
9090+COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh';
9191+9292+-- +goose StatementEnd
+415-6
internal/db/postgres/aggregator_repo.go
···6969}
70707171// GetAggregator retrieves an aggregator by DID
7272+// Returns only public/display fields - use GetAggregatorCredentials for authentication data
7273func (r *postgresAggregatorRepo) GetAggregator(ctx context.Context, did string) (*aggregators.Aggregator, error) {
7374 query := `
7475 SELECT
···7980 WHERE did = $1`
80818182 agg := &aggregators.Aggregator{}
8282- var description, avatarCID, maintainerDID, homepageURL, recordURI, recordCID sql.NullString
8383+ var description, avatarURL, maintainerDID, sourceURL, recordURI, recordCID sql.NullString
8384 var configSchema []byte
84858586 err := r.db.QueryRowContext(ctx, query, did).Scan(
8687 &agg.DID,
8788 &agg.DisplayName,
8889 &description,
8989- &avatarCID,
9090+ &avatarURL,
9091 &configSchema,
9192 &maintainerDID,
9292- &homepageURL,
9393+ &sourceURL,
9394 &agg.CommunitiesUsing,
9495 &agg.PostsCreated,
9596 &agg.CreatedAt,
···105106 return nil, fmt.Errorf("failed to get aggregator: %w", err)
106107 }
107108108108- // Map nullable fields
109109+ // Map nullable string fields
109110 agg.Description = description.String
110110- agg.AvatarURL = avatarCID.String
111111+ agg.AvatarURL = avatarURL.String
111112 agg.MaintainerDID = maintainerDID.String
112112- agg.SourceURL = homepageURL.String
113113+ agg.SourceURL = sourceURL.String
113114 agg.RecordURI = recordURI.String
114115 agg.RecordCID = recordCID.String
116116+115117 if configSchema != nil {
116118 agg.ConfigSchema = configSchema
117119 }
···753755 }
754756755757 return posts, nil
758758+}
759759+760760+// ===== API Key Authentication Operations =====
761761+762762+// GetByAPIKeyHash looks up an aggregator by their API key hash for authentication
763763+// Returns ErrAggregatorNotFound if no aggregator exists with that key hash
764764+// Returns ErrAPIKeyRevoked if the API key has been revoked
765765+// Note: Returns only public Aggregator fields - use GetCredentialsByAPIKeyHash for credentials
766766+func (r *postgresAggregatorRepo) GetByAPIKeyHash(ctx context.Context, keyHash string) (*aggregators.Aggregator, error) {
767767+ query := `
768768+ SELECT
769769+ did, display_name, description, avatar_url, config_schema,
770770+ maintainer_did, source_url, communities_using, posts_created,
771771+ created_at, indexed_at, record_uri, record_cid,
772772+ api_key_revoked_at
773773+ FROM aggregators
774774+ WHERE api_key_hash = $1`
775775+776776+ agg := &aggregators.Aggregator{}
777777+ var description, avatarURL, maintainerDID, sourceURL, recordURI, recordCID sql.NullString
778778+ var configSchema []byte
779779+ var apiKeyRevokedAt sql.NullTime
780780+781781+ err := r.db.QueryRowContext(ctx, query, keyHash).Scan(
782782+ &agg.DID,
783783+ &agg.DisplayName,
784784+ &description,
785785+ &avatarURL,
786786+ &configSchema,
787787+ &maintainerDID,
788788+ &sourceURL,
789789+ &agg.CommunitiesUsing,
790790+ &agg.PostsCreated,
791791+ &agg.CreatedAt,
792792+ &agg.IndexedAt,
793793+ &recordURI,
794794+ &recordCID,
795795+ &apiKeyRevokedAt,
796796+ )
797797+798798+ if err == sql.ErrNoRows {
799799+ return nil, aggregators.ErrAggregatorNotFound
800800+ }
801801+ if err != nil {
802802+ return nil, fmt.Errorf("failed to get aggregator by API key hash: %w", err)
803803+ }
804804+805805+ // Check if API key is revoked before returning
806806+ if apiKeyRevokedAt.Valid {
807807+ return nil, aggregators.ErrAPIKeyRevoked
808808+ }
809809+810810+ // Map nullable string fields
811811+ agg.Description = description.String
812812+ agg.AvatarURL = avatarURL.String
813813+ agg.MaintainerDID = maintainerDID.String
814814+ agg.SourceURL = sourceURL.String
815815+ agg.RecordURI = recordURI.String
816816+ agg.RecordCID = recordCID.String
817817+818818+ if configSchema != nil {
819819+ agg.ConfigSchema = configSchema
820820+ }
821821+822822+ return agg, nil
823823+}
824824+825825+// SetAPIKey stores API key credentials and OAuth session for an aggregator
826826+// This is called after successful OAuth flow to generate the API key
827827+// SECURITY: OAuth tokens and DPoP private key are encrypted at rest using pgp_sym_encrypt
828828+func (r *postgresAggregatorRepo) SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *aggregators.OAuthCredentials) error {
829829+ query := `
830830+ UPDATE aggregators SET
831831+ api_key_prefix = $2,
832832+ api_key_hash = $3,
833833+ api_key_created_at = NOW(),
834834+ api_key_revoked_at = NULL,
835835+ oauth_access_token_encrypted = CASE WHEN $4 != '' THEN pgp_sym_encrypt($4, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END,
836836+ oauth_refresh_token_encrypted = CASE WHEN $5 != '' THEN pgp_sym_encrypt($5, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END,
837837+ oauth_token_expires_at = $6,
838838+ oauth_pds_url = $7,
839839+ oauth_auth_server_iss = $8,
840840+ oauth_auth_server_token_endpoint = $9,
841841+ oauth_dpop_private_key_encrypted = CASE WHEN $10 != '' THEN pgp_sym_encrypt($10, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END,
842842+ oauth_dpop_authserver_nonce = $11,
843843+ oauth_dpop_pds_nonce = $12
844844+ WHERE did = $1`
845845+846846+ result, err := r.db.ExecContext(ctx, query,
847847+ did,
848848+ keyPrefix,
849849+ keyHash,
850850+ oauthCreds.AccessToken,
851851+ oauthCreds.RefreshToken,
852852+ oauthCreds.TokenExpiresAt,
853853+ oauthCreds.PDSURL,
854854+ oauthCreds.AuthServerIss,
855855+ oauthCreds.AuthServerTokenEndpoint,
856856+ oauthCreds.DPoPPrivateKeyMultibase,
857857+ oauthCreds.DPoPAuthServerNonce,
858858+ oauthCreds.DPoPPDSNonce,
859859+ )
860860+ if err != nil {
861861+ return fmt.Errorf("failed to set API key: %w", err)
862862+ }
863863+864864+ rows, err := result.RowsAffected()
865865+ if err != nil {
866866+ return fmt.Errorf("failed to get rows affected: %w", err)
867867+ }
868868+ if rows == 0 {
869869+ return aggregators.ErrAggregatorNotFound
870870+ }
871871+872872+ return nil
873873+}
874874+875875+// UpdateOAuthTokens updates OAuth tokens after a refresh operation
876876+// Called after successfully refreshing an expired access token
877877+// SECURITY: OAuth tokens are encrypted at rest using pgp_sym_encrypt
878878+func (r *postgresAggregatorRepo) UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error {
879879+ query := `
880880+ UPDATE aggregators SET
881881+ oauth_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
882882+ oauth_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
883883+ oauth_token_expires_at = $4
884884+ WHERE did = $1`
885885+886886+ result, err := r.db.ExecContext(ctx, query, did, accessToken, refreshToken, expiresAt)
887887+ if err != nil {
888888+ return fmt.Errorf("failed to update OAuth tokens: %w", err)
889889+ }
890890+891891+ rows, err := result.RowsAffected()
892892+ if err != nil {
893893+ return fmt.Errorf("failed to get rows affected: %w", err)
894894+ }
895895+ if rows == 0 {
896896+ return aggregators.ErrAggregatorNotFound
897897+ }
898898+899899+ return nil
900900+}
901901+902902+// UpdateOAuthNonces updates DPoP nonces after token operations
903903+// Nonces are updated after each request to the auth server or PDS
904904+func (r *postgresAggregatorRepo) UpdateOAuthNonces(ctx context.Context, did, authServerNonce, pdsNonce string) error {
905905+ query := `
906906+ UPDATE aggregators SET
907907+ oauth_dpop_authserver_nonce = COALESCE(NULLIF($2, ''), oauth_dpop_authserver_nonce),
908908+ oauth_dpop_pds_nonce = COALESCE(NULLIF($3, ''), oauth_dpop_pds_nonce)
909909+ WHERE did = $1`
910910+911911+ result, err := r.db.ExecContext(ctx, query, did, authServerNonce, pdsNonce)
912912+ if err != nil {
913913+ return fmt.Errorf("failed to update OAuth nonces: %w", err)
914914+ }
915915+916916+ rows, err := result.RowsAffected()
917917+ if err != nil {
918918+ return fmt.Errorf("failed to get rows affected: %w", err)
919919+ }
920920+ if rows == 0 {
921921+ return aggregators.ErrAggregatorNotFound
922922+ }
923923+924924+ return nil
925925+}
926926+927927+// UpdateAPIKeyLastUsed updates the last_used_at timestamp for audit purposes
928928+// Called on each successful authentication to track API key usage
929929+func (r *postgresAggregatorRepo) UpdateAPIKeyLastUsed(ctx context.Context, did string) error {
930930+ query := `
931931+ UPDATE aggregators SET
932932+ api_key_last_used_at = NOW()
933933+ WHERE did = $1`
934934+935935+ result, err := r.db.ExecContext(ctx, query, did)
936936+ if err != nil {
937937+ return fmt.Errorf("failed to update API key last used: %w", err)
938938+ }
939939+940940+ rows, err := result.RowsAffected()
941941+ if err != nil {
942942+ return fmt.Errorf("failed to get rows affected: %w", err)
943943+ }
944944+ if rows == 0 {
945945+ return aggregators.ErrAggregatorNotFound
946946+ }
947947+948948+ return nil
949949+}
950950+951951+// RevokeAPIKey marks an API key as revoked (sets api_key_revoked_at)
952952+// After revocation, the aggregator must complete OAuth flow again to get a new key
953953+func (r *postgresAggregatorRepo) RevokeAPIKey(ctx context.Context, did string) error {
954954+ query := `
955955+ UPDATE aggregators SET
956956+ api_key_revoked_at = NOW()
957957+ WHERE did = $1 AND api_key_hash IS NOT NULL`
958958+959959+ result, err := r.db.ExecContext(ctx, query, did)
960960+ if err != nil {
961961+ return fmt.Errorf("failed to revoke API key: %w", err)
962962+ }
963963+964964+ rows, err := result.RowsAffected()
965965+ if err != nil {
966966+ return fmt.Errorf("failed to get rows affected: %w", err)
967967+ }
968968+ if rows == 0 {
969969+ return aggregators.ErrAggregatorNotFound
970970+ }
971971+972972+ return nil
973973+}
974974+975975+// GetAggregatorCredentials retrieves only credential data for an aggregator
976976+// Used by APIKeyService for authentication operations where full aggregator is not needed
977977+func (r *postgresAggregatorRepo) GetAggregatorCredentials(ctx context.Context, did string) (*aggregators.AggregatorCredentials, error) {
978978+ query := `
979979+ SELECT
980980+ did,
981981+ api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at,
982982+ CASE
983983+ WHEN oauth_access_token_encrypted IS NOT NULL
984984+ THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
985985+ ELSE NULL
986986+ END as oauth_access_token,
987987+ CASE
988988+ WHEN oauth_refresh_token_encrypted IS NOT NULL
989989+ THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
990990+ ELSE NULL
991991+ END as oauth_refresh_token,
992992+ oauth_token_expires_at,
993993+ oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint,
994994+ CASE
995995+ WHEN oauth_dpop_private_key_encrypted IS NOT NULL
996996+ THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
997997+ ELSE NULL
998998+ END as oauth_dpop_private_key_multibase,
999999+ oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce
10001000+ FROM aggregators
10011001+ WHERE did = $1`
10021002+10031003+ creds := &aggregators.AggregatorCredentials{}
10041004+ var apiKeyPrefix, apiKeyHash sql.NullString
10051005+ var oauthAccessToken, oauthRefreshToken sql.NullString
10061006+ var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString
10071007+ var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString
10081008+ var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime
10091009+10101010+ err := r.db.QueryRowContext(ctx, query, did).Scan(
10111011+ &creds.DID,
10121012+ &apiKeyPrefix,
10131013+ &apiKeyHash,
10141014+ &apiKeyCreatedAt,
10151015+ &apiKeyRevokedAt,
10161016+ &apiKeyLastUsed,
10171017+ &oauthAccessToken,
10181018+ &oauthRefreshToken,
10191019+ &oauthTokenExpiresAt,
10201020+ &oauthPDSURL,
10211021+ &oauthAuthServerIss,
10221022+ &oauthAuthServerTokenEndpoint,
10231023+ &oauthDPoPPrivateKey,
10241024+ &oauthDPoPAuthServerNonce,
10251025+ &oauthDPoPPDSNonce,
10261026+ )
10271027+10281028+ if err == sql.ErrNoRows {
10291029+ return nil, aggregators.ErrAggregatorNotFound
10301030+ }
10311031+ if err != nil {
10321032+ return nil, fmt.Errorf("failed to get aggregator credentials: %w", err)
10331033+ }
10341034+10351035+ // Map nullable string fields
10361036+ creds.APIKeyPrefix = apiKeyPrefix.String
10371037+ creds.APIKeyHash = apiKeyHash.String
10381038+ creds.OAuthAccessToken = oauthAccessToken.String
10391039+ creds.OAuthRefreshToken = oauthRefreshToken.String
10401040+ creds.OAuthPDSURL = oauthPDSURL.String
10411041+ creds.OAuthAuthServerIss = oauthAuthServerIss.String
10421042+ creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String
10431043+ creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String
10441044+ creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String
10451045+ creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String
10461046+10471047+ // Map nullable time fields
10481048+ if apiKeyCreatedAt.Valid {
10491049+ t := apiKeyCreatedAt.Time
10501050+ creds.APIKeyCreatedAt = &t
10511051+ }
10521052+ if apiKeyRevokedAt.Valid {
10531053+ t := apiKeyRevokedAt.Time
10541054+ creds.APIKeyRevokedAt = &t
10551055+ }
10561056+ if apiKeyLastUsed.Valid {
10571057+ t := apiKeyLastUsed.Time
10581058+ creds.APIKeyLastUsed = &t
10591059+ }
10601060+ if oauthTokenExpiresAt.Valid {
10611061+ t := oauthTokenExpiresAt.Time
10621062+ creds.OAuthTokenExpiresAt = &t
10631063+ }
10641064+10651065+ return creds, nil
10661066+}
10671067+10681068+// GetCredentialsByAPIKeyHash looks up credentials by API key hash for authentication
10691069+// Returns ErrAPIKeyRevoked if the API key has been revoked
10701070+// Returns ErrAPIKeyInvalid if no aggregator found with that hash
10711071+func (r *postgresAggregatorRepo) GetCredentialsByAPIKeyHash(ctx context.Context, keyHash string) (*aggregators.AggregatorCredentials, error) {
10721072+ query := `
10731073+ SELECT
10741074+ did,
10751075+ api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at,
10761076+ CASE
10771077+ WHEN oauth_access_token_encrypted IS NOT NULL
10781078+ THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
10791079+ ELSE NULL
10801080+ END as oauth_access_token,
10811081+ CASE
10821082+ WHEN oauth_refresh_token_encrypted IS NOT NULL
10831083+ THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
10841084+ ELSE NULL
10851085+ END as oauth_refresh_token,
10861086+ oauth_token_expires_at,
10871087+ oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint,
10881088+ CASE
10891089+ WHEN oauth_dpop_private_key_encrypted IS NOT NULL
10901090+ THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
10911091+ ELSE NULL
10921092+ END as oauth_dpop_private_key_multibase,
10931093+ oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce
10941094+ FROM aggregators
10951095+ WHERE api_key_hash = $1`
10961096+10971097+ creds := &aggregators.AggregatorCredentials{}
10981098+ var apiKeyPrefix, apiKeyHash sql.NullString
10991099+ var oauthAccessToken, oauthRefreshToken sql.NullString
11001100+ var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString
11011101+ var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString
11021102+ var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime
11031103+11041104+ err := r.db.QueryRowContext(ctx, query, keyHash).Scan(
11051105+ &creds.DID,
11061106+ &apiKeyPrefix,
11071107+ &apiKeyHash,
11081108+ &apiKeyCreatedAt,
11091109+ &apiKeyRevokedAt,
11101110+ &apiKeyLastUsed,
11111111+ &oauthAccessToken,
11121112+ &oauthRefreshToken,
11131113+ &oauthTokenExpiresAt,
11141114+ &oauthPDSURL,
11151115+ &oauthAuthServerIss,
11161116+ &oauthAuthServerTokenEndpoint,
11171117+ &oauthDPoPPrivateKey,
11181118+ &oauthDPoPAuthServerNonce,
11191119+ &oauthDPoPPDSNonce,
11201120+ )
11211121+11221122+ if err == sql.ErrNoRows {
11231123+ return nil, aggregators.ErrAPIKeyInvalid
11241124+ }
11251125+ if err != nil {
11261126+ return nil, fmt.Errorf("failed to get credentials by API key hash: %w", err)
11271127+ }
11281128+11291129+ // Map nullable string fields
11301130+ creds.APIKeyPrefix = apiKeyPrefix.String
11311131+ creds.APIKeyHash = apiKeyHash.String
11321132+ creds.OAuthAccessToken = oauthAccessToken.String
11331133+ creds.OAuthRefreshToken = oauthRefreshToken.String
11341134+ creds.OAuthPDSURL = oauthPDSURL.String
11351135+ creds.OAuthAuthServerIss = oauthAuthServerIss.String
11361136+ creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String
11371137+ creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String
11381138+ creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String
11391139+ creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String
11401140+11411141+ // Map nullable time fields
11421142+ if apiKeyCreatedAt.Valid {
11431143+ t := apiKeyCreatedAt.Time
11441144+ creds.APIKeyCreatedAt = &t
11451145+ }
11461146+ if apiKeyRevokedAt.Valid {
11471147+ t := apiKeyRevokedAt.Time
11481148+ creds.APIKeyRevokedAt = &t
11491149+ }
11501150+ if apiKeyLastUsed.Valid {
11511151+ t := apiKeyLastUsed.Time
11521152+ creds.APIKeyLastUsed = &t
11531153+ }
11541154+ if oauthTokenExpiresAt.Valid {
11551155+ t := oauthTokenExpiresAt.Time
11561156+ creds.OAuthTokenExpiresAt = &t
11571157+ }
11581158+11591159+ // Check if API key is revoked
11601160+ if creds.APIKeyRevokedAt != nil {
11611161+ return nil, aggregators.ErrAPIKeyRevoked
11621162+ }
11631163+11641164+ return creds, nil
7561165}
75711667581167// ===== Helper Functions =====