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}