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 10## Prerequisites 11There are some prerequisites that you'll need to handle before implementing this OAuth client. 12 13### Private JWK 14If 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 15 16`make jwks` 17 18You will need to read the JWK from your application and parse it using `oauth.ParseJWKFromBytes`. 19 20### Serve `client-metadata.json` from your application 21 22The client metadata will need to be accessible from your domain. An example using `echo` is below. 23 24```go 25func (s *TestServer) handleClientMetadata(e echo.Context) error { 26 metadata := map[string]any{ 27 "client_id": serverMetadataUrl, 28 "client_name": "Atproto Oauth Golang Tester", 29 "client_uri": serverUrlRoot, 30 "logo_uri": fmt.Sprintf("%s/logo.png", serverUrlRoot), 31 "tos_uri": fmt.Sprintf("%s/tos", serverUrlRoot), 32 "policy_url": fmt.Sprintf("%s/policy", serverUrlRoot), 33 "redirect_uris": []string{serverCallbackUrl}, 34 "grant_types": []string{"authorization_code", "refresh_token"}, 35 "response_types": []string{"code"}, 36 "application_type": "web", 37 "dpop_bound_access_tokens": true, 38 "jwks_uri": fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot), 39 "scope": "atproto transition:generic", 40 "token_endpoint_auth_method": "private_key_jwt", 41 "token_endpoint_auth_signing_alg": "ES256", 42 } 43 44 return e.JSON(200, metadata) 45} 46``` 47 48### Serve `jwks.json` 49 50You will also need to serve your private JWK's __public key__ from your domain. Again, an example is below. 51 52```go 53func (s *TestServer) handleJwks(e echo.Context) error { 54 b, err := os.ReadFile("./jwk.json") 55 if err != nil { 56 return err 57 } 58 59 k, err := oauth.ParseJWKFromBytes(b) 60 if err != nil { 61 return err 62 } 63 64 pubKey, err := k.PublicKey() 65 if err != nil { 66 return err 67 } 68 69 return e.JSON(200, oauth.CreateJwksResponseObject(pubKey)) 70} 71``` 72 73## Usage 74 75Once you have completed the prerequisites, you can implement and use the client. 76 77### Create a new OAuth Client 78 79Create an OAuth client by calling `oauth.NewClient` 80 81```go 82clientId := "https://yourdomain.com/path/to/client-metadata.json" 83callbackUrl := "https://yourdomain.com/oauth-callback" 84 85b, err := os.ReadFile("./jwks.json") 86if err != nil { 87 return err 88} 89 90k, err := oauth.ParseJWKFromBytes(b) 91if err != nil { 92 return err 93} 94 95cli, err := oauth.NewClient(oauth.ClientArgs{ 96 ClientJwk: k, 97 ClientId: clientId, 98 RedirectUri: callbackUrl, 99}) 100if err != nil { 101 return err 102} 103``` 104 105### Starting Authenticating 106 107There are examples of the authentication flow inside of `cmd/client_tester/handle_auth.go`, however we'll talk about some general points here. 108 109#### Determining the user's PDS 110 111You 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. 112 113```go 114cli := oauth.NewClient() 115userInput := "hailey.at" 116 117// If you already have a did or a URL, you can skip this step 118did, err := resolveHandle(ctx, userInput) // returns did:plc:abc123 or did:web:test.com 119if err != nil { 120 return err 121} 122 123// If you already have a URL, you can skip this step 124service, err := resolveService(ctx, did) // returns https://pds.haileyok.com 125if err != nil { 126 return err 127} 128 129authserver, err := cli.ResolvePdsAuthServer(ctx, service) 130if err != nil { 131 return err 132} 133 134authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver) 135if err != nil { 136 return err 137} 138``` 139 140By this point, you will have the necessary information to direct the user where they need to go. 141 142#### Create a private DPoP JWK for the user 143 144You'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. 145 146```go 147k, err := oauth.GenerateKey(nil) 148if err != nil { 149 return err 150} 151 152b, err := json.Marshal(k) 153if err != nil { 154 return err 155} 156``` 157 158#### Make the PAR request 159 160```go 161// Note: the login hint - here `handle` - should only be set if you have a DID or handle. Leave it empty if all you 162// have is the PDS url. 163parResp, err := cli.SendParAuthRequest(ctx, authserver, authmeta, handle, scope, dpopPrivateKey) 164if err != nil { 165 return err 166} 167``` 168 169#### Store the needed information before redirecting 170 171Some items will need to be stored for later when the PDS redirects to your application. 172 173- The user's DID, if you have it 174- The user's PDS url 175- The authserver issuer 176- The `state` value from the PAR request 177- The PKCE verifier from the PAR rquest 178- The DPoP autherserver nonce from the PAR request 179- The DPoP private JWK thhat you generated 180 181It 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`. 182 183#### Redirect 184 185Once 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. 186 187```go 188u, _ := url.Parse(meta.AuthorizationEndpoint) 189u.RawQuery = fmt.Sprintf("client_id=%s&requires_uri=%s", url.QueryEscape(yourClientId), parResp.RequestUri) 190 191// Redirect the user to created url 192``` 193 194### Callback handling 195 196Handling the response is pretty easy, though you'll want to check a few things once you receive the response. 197 198- Ensure that `state`, `iss`, and `code` are present in the `GET` parameters 199- Ensure that the `state` value matches the `state` value you stored before redirection 200 201You'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. 202 203```go 204resCode := e.QueryParam("code") 205resIss := e.QueryParam("iss") 206 207itResp, err := cli.InitialTokenRequest(ctx, resCode, resIss, requestInfo.PkceVerifier, requestInfo.DpopAuthserverNonce, requestInfo.privateJwk) 208if err != nil { 209 return err 210} 211``` 212 213#### Final checks 214 215Finally, 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`. 216 217```go 218if itResp.Scope != requestedScope { 219 return fmt.Errorf("bad scope") 220} 221 222if requestInfo.Did == "" { 223 // Do something... 224} 225``` 226 227#### Store the response 228 229Now, 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. 230