A community based topic aggregation platform built on atproto
1package oauth
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12
13 "github.com/lestrrat-go/jwx/v2/jwk"
14)
15
16// Client handles atProto OAuth flows (PAR, PKCE, DPoP)
17type Client struct {
18 clientID string
19 clientJWK jwk.Key
20 redirectURI string
21 httpClient *http.Client
22}
23
24// NewClient creates a new OAuth client
25func NewClient(clientID string, clientJWK jwk.Key, redirectURI string) *Client {
26 return &Client{
27 clientID: clientID,
28 clientJWK: clientJWK,
29 redirectURI: redirectURI,
30 httpClient: &http.Client{
31 Timeout: 30 * time.Second,
32 },
33 }
34}
35
36// AuthServerMetadata represents OAuth 2.0 authorization server metadata (RFC 8414)
37type AuthServerMetadata struct {
38 Issuer string `json:"issuer"`
39 AuthorizationEndpoint string `json:"authorization_endpoint"`
40 TokenEndpoint string `json:"token_endpoint"`
41 PushedAuthReqEndpoint string `json:"pushed_authorization_request_endpoint"`
42 JWKSURI string `json:"jwks_uri"`
43 GrantTypesSupported []string `json:"grant_types_supported"`
44 ResponseTypesSupported []string `json:"response_types_supported"`
45 CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
46 DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
47}
48
49// ResolvePDSAuthServer resolves the authorization server for a PDS
50// Follows the PDS → Authorization Server discovery flow
51func (c *Client) ResolvePDSAuthServer(ctx context.Context, pdsURL string) (string, error) {
52 // Fetch PDS metadata from /.well-known/oauth-protected-resource
53 metadataURL := strings.TrimSuffix(pdsURL, "/") + "/.well-known/oauth-protected-resource"
54
55 req, err := http.NewRequestWithContext(ctx, "GET", metadataURL, nil)
56 if err != nil {
57 return "", fmt.Errorf("failed to create request: %w", err)
58 }
59
60 resp, err := c.httpClient.Do(req)
61 if err != nil {
62 return "", fmt.Errorf("failed to fetch PDS metadata: %w", err)
63 }
64 defer func() { _ = resp.Body.Close() }() //nolint:errcheck
65
66 if resp.StatusCode != http.StatusOK {
67 return "", fmt.Errorf("PDS returned status %d", resp.StatusCode)
68 }
69
70 var metadata struct {
71 AuthorizationServers []string `json:"authorization_servers"`
72 }
73
74 if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
75 return "", fmt.Errorf("failed to decode PDS metadata: %w", err)
76 }
77
78 if len(metadata.AuthorizationServers) == 0 {
79 return "", fmt.Errorf("no authorization servers found for PDS")
80 }
81
82 // Return the first (primary) authorization server
83 return metadata.AuthorizationServers[0], nil
84}
85
86// FetchAuthServerMetadata fetches OAuth 2.0 authorization server metadata
87func (c *Client) FetchAuthServerMetadata(ctx context.Context, issuer string) (*AuthServerMetadata, error) {
88 // OAuth 2.0 discovery endpoint
89 metadataURL := strings.TrimSuffix(issuer, "/") + "/.well-known/oauth-authorization-server"
90
91 req, err := http.NewRequestWithContext(ctx, "GET", metadataURL, nil)
92 if err != nil {
93 return nil, fmt.Errorf("failed to create request: %w", err)
94 }
95
96 resp, err := c.httpClient.Do(req)
97 if err != nil {
98 return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
99 }
100 defer func() { _ = resp.Body.Close() }() //nolint:errcheck
101
102 if resp.StatusCode != http.StatusOK {
103 return nil, fmt.Errorf("auth server returned status %d", resp.StatusCode)
104 }
105
106 var metadata AuthServerMetadata
107 if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
108 return nil, fmt.Errorf("failed to decode auth server metadata: %w", err)
109 }
110
111 return &metadata, nil
112}
113
114// PARResponse represents the response from a Pushed Authorization Request
115type PARResponse struct {
116 RequestURI string `json:"request_uri"`
117 ExpiresIn int `json:"expires_in"`
118 State string // Generated by client
119 PKCEVerifier string // Generated by client
120 DpopAuthserverNonce string // From response header (if provided)
121}
122
123// SendPARRequest sends a Pushed Authorization Request (PAR) - RFC 9126
124// This pre-registers the authorization request with the server
125func (c *Client) SendPARRequest(ctx context.Context, authMeta *AuthServerMetadata, handle, scope string, dpopKey jwk.Key) (*PARResponse, error) {
126 // Generate PKCE challenge
127 pkce, err := GeneratePKCEChallenge()
128 if err != nil {
129 return nil, fmt.Errorf("failed to generate PKCE: %w", err)
130 }
131
132 // Generate state
133 state, err := GenerateState()
134 if err != nil {
135 return nil, fmt.Errorf("failed to generate state: %w", err)
136 }
137
138 // Create form data
139 data := url.Values{}
140 data.Set("client_id", c.clientID)
141 data.Set("redirect_uri", c.redirectURI)
142 data.Set("response_type", "code")
143 data.Set("scope", scope)
144 data.Set("state", state)
145 data.Set("code_challenge", pkce.Challenge)
146 data.Set("code_challenge_method", pkce.Method)
147 data.Set("login_hint", handle) // atProto-specific: suggests which account to use
148
149 // Create DPoP proof for PAR endpoint
150 dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.PushedAuthReqEndpoint, "", "")
151 if err != nil {
152 return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
153 }
154
155 // Send PAR request
156 req, err := http.NewRequestWithContext(ctx, "POST", authMeta.PushedAuthReqEndpoint, strings.NewReader(data.Encode()))
157 if err != nil {
158 return nil, fmt.Errorf("failed to create request: %w", err)
159 }
160
161 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
162 req.Header.Set("DPoP", dpopProof)
163
164 resp, err := c.httpClient.Do(req)
165 if err != nil {
166 return nil, fmt.Errorf("failed to send PAR request: %w", err)
167 }
168 defer func() { _ = resp.Body.Close() }() //nolint:errcheck
169
170 body, err := io.ReadAll(resp.Body)
171 if err != nil {
172 return nil, fmt.Errorf("failed to read PAR response: %w", err)
173 }
174
175 // Handle DPoP nonce requirement (RFC 9449 Section 8)
176 // If server returns use_dpop_nonce error, retry with the nonce
177 if resp.StatusCode == http.StatusBadRequest {
178 var errorResp struct {
179 Error string `json:"error"`
180 ErrorDescription string `json:"error_description"`
181 }
182 if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error == "use_dpop_nonce" {
183 // Get nonce from response header
184 nonce := resp.Header.Get("DPoP-Nonce")
185 if nonce != "" {
186 // Retry with nonce
187 dpopProof, err = CreateDPoPProof(dpopKey, "POST", authMeta.PushedAuthReqEndpoint, nonce, "")
188 if err != nil {
189 return nil, fmt.Errorf("failed to create DPoP proof with nonce: %w", err)
190 }
191
192 // Re-create request with new DPoP proof
193 req, err = http.NewRequestWithContext(ctx, "POST", authMeta.PushedAuthReqEndpoint, strings.NewReader(data.Encode()))
194 if err != nil {
195 return nil, fmt.Errorf("failed to create retry request: %w", err)
196 }
197 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
198 req.Header.Set("DPoP", dpopProof)
199
200 // Send retry request
201 resp, err = c.httpClient.Do(req)
202 if err != nil {
203 return nil, fmt.Errorf("failed to send retry PAR request: %w", err)
204 }
205 defer func() { _ = resp.Body.Close() }() //nolint:errcheck
206 body, err = io.ReadAll(resp.Body)
207 if err != nil {
208 return nil, fmt.Errorf("failed to read retry PAR response: %w", err)
209 }
210 }
211 }
212 }
213
214 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
215 return nil, fmt.Errorf("PAR request failed with status %d", resp.StatusCode)
216 }
217
218 var parResp struct {
219 RequestURI string `json:"request_uri"`
220 ExpiresIn int `json:"expires_in"`
221 }
222
223 if err := json.Unmarshal(body, &parResp); err != nil {
224 return nil, fmt.Errorf("failed to decode PAR response: %w", err)
225 }
226
227 // Extract DPoP nonce from response header (if provided)
228 dpopNonce := resp.Header.Get("DPoP-Nonce")
229
230 return &PARResponse{
231 RequestURI: parResp.RequestURI,
232 ExpiresIn: parResp.ExpiresIn,
233 State: state,
234 PKCEVerifier: pkce.Verifier,
235 DpopAuthserverNonce: dpopNonce,
236 }, nil
237}
238
239// TokenResponse represents an OAuth token response
240type TokenResponse struct {
241 AccessToken string `json:"access_token"`
242 TokenType string `json:"token_type"` // Should be "DPoP"
243 ExpiresIn int `json:"expires_in"`
244 RefreshToken string `json:"refresh_token"`
245 Scope string `json:"scope"`
246 Sub string `json:"sub"` // DID of the user
247 DpopAuthserverNonce string // From response header
248}
249
250// InitialTokenRequest exchanges authorization code for tokens (DPoP-bound)
251func (c *Client) InitialTokenRequest(ctx context.Context, code, issuer, pkceVerifier, dpopNonce string, dpopKey jwk.Key) (*TokenResponse, error) {
252 // Get auth server metadata for token endpoint
253 authMeta, err := c.FetchAuthServerMetadata(ctx, issuer)
254 if err != nil {
255 return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
256 }
257
258 // Create form data
259 data := url.Values{}
260 data.Set("grant_type", "authorization_code")
261 data.Set("code", code)
262 data.Set("redirect_uri", c.redirectURI)
263 data.Set("code_verifier", pkceVerifier)
264 data.Set("client_id", c.clientID)
265
266 // Create DPoP proof for token endpoint
267 dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.TokenEndpoint, dpopNonce, "")
268 if err != nil {
269 return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
270 }
271
272 // Send token request
273 req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(data.Encode()))
274 if err != nil {
275 return nil, fmt.Errorf("failed to create request: %w", err)
276 }
277
278 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
279 req.Header.Set("DPoP", dpopProof)
280
281 resp, err := c.httpClient.Do(req)
282 if err != nil {
283 return nil, fmt.Errorf("failed to send token request: %w", err)
284 }
285 defer func() { _ = resp.Body.Close() }() //nolint:errcheck
286
287 var tokenResp TokenResponse
288 if resp.StatusCode != http.StatusOK {
289 return nil, fmt.Errorf("token request failed with status %d", resp.StatusCode)
290 }
291
292 if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
293 return nil, fmt.Errorf("failed to decode token response: %w", err)
294 }
295
296 // Extract updated DPoP nonce
297 tokenResp.DpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
298
299 return &tokenResp, nil
300}
301
302// RefreshTokenRequest refreshes an access token using a refresh token
303func (c *Client) RefreshTokenRequest(ctx context.Context, refreshToken, issuer, dpopNonce string, dpopKey jwk.Key) (*TokenResponse, error) {
304 // Get auth server metadata for token endpoint
305 authMeta, err := c.FetchAuthServerMetadata(ctx, issuer)
306 if err != nil {
307 return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
308 }
309
310 // Create form data
311 data := url.Values{}
312 data.Set("grant_type", "refresh_token")
313 data.Set("refresh_token", refreshToken)
314 data.Set("client_id", c.clientID)
315
316 // Create DPoP proof for token endpoint
317 dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.TokenEndpoint, dpopNonce, "")
318 if err != nil {
319 return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
320 }
321
322 // Send refresh request
323 req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(data.Encode()))
324 if err != nil {
325 return nil, fmt.Errorf("failed to create request: %w", err)
326 }
327
328 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
329 req.Header.Set("DPoP", dpopProof)
330
331 resp, err := c.httpClient.Do(req)
332 if err != nil {
333 return nil, fmt.Errorf("failed to send refresh request: %w", err)
334 }
335 defer func() { _ = resp.Body.Close() }() //nolint:errcheck
336
337 var tokenResp TokenResponse
338 if resp.StatusCode != http.StatusOK {
339 return nil, fmt.Errorf("refresh request failed with status %d", resp.StatusCode)
340 }
341
342 if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
343 return nil, fmt.Errorf("failed to decode token response: %w", err)
344 }
345
346 // Extract updated DPoP nonce
347 tokenResp.DpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
348
349 return &tokenResp, nil
350}