this repo has no description
1package oauth
2
3import (
4 "context"
5 "crypto/ecdsa"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13
14 "github.com/golang-jwt/jwt/v5"
15 "github.com/google/uuid"
16 "github.com/haileyok/atproto-oauth-golang/helpers"
17 internal_helpers "github.com/haileyok/atproto-oauth-golang/internal/helpers"
18 "github.com/lestrrat-go/jwx/v2/jwk"
19)
20
21type Client struct {
22 h *http.Client
23 clientPrivateKey *ecdsa.PrivateKey
24 clientKid string
25 clientId string
26 redirectUri string
27}
28
29type ClientArgs struct {
30 Http *http.Client
31 ClientJwk jwk.Key
32 ClientId string
33 RedirectUri string
34}
35
36func NewClient(args ClientArgs) (*Client, error) {
37 if args.ClientId == "" {
38 return nil, fmt.Errorf("no client id provided")
39 }
40
41 if args.RedirectUri == "" {
42 return nil, fmt.Errorf("no redirect uri provided")
43 }
44
45 if args.Http == nil {
46 args.Http = &http.Client{
47 Timeout: 5 * time.Second,
48 }
49 }
50
51 clientPkey, err := helpers.GetPrivateKey(args.ClientJwk)
52 if err != nil {
53 return nil, fmt.Errorf("could not load private key from provided client jwk: %w", err)
54 }
55
56 kid := args.ClientJwk.KeyID()
57
58 return &Client{
59 h: args.Http,
60 clientKid: kid,
61 clientPrivateKey: clientPkey,
62 clientId: args.ClientId,
63 redirectUri: args.RedirectUri,
64 }, nil
65}
66
67func (c *Client) ResolvePdsAuthServer(ctx context.Context, ustr string) (string, error) {
68 u, err := helpers.IsUrlSafeAndParsed(ustr)
69 if err != nil {
70 return "", err
71 }
72
73 u.Path = "/.well-known/oauth-protected-resource"
74
75 req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
76 if err != nil {
77 return "", fmt.Errorf("error creating request for oauth protected resource: %w", err)
78 }
79
80 resp, err := c.h.Do(req)
81 if err != nil {
82 return "", fmt.Errorf("could not get response from server: %w", err)
83 }
84 defer resp.Body.Close()
85
86 if resp.StatusCode != http.StatusOK {
87 io.Copy(io.Discard, resp.Body)
88 return "", fmt.Errorf("received non-200 response from pds. code was %d", resp.StatusCode)
89 }
90
91 var resource OauthProtectedResource
92 if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
93 return "", fmt.Errorf("could not unmarshal json: %w", err)
94 }
95
96 if len(resource.AuthorizationServers) == 0 {
97 return "", fmt.Errorf("oauth protected resource contained no authorization servers")
98 }
99
100 return resource.AuthorizationServers[0], nil
101}
102
103func (c *Client) FetchAuthServerMetadata(ctx context.Context, ustr string) (*OauthAuthorizationMetadata, error) {
104 u, err := helpers.IsUrlSafeAndParsed(ustr)
105 if err != nil {
106 return nil, err
107 }
108
109 u.Path = "/.well-known/oauth-authorization-server"
110
111 req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
112 if err != nil {
113 return nil, fmt.Errorf("error creating request to fetch auth metadata: %w", err)
114 }
115
116 resp, err := c.h.Do(req)
117 if err != nil {
118 return nil, fmt.Errorf("error getting response for authserver metadata: %w", err)
119 }
120 defer resp.Body.Close()
121
122 if resp.StatusCode != http.StatusOK {
123 io.Copy(io.Discard, resp.Body)
124 return nil, fmt.Errorf("received non-200 response from pds. status code was %d", resp.StatusCode)
125 }
126
127 var metadata OauthAuthorizationMetadata
128 if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
129 return nil, fmt.Errorf("could not unmarshal authserver metadata: %w", err)
130 }
131
132 if err := metadata.Validate(u); err != nil {
133 return nil, fmt.Errorf("could not validate authserver metadata: %w", err)
134 }
135
136 return &metadata, nil
137}
138
139func (c *Client) ClientAssertionJwt(authServerUrl string) (string, error) {
140 claims := jwt.MapClaims{
141 "iss": c.clientId,
142 "sub": c.clientId,
143 "aud": authServerUrl,
144 "jti": uuid.NewString(),
145 "iat": time.Now().Unix(),
146 }
147
148 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
149 token.Header["kid"] = c.clientKid
150
151 tokenString, err := token.SignedString(c.clientPrivateKey)
152 if err != nil {
153 return "", err
154 }
155
156 return tokenString, nil
157}
158
159func (c *Client) AuthServerDpopJwt(method, url, nonce string, privateJwk jwk.Key) (string, error) {
160 pubJwk, err := privateJwk.PublicKey()
161 if err != nil {
162 return "", err
163 }
164
165 b, err := json.Marshal(pubJwk)
166 if err != nil {
167 return "", err
168 }
169
170 var pubMap map[string]any
171 if err := json.Unmarshal(b, &pubMap); err != nil {
172 return "", err
173 }
174
175 now := time.Now().Unix()
176
177 claims := jwt.MapClaims{
178 "jti": uuid.NewString(),
179 "htm": method,
180 "htu": url,
181 "iat": now,
182 "exp": now + 30,
183 }
184
185 if nonce != "" {
186 claims["nonce"] = nonce
187 }
188
189 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
190 token.Header["typ"] = "dpop+jwt"
191 token.Header["alg"] = "ES256"
192 token.Header["jwk"] = pubMap
193
194 var rawKey any
195 if err := privateJwk.Raw(&rawKey); err != nil {
196 return "", err
197 }
198
199 tokenString, err := token.SignedString(rawKey)
200 if err != nil {
201 return "", fmt.Errorf("failed to sign token: %w", err)
202 }
203
204 return tokenString, nil
205}
206
207type ParAuthRequestExtra struct {
208 Name string
209 Value string
210}
211
212func (c *Client) SendParAuthRequest(ctx context.Context, authServerUrl string, authServerMeta *OauthAuthorizationMetadata, loginHint, scope string, dpopPrivateKey jwk.Key, extras ...ParAuthRequestExtra) (*SendParAuthResponse, error) {
213 if authServerMeta == nil {
214 return nil, fmt.Errorf("nil metadata provided")
215 }
216
217 parUrl := authServerMeta.PushedAuthorizationRequestEndpoint
218
219 state, err := internal_helpers.GenerateToken(10)
220 if err != nil {
221 return nil, fmt.Errorf("could not generate state token: %w", err)
222 }
223
224 pkceVerifier, err := internal_helpers.GenerateToken(48)
225 if err != nil {
226 return nil, fmt.Errorf("could not generate pkce verifier: %w", err)
227 }
228
229 codeChallenge := internal_helpers.GenerateCodeChallenge(pkceVerifier)
230 codeChallengeMethod := "S256"
231
232 clientAssertion, err := c.ClientAssertionJwt(authServerUrl)
233 if err != nil {
234 return nil, fmt.Errorf("error getting client assertion: %w", err)
235 }
236
237 dpopAuthserverNonce := ""
238 dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey)
239 if err != nil {
240 return nil, fmt.Errorf("error getting dpop proof: %w", err)
241 }
242
243 params := url.Values{
244 "response_type": {"code"},
245 "code_challenge": {codeChallenge},
246 "code_challenge_method": {codeChallengeMethod},
247 "client_id": {c.clientId},
248 "state": {state},
249 "redirect_uri": {c.redirectUri},
250 "scope": {scope},
251 "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
252 "client_assertion": {clientAssertion},
253 }
254
255 for _, e := range extras {
256 if !strings.HasPrefix(e.Name, "ext-") {
257 e.Name = "ext-" + e.Name
258 }
259 e.Value = url.QueryEscape(e.Value)
260 params[e.Name] = []string{e.Value}
261 }
262
263 if loginHint != "" {
264 params.Set("login_hint", loginHint)
265 }
266
267 _, err = helpers.IsUrlSafeAndParsed(parUrl)
268 if err != nil {
269 return nil, err
270 }
271
272 req, err := http.NewRequestWithContext(ctx, "POST", parUrl, strings.NewReader(params.Encode()))
273 if err != nil {
274 return nil, err
275 }
276
277 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
278 req.Header.Set("DPoP", dpopProof)
279
280 resp, err := c.h.Do(req)
281 if err != nil {
282 return nil, err
283 }
284 defer resp.Body.Close()
285
286 var rmap map[string]any
287 if err := json.NewDecoder(resp.Body).Decode(&rmap); err != nil {
288 return nil, err
289 }
290
291 if resp.StatusCode != 201 {
292 if resp.StatusCode == 400 && rmap["error"] == "use_dpop_nonce" {
293 dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
294 dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey)
295 if err != nil {
296 return nil, err
297 }
298
299 req2, err := http.NewRequestWithContext(
300 ctx,
301 "POST",
302 parUrl,
303 strings.NewReader(params.Encode()),
304 )
305 if err != nil {
306 return nil, err
307 }
308
309 req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
310 req2.Header.Set("DPoP", dpopProof)
311
312 resp2, err := c.h.Do(req2)
313 if err != nil {
314 return nil, err
315 }
316 defer resp2.Body.Close()
317
318 rmap = map[string]any{}
319 if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil {
320 return nil, err
321 }
322
323 if resp2.StatusCode != 201 {
324 return nil, fmt.Errorf("received error from server when submitting par request: %s", rmap["error"])
325 }
326 } else {
327 return nil, fmt.Errorf("received error from server when submitting par request: %s", rmap["error"])
328 }
329 }
330
331 return &SendParAuthResponse{
332 PkceVerifier: pkceVerifier,
333 State: state,
334 DpopAuthserverNonce: dpopAuthserverNonce,
335 ExpiresIn: rmap["expires_in"].(float64),
336 RequestUri: rmap["request_uri"].(string),
337 }, nil
338}
339
340func (c *Client) InitialTokenRequest(
341 ctx context.Context,
342 code,
343 authserverIss,
344 pkceVerifier,
345 dpopAuthserverNonce string,
346 dpopPrivateJwk jwk.Key,
347) (*TokenResponse, error) {
348 // we might need to re-run to update dpop nonce
349 for range 2 {
350 authserverMeta, err := c.FetchAuthServerMetadata(ctx, authserverIss)
351 if err != nil {
352 return nil, err
353 }
354
355 clientAssertion, err := c.ClientAssertionJwt(authserverIss)
356 if err != nil {
357 return nil, err
358 }
359
360 params := url.Values{
361 "client_id": {c.clientId},
362 "redirect_uri": {c.redirectUri},
363 "grant_type": {"authorization_code"},
364 "code": {code},
365 "code_verifier": {pkceVerifier},
366 "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
367 "client_assertion": {clientAssertion},
368 }
369
370 dpopProof, err := c.AuthServerDpopJwt("POST", authserverMeta.TokenEndpoint, dpopAuthserverNonce, dpopPrivateJwk)
371 if err != nil {
372 return nil, err
373 }
374
375 req, err := http.NewRequestWithContext(ctx, "POST", authserverMeta.TokenEndpoint, strings.NewReader(params.Encode()))
376 if err != nil {
377 return nil, err
378 }
379
380 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
381 req.Header.Set("DPoP", dpopProof)
382
383 resp, err := c.h.Do(req)
384 if err != nil {
385 return nil, err
386 }
387 defer resp.Body.Close()
388
389 if resp.StatusCode != 200 && resp.StatusCode != 201 {
390 var respMap map[string]string
391 if err := json.NewDecoder(resp.Body).Decode(&respMap); err != nil {
392 return nil, err
393 }
394
395 if resp.StatusCode == 400 && respMap["error"] == "use_dpop_nonce" {
396 dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
397 continue
398 }
399
400 return nil, fmt.Errorf("token refresh error: %s", respMap["error"])
401 }
402
403 var tokenResponse TokenResponse
404 if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
405 return nil, err
406 }
407
408 // set nonce so the updates are reflected in the response
409 tokenResponse.DpopAuthserverNonce = dpopAuthserverNonce
410
411 return &tokenResponse, nil
412 }
413
414 return nil, nil
415}
416
417func (c *Client) RefreshTokenRequest(
418 ctx context.Context,
419 refreshToken,
420 authserverIss,
421 dpopAuthserverNonce string,
422 dpopPrivateJwk jwk.Key,
423) (*TokenResponse, error) {
424 // we may need to update the dpop nonce
425 for range 2 {
426 authserverMeta, err := c.FetchAuthServerMetadata(ctx, authserverIss)
427 if err != nil {
428 return nil, err
429 }
430
431 clientAssertion, err := c.ClientAssertionJwt(authserverIss)
432 if err != nil {
433 return nil, err
434 }
435
436 params := url.Values{
437 "client_id": {c.clientId},
438 "grant_type": {"refresh_token"},
439 "refresh_token": {refreshToken},
440 "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
441 "client_assertion": {clientAssertion},
442 }
443
444 dpopProof, err := c.AuthServerDpopJwt("POST", authserverMeta.TokenEndpoint, dpopAuthserverNonce, dpopPrivateJwk)
445 if err != nil {
446 return nil, err
447 }
448
449 req, err := http.NewRequestWithContext(ctx, "POST", authserverMeta.TokenEndpoint, strings.NewReader(params.Encode()))
450 if err != nil {
451 return nil, err
452 }
453
454 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
455 req.Header.Set("DPoP", dpopProof)
456
457 resp, err := c.h.Do(req)
458 if err != nil {
459 return nil, err
460 }
461 defer resp.Body.Close()
462
463 if resp.StatusCode != 200 && resp.StatusCode != 201 {
464 var respMap map[string]string
465 if err := json.NewDecoder(resp.Body).Decode(&respMap); err != nil {
466 return nil, err
467 }
468
469 if resp.StatusCode == 400 && respMap["error"] == "use_dpop_nonce" {
470 dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
471 continue
472 }
473
474 return nil, fmt.Errorf("token refresh error: %s", respMap["error"])
475 }
476
477 var tokenResponse TokenResponse
478 if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
479 return nil, err
480 }
481
482 // set the nonce so that updates are reflected in response
483 tokenResponse.DpopAuthserverNonce = dpopAuthserverNonce
484
485 return &tokenResponse, nil
486 }
487
488 return nil, nil
489}