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}