this repo has no description
1# Atproto OAuth Golang 2 3> [!WARNING] 4> This is an experimental repo. It may contain bugs. Use at your own risk. 5 6> [!WARNING] 7> You should always validate user input. The example/test code inside this repo may be used as an implementation guide, but no guarantees are made. 8 9```bash 10go get github.com/haileyok/atproto-oauth-golang 11``` 12 13## Prerequisites 14There are some prerequisites that you'll need to handle before implementing this OAuth client. 15 16### Private JWK 17If you do not already have a private JWK for your application, first create one. There is a helper CLI tool that can generate one for you. From the project directory, run 18 19`make jwks` 20 21You will need to read the JWK from your application and parse it using `oauth.ParseJWKFromBytes`. 22 23### Serve `client-metadata.json` from your application 24 25The client metadata will need to be accessible from your domain. An example using `echo` is below. 26 27```go 28func (s *TestServer) handleClientMetadata(e echo.Context) error { 29 metadata := map[string]any{ 30 "client_id": serverMetadataUrl, 31 "client_name": "Atproto Oauth Golang Tester", 32 "client_uri": serverUrlRoot, 33 "logo_uri": fmt.Sprintf("%s/logo.png", serverUrlRoot), 34 "tos_uri": fmt.Sprintf("%s/tos", serverUrlRoot), 35 "policy_url": fmt.Sprintf("%s/policy", serverUrlRoot), 36 "redirect_uris": []string{serverCallbackUrl}, 37 "grant_types": []string{"authorization_code", "refresh_token"}, 38 "response_types": []string{"code"}, 39 "application_type": "web", 40 "dpop_bound_access_tokens": true, 41 "jwks_uri": fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot), 42 "scope": "atproto transition:generic", 43 "token_endpoint_auth_method": "private_key_jwt", 44 "token_endpoint_auth_signing_alg": "ES256", 45 } 46 47 return e.JSON(200, metadata) 48} 49``` 50 51### Serve `jwks.json` 52 53You will also need to serve your private JWK's __public key__ from your domain. Again, an example is below. 54 55```go 56func (s *TestServer) handleJwks(e echo.Context) error { 57 b, err := os.ReadFile("./jwk.json") 58 if err != nil { 59 return err 60 } 61 62 k, err := helpers.ParseJWKFromBytes(b) 63 if err != nil { 64 return err 65 } 66 67 pubKey, err := k.PublicKey() 68 if err != nil { 69 return err 70 } 71 72 return e.JSON(200, helpers.CreateJwksResponseObject(pubKey)) 73} 74``` 75 76## Usage 77 78Once you have completed the prerequisites, you can implement and use the client. 79 80### Create a new OAuth Client 81 82Create an OAuth client by calling `oauth.NewClient` 83 84```go 85clientId := "https://yourdomain.com/path/to/client-metadata.json" 86callbackUrl := "https://yourdomain.com/oauth-callback" 87 88b, err := os.ReadFile("./jwks.json") 89if err != nil { 90 return err 91} 92 93k, err := helpers.ParseJWKFromBytes(b) 94if err != nil { 95 return err 96} 97 98cli, err := helpers.NewClient(oauth.ClientArgs{ 99 ClientJwk: k, 100 ClientId: clientId, 101 RedirectUri: callbackUrl, 102}) 103if err != nil { 104 return err 105} 106``` 107 108### Starting Authenticating 109 110There are examples of the authentication flow inside of `cmd/client_tester/handle_auth.go`, however we'll talk about some general points here. 111 112#### Determining the user's PDS 113 114You should allow for users to input their handle, DID, or PDS URL when detemrining where to send the user for authentication. An example that covers all the bases of what you'll need to do is when a user uses their handle. 115 116```go 117cli := oauth.NewClient() 118userInput := "hailey.at" 119 120// If you already have a did or a URL, you can skip this step 121did, err := resolveHandle(ctx, userInput) // returns did:plc:abc123 or did:web:test.com 122if err != nil { 123 return err 124} 125 126// If you already have a URL, you can skip this step 127service, err := resolveService(ctx, did) // returns https://pds.haileyok.com 128if err != nil { 129 return err 130} 131 132authserver, err := cli.ResolvePdsAuthServer(ctx, service) 133if err != nil { 134 return err 135} 136 137authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver) 138if err != nil { 139 return err 140} 141``` 142 143By this point, you will have the necessary information to direct the user where they need to go. 144 145#### Create a private DPoP JWK for the user 146 147You'll need to create a private DPoP JWK for the user before directing them to their PDS to authenticate. You'll need to store this in a later step, and you will need to pass it along inside the PAR request, so go ahead and marshal it as well. 148 149```go 150k, err := helpers.GenerateKey(nil) 151if err != nil { 152 return err 153} 154 155b, err := json.Marshal(k) 156if err != nil { 157 return err 158} 159``` 160 161#### Make the PAR request 162 163```go 164// Note: the login hint - here `handle` - should only be set if you have a DID or handle. Leave it empty if all you 165// have is the PDS url. 166parResp, err := cli.SendParAuthRequest(ctx, authserver, authmeta, handle, scope, dpopPrivateKey) 167if err != nil { 168 return err 169} 170``` 171 172#### Store the needed information before redirecting 173 174Some items will need to be stored for later when the PDS redirects to your application. 175 176- The user's DID, if you have it 177- The user's PDS url 178- The authserver issuer 179- The `state` value from the PAR request 180- The PKCE verifier from the PAR rquest 181- The DPoP autherserver nonce from the PAR request 182- The DPoP private JWK thhat you generated 183 184It is up to you how you want to store these values. Most likely, you will want to store them in a database. You may also want to store the `state` variable in the user's session _as well as the database_ so you can verify it later. There's a basic implementation inside of `cmd/client_tester/handle_auth.go`. 185 186#### Redirect 187 188Once you've stored the needed info, send the user to their PDS. The URL to redirect the user to should have both the `client_id` and `request_uri` `GET` parameters set. 189 190```go 191u, _ := url.Parse(meta.AuthorizationEndpoint) 192u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(yourClientId), parResp.RequestUri) 193 194// Redirect the user to created url 195``` 196 197### Callback handling 198 199Handling the response is pretty easy, though you'll want to check a few things once you receive the response. 200 201- Ensure that `state`, `iss`, and `code` are present in the `GET` parameters 202- Ensure that the `state` value matches the `state` value you stored before redirection 203 204You'll next need to load all of the request information you previously stored. Once you have that information, you can perform the initial token request. 205 206```go 207resCode := e.QueryParam("code") 208resIss := e.QueryParam("iss") 209 210itResp, err := cli.InitialTokenRequest(ctx, resCode, resIss, requestInfo.PkceVerifier, requestInfo.DpopAuthserverNonce, requestInfo.privateJwk) 211if err != nil { 212 return err 213} 214``` 215 216#### Final checks 217 218Finally, check that the scope received matches the requested scope. Also, if you didn't have the user's DID before redirecting earlier, you can now get their DID from `itResp.Sub`. 219 220```go 221if itResp.Scope != requestedScope { 222 return fmt.Errorf("bad scope") 223} 224 225if requestInfo.Did == "" { 226 // Do something... 227} 228``` 229 230#### Store the response 231 232Now, you can store the response items to make make authenticated requests later. You likely will want to store at least the user's DID in a secure session so that you know who the user is. 233 234### Refreshing the token 235 236The acess token you receive will expire after one hour and you will need to refresh it. You may choose to create a helper method that will refresh the token as necessary whenever you fetch the authentication information from your store. For an example, see `cmd/client_test/user.go`. 237 238## Making requests 239 240You may have some experience using the atproto SDK's helper methods from `indigo`. For example, you may be able to call `ActorGetProfile()` to fetch a user's profile. Currently, the atproto SDK does not support OAuth however, and will need some 241changes. In the meantime, I have added a custom XRPC client to this repo that can be used with OAuth sessions created in this library. 242 243### Creating an XRPC client 244 245Similar to the `indigo/xrpc` package, you can create an XRPC client like so 246 247```go 248client := &oauth.XrpcClient{ 249 OnDpopPdsNonceChanged: func(did, newNonce string) { 250 // Handle updating your store with the new nonce 251 }, 252} 253``` 254 255The `OnDpopPdsNonceChanged` callback will fire whenever an authenticated request results in an updated DPoP PDS nonce. You should update your store with this nonce for future requests. 256 257### Making requests 258 259Instead of using "helpers", for now you should make requests by simply calling `Do()` on the XRPC client. You will need to pass `XrpcAuthedRequestArgs` to the function to perform authenticated requests. 260If the parameter is `nil`, the request will be made unauthenticated. A few examples are below. 261 262#### Creating authentication arguments 263 264```go 265// Get your user's session - however you are doing that - and retrieve their did 266 267// Grab the oauth session from your database 268oauthSession, err := s.getOauthSession(e.Request().Context(), did) 269 270// Parse the user's JWK to pass into arguments 271privateJwk, err := helpers.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk)) 272if err != nil { 273 return nil, false, err 274} 275 276return &oauth.XrpcAuthedRequestArgs{ 277 Did: oauthSession.Did, 278 AccessToken: oauthSession.AccessToken, 279 PdsUrl: oauthSession.PdsUrl, 280 Issuer: oauthSession.AuthserverIss, 281 DpopPdsNonce: oauthSession.DpopPdsNonce, 282 DpopPrivateJwk: privateJwk, 283}, nil 284``` 285 286#### Making a post 287 288```go 289authArgs, err := s.getOauthSessionAuthArgs(e) 290if err != nil { 291 return err 292} 293 294post := bsky.FeedPost{ 295 Text: "hello from atproto golang oauth client", 296 CreatedAt: syntax.DatetimeNow().String(), 297} 298 299input := atproto.RepoCreateRecord_Input{ 300 Collection: "app.bsky.feed.post", 301 Repo: authArgs.Did, 302 Record: &util.LexiconTypeDecoder{Val: &post}, 303} 304 305var out atproto.RepoCreateRecord_Output 306if err := s.xrpcCli.Do(e.Request().Context(), authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 307 return err 308} 309``` 310 311#### Getting a profile 312 313```go 314authArgs, err := s.getOauthSessionAuthArgs(e) 315if err != nil { 316 return err 317} 318 319var out bsky.ActorDefs_ProfileViewDetailed 320if err := s.xrpcCli.Do(e.Request().Context(), authArgs, xrpc.Query, "", "app.bsky.actor.getProfile", map[string]any{"actor": authArgs.Did}, nil, &out); err != nil { 321 return err 322} 323```