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
207func (c *Client) SendParAuthRequest(ctx context.Context, authServerUrl string, authServerMeta *OauthAuthorizationMetadata, loginHint, scope string, dpopPrivateKey jwk.Key) (*SendParAuthResponse, error) {
208 if authServerMeta == nil {
209 return nil, fmt.Errorf("nil metadata provided")
210 }
211
212 parUrl := authServerMeta.PushedAuthorizationRequestEndpoint
213
214 state, err := internal_helpers.GenerateToken(10)
215 if err != nil {
216 return nil, fmt.Errorf("could not generate state token: %w", err)
217 }
218
219 pkceVerifier, err := internal_helpers.GenerateToken(48)
220 if err != nil {
221 return nil, fmt.Errorf("could not generate pkce verifier: %w", err)
222 }
223
224 codeChallenge := internal_helpers.GenerateCodeChallenge(pkceVerifier)
225 codeChallengeMethod := "S256"
226
227 clientAssertion, err := c.ClientAssertionJwt(authServerUrl)
228 if err != nil {
229 return nil, fmt.Errorf("error getting client assertion: %w", err)
230 }
231
232 dpopAuthserverNonce := ""
233 dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey)
234 if err != nil {
235 return nil, fmt.Errorf("error getting dpop proof: %w", err)
236 }
237
238 params := url.Values{
239 "response_type": {"code"},
240 "code_challenge": {codeChallenge},
241 "code_challenge_method": {codeChallengeMethod},
242 "client_id": {c.clientId},
243 "state": {state},
244 "redirect_uri": {c.redirectUri},
245 "scope": {scope},
246 "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
247 "client_assertion": {clientAssertion},
248 }
249
250 if loginHint != "" {
251 params.Set("login_hint", loginHint)
252 }
253
254 _, err = helpers.IsUrlSafeAndParsed(parUrl)
255 if err != nil {
256 return nil, err
257 }
258
259 req, err := http.NewRequestWithContext(ctx, "POST", parUrl, strings.NewReader(params.Encode()))
260 if err != nil {
261 return nil, err
262 }
263
264 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
265 req.Header.Set("DPoP", dpopProof)
266
267 resp, err := c.h.Do(req)
268 if err != nil {
269 return nil, err
270 }
271 defer resp.Body.Close()
272
273 var rmap map[string]any
274 if err := json.NewDecoder(resp.Body).Decode(&rmap); err != nil {
275 return nil, err
276 }
277
278 if resp.StatusCode != 201 {
279 if resp.StatusCode == 400 && rmap["error"] == "use_dpop_nonce" {
280 dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
281 dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey)
282 if err != nil {
283 return nil, err
284 }
285
286 req2, err := http.NewRequestWithContext(
287 ctx,
288 "POST",
289 parUrl,
290 strings.NewReader(params.Encode()),
291 )
292 if err != nil {
293 return nil, err
294 }
295
296 req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
297 req2.Header.Set("DPoP", dpopProof)
298
299 resp2, err := c.h.Do(req2)
300 if err != nil {
301 return nil, err
302 }
303 defer resp2.Body.Close()
304
305 rmap = map[string]any{}
306 if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil {
307 return nil, err
308 }
309
310 if resp2.StatusCode != 201 {
311 return nil, fmt.Errorf("received error from server when submitting par request: %s", rmap["error"])
312 }
313 } else {
314 return nil, fmt.Errorf("received error from server when submitting par request: %s", rmap["error"])
315 }
316 }
317
318 return &SendParAuthResponse{
319 PkceVerifier: pkceVerifier,
320 State: state,
321 DpopAuthserverNonce: dpopAuthserverNonce,
322 ExpiresIn: rmap["expires_in"].(float64),
323 RequestUri: rmap["request_uri"].(string),
324 }, nil
325}
326
327func (c *Client) InitialTokenRequest(
328 ctx context.Context,
329 code,
330 authserverIss,
331 pkceVerifier,
332 dpopAuthserverNonce string,
333 dpopPrivateJwk jwk.Key,
334) (*TokenResponse, error) {
335 // we might need to re-run to update dpop nonce
336 for range 2 {
337 authserverMeta, err := c.FetchAuthServerMetadata(ctx, authserverIss)
338 if err != nil {
339 return nil, err
340 }
341
342 clientAssertion, err := c.ClientAssertionJwt(authserverIss)
343 if err != nil {
344 return nil, err
345 }
346
347 params := url.Values{
348 "client_id": {c.clientId},
349 "redirect_uri": {c.redirectUri},
350 "grant_type": {"authorization_code"},
351 "code": {code},
352 "code_verifier": {pkceVerifier},
353 "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
354 "client_assertion": {clientAssertion},
355 }
356
357 dpopProof, err := c.AuthServerDpopJwt("POST", authserverMeta.TokenEndpoint, dpopAuthserverNonce, dpopPrivateJwk)
358 if err != nil {
359 return nil, err
360 }
361
362 req, err := http.NewRequestWithContext(ctx, "POST", authserverMeta.TokenEndpoint, strings.NewReader(params.Encode()))
363 if err != nil {
364 return nil, err
365 }
366
367 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
368 req.Header.Set("DPoP", dpopProof)
369
370 resp, err := c.h.Do(req)
371 if err != nil {
372 return nil, err
373 }
374 defer resp.Body.Close()
375
376 if resp.StatusCode != 200 && resp.StatusCode != 201 {
377 var respMap map[string]string
378 if err := json.NewDecoder(resp.Body).Decode(&respMap); err != nil {
379 return nil, err
380 }
381
382 if resp.StatusCode == 400 && respMap["error"] == "use_dpop_nonce" {
383 dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
384 continue
385 }
386
387 return nil, fmt.Errorf("token refresh error: %s", respMap["error"])
388 }
389
390 var tokenResponse TokenResponse
391 if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
392 return nil, err
393 }
394
395 // set nonce so the updates are reflected in the response
396 tokenResponse.DpopAuthserverNonce = dpopAuthserverNonce
397
398 return &tokenResponse, nil
399 }
400
401 return nil, nil
402}
403
404func (c *Client) RefreshTokenRequest(
405 ctx context.Context,
406 refreshToken,
407 authserverIss,
408 dpopAuthserverNonce string,
409 dpopPrivateJwk jwk.Key,
410) (*TokenResponse, error) {
411 // we may need to update the dpop nonce
412 for range 2 {
413 authserverMeta, err := c.FetchAuthServerMetadata(ctx, authserverIss)
414 if err != nil {
415 return nil, err
416 }
417
418 clientAssertion, err := c.ClientAssertionJwt(authserverIss)
419 if err != nil {
420 return nil, err
421 }
422
423 params := url.Values{
424 "client_id": {c.clientId},
425 "grant_type": {"refresh_token"},
426 "refresh_token": {refreshToken},
427 "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
428 "client_assertion": {clientAssertion},
429 }
430
431 dpopProof, err := c.AuthServerDpopJwt("POST", authserverMeta.TokenEndpoint, dpopAuthserverNonce, dpopPrivateJwk)
432 if err != nil {
433 return nil, err
434 }
435
436 req, err := http.NewRequestWithContext(ctx, "POST", authserverMeta.TokenEndpoint, strings.NewReader(params.Encode()))
437 if err != nil {
438 return nil, err
439 }
440
441 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
442 req.Header.Set("DPoP", dpopProof)
443
444 resp, err := c.h.Do(req)
445 if err != nil {
446 return nil, err
447 }
448 defer resp.Body.Close()
449
450 if resp.StatusCode != 200 && resp.StatusCode != 201 {
451 var respMap map[string]string
452 if err := json.NewDecoder(resp.Body).Decode(&respMap); err != nil {
453 return nil, err
454 }
455
456 if resp.StatusCode == 400 && respMap["error"] == "use_dpop_nonce" {
457 dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce")
458 continue
459 }
460
461 return nil, fmt.Errorf("token refresh error: %s", respMap["error"])
462 }
463
464 var tokenResponse TokenResponse
465 if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
466 return nil, err
467 }
468
469 // set the nonce so that updates are reflected in response
470 tokenResponse.DpopAuthserverNonce = dpopAuthserverNonce
471
472 return &tokenResponse, nil
473 }
474
475 return nil, nil
476}