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