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}