this repo has no description

readme and fixes

+4
Makefile
··· 39 39 test-jwks: ## Create a test jwks file 40 40 go run ./cmd/cmd generate-jwks --prefix demo 41 41 42 + .PHONY: jwks 43 + jwks: 44 + go run ./cmd/cmd generate-jwks 45 + 42 46 .env: 43 47 if [ ! -f ".env" ]; then cp example.dev.env .env; fi
+230
README.md
··· 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 11 + There are some prerequisites that you'll need to handle before implementing this OAuth client. 12 + 13 + ### Private JWK 14 + If 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 + 18 + You 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 + 22 + The client metadata will need to be accessible from your domain. An example using `echo` is below. 23 + 24 + ```go 25 + func (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 + 50 + You will also need to serve your private JWK's __public key__ from your domain. Again, an example is below. 51 + 52 + ```go 53 + func (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 + 75 + Once you have completed the prerequisites, you can implement and use the client. 76 + 77 + ### Create a new OAuth Client 78 + 79 + Create an OAuth client by calling `oauth.NewClient` 80 + 81 + ```go 82 + clientId := "https://yourdomain.com/path/to/client-metadata.json" 83 + callbackUrl := "https://yourdomain.com/oauth-callback" 84 + 85 + b, err := os.ReadFile("./jwks.json") 86 + if err != nil { 87 + return err 88 + } 89 + 90 + k, err := oauth.ParseJWKFromBytes(b) 91 + if err != nil { 92 + return err 93 + } 94 + 95 + cli, err := oauth.NewClient(oauth.ClientArgs{ 96 + ClientJwk: k, 97 + ClientId: clientId, 98 + RedirectUri: callbackUrl, 99 + }) 100 + if err != nil { 101 + return err 102 + } 103 + ``` 104 + 105 + ### Starting Authenticating 106 + 107 + There 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 + 111 + You 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 114 + cli := oauth.NewClient() 115 + userInput := "hailey.at" 116 + 117 + // If you already have a did or a URL, you can skip this step 118 + did, err := resolveHandle(ctx, userInput) // returns did:plc:abc123 or did:web:test.com 119 + if err != nil { 120 + return err 121 + } 122 + 123 + // If you already have a URL, you can skip this step 124 + service, err := resolveService(ctx, did) // returns https://pds.haileyok.com 125 + if err != nil { 126 + return err 127 + } 128 + 129 + authserver, err := cli.ResolvePdsAuthServer(ctx, service) 130 + if err != nil { 131 + return err 132 + } 133 + 134 + authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver) 135 + if err != nil { 136 + return err 137 + } 138 + ``` 139 + 140 + By 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 + 144 + You'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 147 + k, err := oauth.GenerateKey(nil) 148 + if err != nil { 149 + return err 150 + } 151 + 152 + b, err := json.Marshal(k) 153 + if 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. 163 + parResp, err := cli.SendParAuthRequest(ctx, authserver, authmeta, handle, scope, dpopPrivateKey) 164 + if err != nil { 165 + return err 166 + } 167 + ``` 168 + 169 + #### Store the needed information before redirecting 170 + 171 + Some 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 + 181 + It 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 + 185 + Once 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 188 + u, _ := url.Parse(meta.AuthorizationEndpoint) 189 + u.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 + 196 + Handling 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 + 201 + You'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 204 + resCode := e.QueryParam("code") 205 + resIss := e.QueryParam("iss") 206 + 207 + itResp, err := cli.InitialTokenRequest(ctx, resCode, resIss, requestInfo.PkceVerifier, requestInfo.DpopAuthserverNonce, requestInfo.privateJwk) 208 + if err != nil { 209 + return err 210 + } 211 + ``` 212 + 213 + #### Final checks 214 + 215 + Finally, 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 218 + if itResp.Scope != requestedScope { 219 + return fmt.Errorf("bad scope") 220 + } 221 + 222 + if requestInfo.Did == "" { 223 + // Do something... 224 + } 225 + ``` 226 + 227 + #### Store the response 228 + 229 + Now, 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 +
+10 -16
cmd/client_test/handle_auth.go
··· 16 16 ) 17 17 18 18 func (s *TestServer) handleLoginSubmit(e echo.Context) error { 19 - authInput := e.FormValue("auth-input") 19 + authInput := strings.ToLower(e.FormValue("auth-input")) 20 20 if authInput == "" { 21 21 return e.Redirect(302, "/login?e=auth-input-empty") 22 22 } 23 23 24 24 var service string 25 25 var did string 26 + var loginHint string 26 27 27 28 if strings.HasPrefix("https://", authInput) { 28 29 u, err := url.Parse(authInput) ··· 57 58 } 58 59 59 60 service = maybeService 61 + loginHint = authInput 60 62 } 61 63 62 - authserver, err := s.oauthClient.ResolvePDSAuthServer(ctx, service) 64 + authserver, err := s.oauthClient.ResolvePdsAuthServer(ctx, service) 63 65 if err != nil { 64 66 return err 65 67 } ··· 79 81 return err 80 82 } 81 83 82 - parResp, err := s.oauthClient.SendParAuthRequest( 83 - ctx, 84 - authserver, 85 - meta, 86 - "", 87 - scope, 88 - dpopPrivateKey, 89 - ) 84 + parResp, err := s.oauthClient.SendParAuthRequest(ctx, authserver, meta, loginHint, scope, dpopPrivateKey) 85 + if err != nil { 86 + return err 87 + } 90 88 91 89 oauthRequest := &OauthRequest{ 92 90 State: parResp.State, ··· 103 101 } 104 102 105 103 u, _ := url.Parse(meta.AuthorizationEndpoint) 106 - u.RawQuery = fmt.Sprintf( 107 - "client_id=%s&request_uri=%s", 108 - url.QueryEscape(serverMetadataUrl), 109 - parResp.Resp["request_uri"].(string), 110 - ) 104 + u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(serverMetadataUrl), parResp.RequestUri) 111 105 112 106 sess, err := session.Get("session", e) 113 107 if err != nil { ··· 165 159 return fmt.Errorf("incoming iss did not match authserver iss") 166 160 } 167 161 168 - jwk, err := oauth.ParseKeyFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 162 + jwk, err := oauth.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 169 163 if err != nil { 170 164 return err 171 165 }
+1 -2
cmd/client_test/main.go
··· 14 14 _ "github.com/joho/godotenv/autoload" 15 15 "github.com/labstack/echo-contrib/session" 16 16 "github.com/labstack/echo/v4" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 17 slogecho "github.com/samber/slog-echo" 19 18 "github.com/urfave/cli/v2" 20 19 "gorm.io/driver/sqlite" ··· 101 100 return nil, err 102 101 } 103 102 104 - k, err := jwk.ParseKey(b) 103 + k, err := oauth.ParseJWKFromBytes(b) 105 104 if err != nil { 106 105 return nil, err 107 106 }
+2 -2
cmd/client_test/user.go
··· 21 21 } 22 22 23 23 if oauthSession.Expiration.Sub(time.Now()) <= 5*time.Minute { 24 - privateJwk, err := oauth.ParseKeyFromBytes([]byte(oauthSession.DpopPrivateJwk)) 24 + privateJwk, err := oauth.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk)) 25 25 if err != nil { 26 26 return nil, err 27 27 } ··· 59 59 60 60 oauthSession, err := s.getOauthSession(e.Request().Context(), did) 61 61 62 - privateJwk, err := oauth.ParseKeyFromBytes([]byte(oauthSession.DpopPrivateJwk)) 62 + privateJwk, err := oauth.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk)) 63 63 if err != nil { 64 64 return nil, false, err 65 65 }
+1 -1
generic.go
··· 92 92 } 93 93 } 94 94 95 - func ParseKeyFromBytes(b []byte) (jwk.Key, error) { 95 + func ParseJWKFromBytes(b []byte) (jwk.Key, error) { 96 96 return jwk.ParseKey(b) 97 97 } 98 98
+37 -28
oauth.go
··· 62 62 }, nil 63 63 } 64 64 65 - func (c *Client) ResolvePDSAuthServer(ctx context.Context, ustr string) (string, error) { 65 + func (c *Client) ResolvePdsAuthServer(ctx context.Context, ustr string) (string, error) { 66 66 u, err := isSafeAndParsed(ustr) 67 67 if err != nil { 68 68 return "", err ··· 234 234 235 235 clientAssertion, err := c.ClientAssertionJwt(authServerUrl) 236 236 if err != nil { 237 - return nil, err 237 + return nil, fmt.Errorf("error getting client assertion: %w", err) 238 238 } 239 239 240 240 dpopAuthserverNonce := "" ··· 283 283 return nil, err 284 284 } 285 285 286 - if resp.StatusCode == 400 && rmap["error"] == "use_dpop_nonce" { 287 - dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce") 288 - dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey) 289 - if err != nil { 290 - return nil, err 291 - } 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 + } 292 303 293 - req2, err := http.NewRequestWithContext( 294 - ctx, 295 - "POST", 296 - parUrl, 297 - strings.NewReader(params.Encode()), 298 - ) 299 - if err != nil { 300 - return nil, err 301 - } 304 + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") 305 + req2.Header.Set("DPoP", dpopProof) 302 306 303 - req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") 304 - req2.Header.Set("DPoP", dpopProof) 307 + resp2, err := c.h.Do(req2) 308 + if err != nil { 309 + return nil, err 310 + } 311 + defer resp2.Body.Close() 305 312 306 - resp2, err := c.h.Do(req2) 307 - if err != nil { 308 - return nil, err 309 - } 310 - defer resp2.Body.Close() 313 + rmap = map[string]any{} 314 + if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil { 315 + return nil, err 316 + } 311 317 312 - rmap = map[string]any{} 313 - if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil { 314 - return nil, err 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"]) 315 323 } 316 324 } 317 325 ··· 319 327 PkceVerifier: pkceVerifier, 320 328 State: state, 321 329 DpopAuthserverNonce: dpopAuthserverNonce, 322 - Resp: rmap, 330 + ExpiresIn: rmap["expires_in"].(float64), 331 + RequestUri: rmap["request_uri"].(string), 323 332 }, nil 324 333 } 325 334
+5 -5
oauth_test.go
··· 27 27 panic(err) 28 28 } 29 29 30 - k, err := ParseKeyFromBytes(b) 30 + k, err := ParseJWKFromBytes(b) 31 31 if err != nil { 32 32 panic(err) 33 33 } ··· 61 61 func TestResolvePDSAuthServer(t *testing.T) { 62 62 assert := assert.New(t) 63 63 64 - authServer, err := oauthClient.ResolvePDSAuthServer(ctx, pdsUrl) 64 + authServer, err := oauthClient.ResolvePdsAuthServer(ctx, pdsUrl) 65 65 66 66 assert.NoError(err) 67 67 assert.NotEmpty(authServer) ··· 88 88 func TestSendParAuthRequest(t *testing.T) { 89 89 assert := assert.New(t) 90 90 91 - authserverUrl, err := oauthClient.ResolvePDSAuthServer(ctx, pdsUrl) 91 + authserverUrl, err := oauthClient.ResolvePdsAuthServer(ctx, pdsUrl) 92 92 meta, err := oauthClient.FetchAuthServerMetadata(ctx, pdsUrl) 93 93 if err != nil { 94 94 panic(err) ··· 106 106 } 107 107 108 108 assert.NoError(err) 109 - assert.Equal(float64(299), parResp.Resp["expires_in"]) 110 - assert.NotEmpty(parResp.Resp["request_uri"]) 109 + assert.Equal(float64(299), parResp.ExpiresIn) 110 + assert.NotEmpty(parResp.RequestUri) 111 111 }
+2 -1
types.go
··· 28 28 PkceVerifier string 29 29 State string 30 30 DpopAuthserverNonce string 31 - Resp map[string]any 31 + ExpiresIn float64 32 + RequestUri string 32 33 } 33 34 34 35 type OauthProtectedResource struct {