A community based topic aggregation platform built on atproto
1package xrpc 2 3import ( 4 "fmt" 5 "net/http" 6 "sync" 7 8 "Coves/internal/atproto/oauth" 9 oauthCore "Coves/internal/core/oauth" 10 11 "github.com/lestrrat-go/jwx/v2/jwk" 12) 13 14// DPoPTransport is an http.RoundTripper that automatically adds DPoP proofs to requests 15// It intercepts HTTP requests and: 16// 1. Adds Authorization: DPoP <access_token> 17// 2. Creates and adds DPoP proof JWT 18// 3. Handles nonce rotation (retries on 401 with new nonce) 19// 4. Updates nonces in session store 20type DPoPTransport struct { 21 base http.RoundTripper // Underlying transport (usually http.DefaultTransport) 22 session *oauthCore.OAuthSession // User's OAuth session 23 sessionStore oauthCore.SessionStore // For updating nonces 24 dpopKey jwk.Key // Parsed DPoP private key 25 mu sync.Mutex // Protects nonce updates 26} 27 28// NewDPoPTransport creates a new DPoP-enabled HTTP transport 29func NewDPoPTransport(base http.RoundTripper, session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*DPoPTransport, error) { 30 if base == nil { 31 base = http.DefaultTransport 32 } 33 34 // Parse DPoP private key from session 35 dpopKey, err := oauth.ParseJWKFromJSON([]byte(session.DPoPPrivateJWK)) 36 if err != nil { 37 return nil, fmt.Errorf("failed to parse DPoP key: %w", err) 38 } 39 40 return &DPoPTransport{ 41 base: base, 42 session: session, 43 sessionStore: sessionStore, 44 dpopKey: dpopKey, 45 }, nil 46} 47 48// RoundTrip implements http.RoundTripper 49// This is called for every HTTP request made by the client 50func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) { 51 // Clone the request (don't modify original) 52 req = req.Clone(req.Context()) 53 54 // Add Authorization header with DPoP-bound access token 55 req.Header.Set("Authorization", "DPoP "+t.session.AccessToken) 56 57 // Determine which nonce to use based on the target URL 58 nonce := t.getDPoPNonce(req.URL.String()) 59 60 // Create DPoP proof for this specific request 61 dpopProof, err := oauth.CreateDPoPProof( 62 t.dpopKey, 63 req.Method, 64 req.URL.String(), 65 nonce, 66 t.session.AccessToken, 67 ) 68 if err != nil { 69 return nil, fmt.Errorf("failed to create DPoP proof: %w", err) 70 } 71 72 // Add DPoP proof header 73 req.Header.Set("DPoP", dpopProof) 74 75 // Execute the request 76 resp, err := t.base.RoundTrip(req) 77 if err != nil { 78 return nil, err 79 } 80 81 // Handle DPoP nonce rotation 82 if resp.StatusCode == http.StatusUnauthorized { 83 // Check if server provided a new nonce 84 newNonce := resp.Header.Get("DPoP-Nonce") 85 if newNonce != "" { 86 // Update nonce and retry request once 87 t.updateDPoPNonce(req.URL.String(), newNonce) 88 89 // Close the 401 response body 90 _ = resp.Body.Close() 91 92 // Retry with new nonce 93 return t.retryWithNewNonce(req, newNonce) 94 } 95 } 96 97 // Check for nonce update even on successful responses 98 if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" { 99 t.updateDPoPNonce(req.URL.String(), newNonce) 100 } 101 102 return resp, nil 103} 104 105// getDPoPNonce determines which DPoP nonce to use for a given URL 106func (t *DPoPTransport) getDPoPNonce(url string) string { 107 t.mu.Lock() 108 defer t.mu.Unlock() 109 110 // If URL is to the PDS, use PDS nonce 111 if contains(url, t.session.PDSURL) { 112 return t.session.DPoPPDSNonce 113 } 114 115 // If URL is to auth server, use auth server nonce 116 if contains(url, t.session.AuthServerIss) { 117 return t.session.DPoPAuthServerNonce 118 } 119 120 // Default: no nonce (first request to this server) 121 return "" 122} 123 124// updateDPoPNonce updates the appropriate nonce based on URL 125func (t *DPoPTransport) updateDPoPNonce(url, newNonce string) { 126 t.mu.Lock() 127 128 // Read DID inside lock to avoid race condition 129 did := t.session.DID 130 131 // Update PDS nonce 132 if contains(url, t.session.PDSURL) { 133 t.session.DPoPPDSNonce = newNonce 134 t.mu.Unlock() 135 // Persist to database (async, best-effort) 136 go func() { 137 _ = t.sessionStore.UpdatePDSNonce(did, newNonce) 138 }() 139 return 140 } 141 142 // Update auth server nonce 143 if contains(url, t.session.AuthServerIss) { 144 t.session.DPoPAuthServerNonce = newNonce 145 t.mu.Unlock() 146 // Persist to database (async, best-effort) 147 go func() { 148 _ = t.sessionStore.UpdateAuthServerNonce(did, newNonce) 149 }() 150 return 151 } 152 153 t.mu.Unlock() 154} 155 156// retryWithNewNonce retries a request with an updated DPoP nonce 157func (t *DPoPTransport) retryWithNewNonce(req *http.Request, newNonce string) (*http.Response, error) { 158 // Create new DPoP proof with updated nonce 159 dpopProof, err := oauth.CreateDPoPProof( 160 t.dpopKey, 161 req.Method, 162 req.URL.String(), 163 newNonce, 164 t.session.AccessToken, 165 ) 166 if err != nil { 167 return nil, fmt.Errorf("failed to create DPoP proof on retry: %w", err) 168 } 169 170 // Update DPoP header 171 req.Header.Set("DPoP", dpopProof) 172 173 // Retry the request (only once - no infinite loops) 174 return t.base.RoundTrip(req) 175} 176 177// contains checks if haystack contains needle (case-sensitive) 178func contains(haystack, needle string) bool { 179 return len(haystack) >= len(needle) && haystack[:len(needle)] == needle || 180 len(haystack) > len(needle) && haystack[len(haystack)-len(needle):] == needle 181} 182 183// AuthenticatedClient creates an HTTP client with DPoP transport 184// This is what handlers use to make authenticated requests to the user's PDS 185func NewAuthenticatedClient(session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*http.Client, error) { 186 transport, err := NewDPoPTransport(nil, session, sessionStore) 187 if err != nil { 188 return nil, fmt.Errorf("failed to create DPoP transport: %w", err) 189 } 190 191 return &http.Client{ 192 Transport: transport, 193 }, nil 194}