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}