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