Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 507 lines 14 kB view raw
1package oauth 2 3import ( 4 "context" 5 "crypto" 6 "crypto/ecdsa" 7 "crypto/elliptic" 8 "crypto/rand" 9 "crypto/sha256" 10 "encoding/base64" 11 "encoding/json" 12 "fmt" 13 "io" 14 "net/http" 15 "net/url" 16 "strings" 17 "time" 18 19 "github.com/go-jose/go-jose/v4" 20 "github.com/go-jose/go-jose/v4/jwt" 21) 22 23type Client struct { 24 ClientID string 25 RedirectURI string 26 PrivateKey *ecdsa.PrivateKey 27 PublicJWK jose.JSONWebKey 28} 29 30type AuthServerMetadata struct { 31 Issuer string `json:"issuer"` 32 AuthorizationEndpoint string `json:"authorization_endpoint"` 33 TokenEndpoint string `json:"token_endpoint"` 34 PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 35 ScopesSupported []string `json:"scopes_supported"` 36 ResponseTypesSupported []string `json:"response_types_supported"` 37 DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 38} 39 40type PARResponse struct { 41 RequestURI string `json:"request_uri"` 42 ExpiresIn int `json:"expires_in"` 43} 44 45type TokenResponse struct { 46 AccessToken string `json:"access_token"` 47 TokenType string `json:"token_type"` 48 ExpiresIn int `json:"expires_in"` 49 RefreshToken string `json:"refresh_token"` 50 Scope string `json:"scope"` 51 Sub string `json:"sub"` 52} 53 54type PendingAuth struct { 55 State string 56 DID string 57 Handle string 58 PDS string 59 AuthServer string 60 Issuer string 61 PKCEVerifier string 62 DPoPKey *ecdsa.PrivateKey 63 DPoPNonce string 64 CreatedAt time.Time 65} 66 67func NewClient(clientID, redirectURI string, privateKey *ecdsa.PrivateKey) *Client { 68 publicJWK := jose.JSONWebKey{ 69 Key: &privateKey.PublicKey, 70 Algorithm: string(jose.ES256), 71 Use: "sig", 72 } 73 thumbprint, _ := publicJWK.Thumbprint(crypto.SHA256) 74 publicJWK.KeyID = base64.RawURLEncoding.EncodeToString(thumbprint) 75 76 return &Client{ 77 ClientID: clientID, 78 RedirectURI: redirectURI, 79 PrivateKey: privateKey, 80 PublicJWK: publicJWK, 81 } 82} 83 84func GenerateKey() (*ecdsa.PrivateKey, error) { 85 return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 86} 87 88func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 89 did, err := c.resolveHandleAt(ctx, handle, "https://public.api.bsky.app") 90 if err == nil { 91 return did, nil 92 } 93 94 parts := strings.Split(handle, ".") 95 if len(parts) >= 2 { 96 if len(parts) > 2 { 97 domain := strings.Join(parts[1:], ".") 98 did, err := c.resolveHandleAt(ctx, handle, fmt.Sprintf("https://%s", domain)) 99 if err == nil { 100 return did, nil 101 } 102 } 103 104 did, err := c.resolveHandleAt(ctx, handle, fmt.Sprintf("https://%s", handle)) 105 if err == nil { 106 return did, nil 107 } 108 } 109 110 return "", fmt.Errorf("failed to resolve handle %s: %v", handle, err) 111} 112 113func (c *Client) resolveHandleAt(ctx context.Context, handle, service string) (string, error) { 114 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", strings.TrimSuffix(service, "/"), url.QueryEscape(handle)) 115 116 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 117 if err != nil { 118 return "", err 119 } 120 121 resp, err := http.DefaultClient.Do(req) 122 if err != nil { 123 return "", err 124 } 125 defer resp.Body.Close() 126 127 if resp.StatusCode != 200 { 128 return "", fmt.Errorf("status %d from %s", resp.StatusCode, service) 129 } 130 131 var result struct { 132 DID string `json:"did"` 133 } 134 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 135 return "", err 136 } 137 return result.DID, nil 138} 139 140func (c *Client) ResolveDIDToPDS(ctx context.Context, did string) (string, error) { 141 var docURL string 142 if strings.HasPrefix(did, "did:plc:") { 143 docURL = fmt.Sprintf("https://plc.directory/%s", did) 144 } else if strings.HasPrefix(did, "did:web:") { 145 domain := strings.TrimPrefix(did, "did:web:") 146 docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 147 } else { 148 return "", fmt.Errorf("unsupported DID method: %s", did) 149 } 150 151 resp, err := http.Get(docURL) 152 if err != nil { 153 return "", err 154 } 155 defer resp.Body.Close() 156 157 var doc struct { 158 Service []struct { 159 ID string `json:"id"` 160 Type string `json:"type"` 161 ServiceEndpoint string `json:"serviceEndpoint"` 162 } `json:"service"` 163 } 164 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 165 return "", err 166 } 167 168 for _, svc := range doc.Service { 169 if svc.Type == "AtprotoPersonalDataServer" { 170 return svc.ServiceEndpoint, nil 171 } 172 } 173 return "", fmt.Errorf("no PDS found in DID document") 174} 175 176func (c *Client) GetAuthServerMetadata(ctx context.Context, pds string) (*AuthServerMetadata, error) { 177 resourceURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", strings.TrimSuffix(pds, "/")) 178 resp, err := http.Get(resourceURL) 179 if err != nil { 180 return nil, err 181 } 182 defer resp.Body.Close() 183 184 var resource struct { 185 AuthorizationServers []string `json:"authorization_servers"` 186 } 187 if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil { 188 return nil, err 189 } 190 191 if len(resource.AuthorizationServers) == 0 { 192 return nil, fmt.Errorf("no authorization servers found") 193 } 194 195 authServerURL := resource.AuthorizationServers[0] 196 metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", strings.TrimSuffix(authServerURL, "/")) 197 198 metaResp, err := http.Get(metaURL) 199 if err != nil { 200 return nil, err 201 } 202 defer metaResp.Body.Close() 203 204 var meta AuthServerMetadata 205 if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil { 206 return nil, err 207 } 208 return &meta, nil 209} 210 211func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) { 212 url = strings.TrimSuffix(url, "/") 213 214 metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url) 215 metaResp, err := http.Get(metaURL) 216 if err == nil && metaResp.StatusCode == 200 { 217 defer metaResp.Body.Close() 218 var meta AuthServerMetadata 219 if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" { 220 return &meta, nil 221 } 222 } 223 if metaResp != nil { 224 metaResp.Body.Close() 225 } 226 227 return c.GetAuthServerMetadata(ctx, url) 228} 229 230func (c *Client) GeneratePKCE() (verifier, challenge string) { 231 b := make([]byte, 32) 232 rand.Read(b) 233 verifier = base64.RawURLEncoding.EncodeToString(b) 234 235 h := sha256.Sum256([]byte(verifier)) 236 challenge = base64.RawURLEncoding.EncodeToString(h[:]) 237 return 238} 239 240func (c *Client) GenerateDPoPKey() (*ecdsa.PrivateKey, error) { 241 return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 242} 243 244func (c *Client) CreateDPoPProof(dpopKey *ecdsa.PrivateKey, method, uri, nonce, ath string) (string, error) { 245 now := time.Now() 246 jti := make([]byte, 16) 247 rand.Read(jti) 248 249 publicJWK := jose.JSONWebKey{ 250 Key: &dpopKey.PublicKey, 251 Algorithm: string(jose.ES256), 252 } 253 254 claims := map[string]interface{}{ 255 "jti": base64.RawURLEncoding.EncodeToString(jti), 256 "htm": method, 257 "htu": uri, 258 "iat": now.Add(-30 * time.Second).Unix(), 259 "exp": now.Add(5 * time.Minute).Unix(), 260 } 261 if nonce != "" { 262 claims["nonce"] = nonce 263 } 264 if ath != "" { 265 claims["ath"] = ath 266 } 267 268 signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: dpopKey}, &jose.SignerOptions{ 269 ExtraHeaders: map[jose.HeaderKey]interface{}{ 270 "typ": "dpop+jwt", 271 "jwk": publicJWK, 272 }, 273 }) 274 if err != nil { 275 return "", err 276 } 277 278 claimsBytes, _ := json.Marshal(claims) 279 sig, err := signer.Sign(claimsBytes) 280 if err != nil { 281 return "", err 282 } 283 284 return sig.CompactSerialize() 285} 286 287func (c *Client) CreateClientAssertion(issuer string) (string, error) { 288 now := time.Now() 289 jti := make([]byte, 16) 290 rand.Read(jti) 291 292 claims := jwt.Claims{ 293 Issuer: c.ClientID, 294 Subject: c.ClientID, 295 Audience: jwt.Audience{issuer}, 296 IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)), 297 Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), 298 ID: base64.RawURLEncoding.EncodeToString(jti), 299 } 300 301 signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: c.PrivateKey}, &jose.SignerOptions{ 302 ExtraHeaders: map[jose.HeaderKey]interface{}{ 303 "kid": c.PublicJWK.KeyID, 304 }, 305 }) 306 if err != nil { 307 return "", err 308 } 309 310 return jwt.Signed(signer).Claims(claims).Serialize() 311} 312 313func (c *Client) SendPAR(meta *AuthServerMetadata, loginHint, scope string, dpopKey *ecdsa.PrivateKey, pkceChallenge string) (*PARResponse, string, string, error) { 314 stateBytes := make([]byte, 16) 315 rand.Read(stateBytes) 316 state := base64.RawURLEncoding.EncodeToString(stateBytes) 317 318 parResp, dpopNonce, err := c.sendPARRequest(meta, loginHint, scope, dpopKey, pkceChallenge, state, "") 319 if err != nil { 320 321 if strings.Contains(err.Error(), "use_dpop_nonce") && dpopNonce != "" { 322 323 parResp, dpopNonce, err = c.sendPARRequest(meta, loginHint, scope, dpopKey, pkceChallenge, state, dpopNonce) 324 if err != nil { 325 return nil, "", "", err 326 } 327 } else { 328 return nil, "", "", err 329 } 330 } 331 332 return parResp, state, dpopNonce, nil 333} 334 335func (c *Client) sendPARRequest(meta *AuthServerMetadata, loginHint, scope string, dpopKey *ecdsa.PrivateKey, pkceChallenge, state, dpopNonce string) (*PARResponse, string, error) { 336 dpopProof, err := c.CreateDPoPProof(dpopKey, "POST", meta.PushedAuthorizationRequestEndpoint, dpopNonce, "") 337 if err != nil { 338 return nil, "", err 339 } 340 341 clientAssertion, err := c.CreateClientAssertion(meta.Issuer) 342 if err != nil { 343 return nil, "", err 344 } 345 346 data := url.Values{} 347 data.Set("client_id", c.ClientID) 348 data.Set("redirect_uri", c.RedirectURI) 349 data.Set("response_type", "code") 350 data.Set("scope", scope) 351 data.Set("state", state) 352 data.Set("code_challenge", pkceChallenge) 353 data.Set("code_challenge_method", "S256") 354 data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 355 data.Set("client_assertion", clientAssertion) 356 if loginHint != "" { 357 data.Set("login_hint", loginHint) 358 } 359 360 req, err := http.NewRequest("POST", meta.PushedAuthorizationRequestEndpoint, strings.NewReader(data.Encode())) 361 if err != nil { 362 return nil, "", err 363 } 364 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 365 req.Header.Set("DPoP", dpopProof) 366 367 resp, err := http.DefaultClient.Do(req) 368 if err != nil { 369 return nil, "", err 370 } 371 defer resp.Body.Close() 372 373 responseNonce := resp.Header.Get("DPoP-Nonce") 374 375 if resp.StatusCode != 200 && resp.StatusCode != 201 { 376 body, _ := io.ReadAll(resp.Body) 377 return nil, responseNonce, fmt.Errorf("PAR failed: %d - %s", resp.StatusCode, string(body)) 378 } 379 380 var parResp PARResponse 381 if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil { 382 return nil, responseNonce, err 383 } 384 385 return &parResp, responseNonce, nil 386} 387 388func (c *Client) ExchangeCode(meta *AuthServerMetadata, code, pkceVerifier string, dpopKey *ecdsa.PrivateKey, dpopNonce string) (*TokenResponse, string, error) { 389 return c.exchangeCodeInternal(meta, code, pkceVerifier, dpopKey, dpopNonce, false) 390} 391 392func (c *Client) exchangeCodeInternal(meta *AuthServerMetadata, code, pkceVerifier string, dpopKey *ecdsa.PrivateKey, dpopNonce string, isRetry bool) (*TokenResponse, string, error) { 393 accessTokenHash := "" 394 dpopProof, err := c.CreateDPoPProof(dpopKey, "POST", meta.TokenEndpoint, dpopNonce, accessTokenHash) 395 if err != nil { 396 return nil, "", err 397 } 398 399 clientAssertion, err := c.CreateClientAssertion(meta.Issuer) 400 if err != nil { 401 return nil, "", err 402 } 403 404 data := url.Values{} 405 data.Set("grant_type", "authorization_code") 406 data.Set("code", code) 407 data.Set("redirect_uri", c.RedirectURI) 408 data.Set("client_id", c.ClientID) 409 data.Set("code_verifier", pkceVerifier) 410 data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 411 data.Set("client_assertion", clientAssertion) 412 413 req, err := http.NewRequest("POST", meta.TokenEndpoint, strings.NewReader(data.Encode())) 414 if err != nil { 415 return nil, "", err 416 } 417 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 418 req.Header.Set("DPoP", dpopProof) 419 420 resp, err := http.DefaultClient.Do(req) 421 if err != nil { 422 return nil, "", err 423 } 424 defer resp.Body.Close() 425 426 newNonce := resp.Header.Get("DPoP-Nonce") 427 428 if resp.StatusCode != 200 { 429 body, _ := io.ReadAll(resp.Body) 430 bodyStr := string(body) 431 432 if !isRetry && strings.Contains(bodyStr, "use_dpop_nonce") && newNonce != "" { 433 return c.exchangeCodeInternal(meta, code, pkceVerifier, dpopKey, newNonce, true) 434 } 435 436 return nil, newNonce, fmt.Errorf("token exchange failed: %d - %s", resp.StatusCode, bodyStr) 437 } 438 439 var tokenResp TokenResponse 440 if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 441 return nil, newNonce, err 442 } 443 444 return &tokenResp, newNonce, nil 445} 446 447func (c *Client) RefreshToken(meta *AuthServerMetadata, refreshToken string, dpopKey *ecdsa.PrivateKey, dpopNonce string) (*TokenResponse, string, error) { 448 return c.refreshTokenInternal(meta, refreshToken, dpopKey, dpopNonce, false) 449} 450 451func (c *Client) refreshTokenInternal(meta *AuthServerMetadata, refreshToken string, dpopKey *ecdsa.PrivateKey, dpopNonce string, isRetry bool) (*TokenResponse, string, error) { 452 dpopProof, err := c.CreateDPoPProof(dpopKey, "POST", meta.TokenEndpoint, dpopNonce, "") 453 if err != nil { 454 return nil, "", err 455 } 456 457 clientAssertion, err := c.CreateClientAssertion(meta.Issuer) 458 if err != nil { 459 return nil, "", err 460 } 461 462 data := url.Values{} 463 data.Set("grant_type", "refresh_token") 464 data.Set("refresh_token", refreshToken) 465 data.Set("client_id", c.ClientID) 466 data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 467 data.Set("client_assertion", clientAssertion) 468 469 req, err := http.NewRequest("POST", meta.TokenEndpoint, strings.NewReader(data.Encode())) 470 if err != nil { 471 return nil, "", err 472 } 473 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 474 req.Header.Set("DPoP", dpopProof) 475 476 resp, err := http.DefaultClient.Do(req) 477 if err != nil { 478 return nil, "", err 479 } 480 defer resp.Body.Close() 481 482 newNonce := resp.Header.Get("DPoP-Nonce") 483 484 if resp.StatusCode != 200 { 485 body, _ := io.ReadAll(resp.Body) 486 bodyStr := string(body) 487 488 if !isRetry && strings.Contains(bodyStr, "use_dpop_nonce") && newNonce != "" { 489 return c.refreshTokenInternal(meta, refreshToken, dpopKey, newNonce, true) 490 } 491 492 return nil, newNonce, fmt.Errorf("refresh failed: %d - %s", resp.StatusCode, bodyStr) 493 } 494 495 var tokenResp TokenResponse 496 if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 497 return nil, newNonce, err 498 } 499 500 return &tokenResp, newNonce, nil 501} 502 503func (c *Client) GetPublicJWKS() map[string]interface{} { 504 return map[string]interface{}{ 505 "keys": []interface{}{c.PublicJWK}, 506 } 507}