Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}