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}