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