···
13
+
"github.com/lestrrat-go/jwx/v2/jwk"
16
+
// Client handles atProto OAuth flows (PAR, PKCE, DPoP)
17
+
type Client struct {
21
+
httpClient *http.Client
24
+
// NewClient creates a new OAuth client
25
+
func NewClient(clientID string, clientJWK jwk.Key, redirectURI string) *Client {
28
+
clientJWK: clientJWK,
29
+
redirectURI: redirectURI,
30
+
httpClient: &http.Client{
31
+
Timeout: 30 * time.Second,
36
+
// AuthServerMetadata represents OAuth 2.0 authorization server metadata (RFC 8414)
37
+
type 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"`
49
+
// ResolvePDSAuthServer resolves the authorization server for a PDS
50
+
// Follows the PDS โ Authorization Server discovery flow
51
+
func (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"
55
+
req, err := http.NewRequestWithContext(ctx, "GET", metadataURL, nil)
57
+
return "", fmt.Errorf("failed to create request: %w", err)
60
+
resp, err := c.httpClient.Do(req)
62
+
return "", fmt.Errorf("failed to fetch PDS metadata: %w", err)
64
+
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
66
+
if resp.StatusCode != http.StatusOK {
67
+
return "", fmt.Errorf("PDS returned status %d", resp.StatusCode)
70
+
var metadata struct {
71
+
AuthorizationServers []string `json:"authorization_servers"`
74
+
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
75
+
return "", fmt.Errorf("failed to decode PDS metadata: %w", err)
78
+
if len(metadata.AuthorizationServers) == 0 {
79
+
return "", fmt.Errorf("no authorization servers found for PDS")
82
+
// Return the first (primary) authorization server
83
+
return metadata.AuthorizationServers[0], nil
86
+
// FetchAuthServerMetadata fetches OAuth 2.0 authorization server metadata
87
+
func (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"
91
+
req, err := http.NewRequestWithContext(ctx, "GET", metadataURL, nil)
93
+
return nil, fmt.Errorf("failed to create request: %w", err)
96
+
resp, err := c.httpClient.Do(req)
98
+
return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
100
+
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
102
+
if resp.StatusCode != http.StatusOK {
103
+
return nil, fmt.Errorf("auth server returned status %d", resp.StatusCode)
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)
111
+
return &metadata, nil
114
+
// PARResponse represents the response from a Pushed Authorization Request
115
+
type 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)
123
+
// SendPARRequest sends a Pushed Authorization Request (PAR) - RFC 9126
124
+
// This pre-registers the authorization request with the server
125
+
func (c *Client) SendPARRequest(ctx context.Context, authMeta *AuthServerMetadata, handle, scope string, dpopKey jwk.Key) (*PARResponse, error) {
126
+
// Generate PKCE challenge
127
+
pkce, err := GeneratePKCEChallenge()
129
+
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
133
+
state, err := GenerateState()
135
+
return nil, fmt.Errorf("failed to generate state: %w", err)
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
149
+
// Create DPoP proof for PAR endpoint
150
+
dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.PushedAuthReqEndpoint, "", "")
152
+
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
155
+
// Send PAR request
156
+
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.PushedAuthReqEndpoint, strings.NewReader(data.Encode()))
158
+
return nil, fmt.Errorf("failed to create request: %w", err)
161
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
162
+
req.Header.Set("DPoP", dpopProof)
164
+
resp, err := c.httpClient.Do(req)
166
+
return nil, fmt.Errorf("failed to send PAR request: %w", err)
168
+
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
170
+
body, err := io.ReadAll(resp.Body)
172
+
return nil, fmt.Errorf("failed to read PAR response: %w", err)
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"`
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")
186
+
// Retry with nonce
187
+
dpopProof, err = CreateDPoPProof(dpopKey, "POST", authMeta.PushedAuthReqEndpoint, nonce, "")
189
+
return nil, fmt.Errorf("failed to create DPoP proof with nonce: %w", err)
192
+
// Re-create request with new DPoP proof
193
+
req, err = http.NewRequestWithContext(ctx, "POST", authMeta.PushedAuthReqEndpoint, strings.NewReader(data.Encode()))
195
+
return nil, fmt.Errorf("failed to create retry request: %w", err)
197
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
198
+
req.Header.Set("DPoP", dpopProof)
200
+
// Send retry request
201
+
resp, err = c.httpClient.Do(req)
203
+
return nil, fmt.Errorf("failed to send retry PAR request: %w", err)
205
+
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
206
+
body, err = io.ReadAll(resp.Body)
208
+
return nil, fmt.Errorf("failed to read retry PAR response: %w", err)
214
+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
215
+
return nil, fmt.Errorf("PAR request failed with status %d", resp.StatusCode)
218
+
var parResp struct {
219
+
RequestURI string `json:"request_uri"`
220
+
ExpiresIn int `json:"expires_in"`
223
+
if err := json.Unmarshal(body, &parResp); err != nil {
224
+
return nil, fmt.Errorf("failed to decode PAR response: %w", err)
227
+
// Extract DPoP nonce from response header (if provided)
228
+
dpopNonce := resp.Header.Get("DPoP-Nonce")
230
+
return &PARResponse{
231
+
RequestURI: parResp.RequestURI,
232
+
ExpiresIn: parResp.ExpiresIn,
234
+
PKCEVerifier: pkce.Verifier,
235
+
DpopAuthserverNonce: dpopNonce,
239
+
// TokenResponse represents an OAuth token response
240
+
type 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
250
+
// InitialTokenRequest exchanges authorization code for tokens (DPoP-bound)
251
+
func (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)
255
+
return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
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)
266
+
// Create DPoP proof for token endpoint
267
+
dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.TokenEndpoint, dpopNonce, "")
269
+
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
272
+
// Send token request
273
+
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(data.Encode()))
275
+
return nil, fmt.Errorf("failed to create request: %w", err)
278
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
279
+
req.Header.Set("DPoP", dpopProof)
281
+
resp, err := c.httpClient.Do(req)
283
+
return nil, fmt.Errorf("failed to send token request: %w", err)
285
+
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
287
+
var tokenResp TokenResponse
288
+
if resp.StatusCode != http.StatusOK {
289
+
return nil, fmt.Errorf("token request failed with status %d", resp.StatusCode)
292
+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
293
+
return nil, fmt.Errorf("failed to decode token response: %w", err)
296
+
// Extract updated DPoP nonce
297
+
tokenResp.DpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
299
+
return &tokenResp, nil
302
+
// RefreshTokenRequest refreshes an access token using a refresh token
303
+
func (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)
307
+
return nil, fmt.Errorf("failed to fetch auth server metadata: %w", err)
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)
316
+
// Create DPoP proof for token endpoint
317
+
dpopProof, err := CreateDPoPProof(dpopKey, "POST", authMeta.TokenEndpoint, dpopNonce, "")
319
+
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
322
+
// Send refresh request
323
+
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(data.Encode()))
325
+
return nil, fmt.Errorf("failed to create request: %w", err)
328
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
329
+
req.Header.Set("DPoP", dpopProof)
331
+
resp, err := c.httpClient.Do(req)
333
+
return nil, fmt.Errorf("failed to send refresh request: %w", err)
335
+
defer func() { _ = resp.Body.Close() }() //nolint:errcheck
337
+
var tokenResp TokenResponse
338
+
if resp.StatusCode != http.StatusOK {
339
+
return nil, fmt.Errorf("refresh request failed with status %d", resp.StatusCode)
342
+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
343
+
return nil, fmt.Errorf("failed to decode token response: %w", err)
346
+
// Extract updated DPoP nonce
347
+
tokenResp.DpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
349
+
return &tokenResp, nil