fork of haileyok/atproto-oauth-golang

par request working

+1 -1
.env.example
··· 1 1 OAUTH_TEST_SERVER_ADDR=":7070" 2 - OAUTH_TEST_SERVER_URL_ROOT="http://localhost:7070" 2 + OAUTH_TEST_SERVER_URL_ROOT="http://127.0.0.1:7070" 3 3 OAUTH_TEST_PDS_URL="https://pds.haileyok.com"
+1
.gitignore
··· 1 1 cmd/client_test/client_test 2 + jwks.json 2 3 .env
+6 -7
Makefile
··· 16 16 17 17 .PHONY: test 18 18 test: ## Run tests 19 - go test -v ./... 20 - 21 - .PHONY: coverage-html 22 - coverage-html: ## Generate test coverage report and open in browser 23 - go test ./... -coverpkg=./... -coverprofile=test-coverage.out 24 - go tool cover -html=test-coverage.out 19 + go clean -testcache && go test -v ./... 25 20 26 21 .PHONY: lint 27 22 lint: ## Verify code style and run static checks ··· 37 32 go build ./... 38 33 39 34 .PHONY: test-server 40 - test-server: 35 + test-server: ## Run the test server 41 36 go run ./cmd/client_test 37 + 38 + .PHONY: test-jwks 39 + test-jwks: ## Create a test jwks file 40 + go run ./cmd/cmd generate-jwks --prefix demo 42 41 43 42 .env: 44 43 if [ ! -f ".env" ]; then cp example.dev.env .env; fi
+86 -20
cmd/client_test/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "log/slog" 6 7 "net/http" 8 + "os" 7 9 10 + oauth "github.com/haileyok/atproto-oauth-golang" 11 + _ "github.com/joho/godotenv/autoload" 8 12 "github.com/labstack/echo/v4" 13 + "github.com/lestrrat-go/jwx/v2/jwk" 9 14 slogecho "github.com/samber/slog-echo" 10 15 "github.com/urfave/cli/v2" 16 + ) 17 + 18 + var ( 19 + ctx = context.Background() 20 + serverAddr = os.Getenv("OAUTH_TEST_SERVER_ADDR") 21 + serverUrlRoot = os.Getenv("OAUTH_TEST_SERVER_URL_ROOT") 22 + serverMetadataUrl = fmt.Sprintf("%s/oauth/client-metadata.json", serverUrlRoot) 23 + serverCallbackUrl = fmt.Sprintf("%s/callback", serverUrlRoot) 24 + pdsUrl = os.Getenv("OAUTH_TEST_PDS_URL") 11 25 ) 12 26 13 27 func main() { ··· 16 30 Action: run, 17 31 } 18 32 33 + if serverUrlRoot == "" { 34 + panic(fmt.Errorf("no server url root set in env file")) 35 + } 36 + 19 37 app.RunAndExitOnError() 20 38 } 21 39 40 + type TestServer struct { 41 + httpd *http.Server 42 + e *echo.Echo 43 + jwksResponse *oauth.JwksResponseObject 44 + } 45 + 22 46 func run(cmd *cli.Context) error { 47 + s, err := NewServer() 48 + if err != nil { 49 + panic(err) 50 + } 51 + 52 + s.run() 53 + 54 + return nil 55 + } 56 + 57 + func NewServer() (*TestServer, error) { 23 58 e := echo.New() 24 59 25 60 e.Use(slogecho.New(slog.Default())) 26 61 27 62 fmt.Println("atproto oauth golang tester server") 28 63 29 - e.GET("/oauth/client-metadata.json", handleClientMetadata) 64 + b, err := os.ReadFile("./jwks.json") 65 + if err != nil { 66 + if os.IsNotExist(err) { 67 + return nil, fmt.Errorf("could not find jwks.json. does it exist? hint: run `go run ./cmd/cmd generate-jwks --prefix demo` to create one.") 68 + } 69 + return nil, err 70 + } 30 71 31 - httpd := http.Server{ 32 - Addr: ":7070", 72 + k, err := jwk.ParseKey(b) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + pubKey, err := k.PublicKey() 78 + if err != nil { 79 + return nil, err 80 + } 81 + 82 + httpd := &http.Server{ 83 + Addr: serverAddr, 33 84 Handler: e, 34 85 } 35 86 36 87 fmt.Println("starting http server...") 37 88 38 - if err := httpd.ListenAndServe(); err != nil { 89 + return &TestServer{ 90 + httpd: httpd, 91 + e: e, 92 + jwksResponse: oauth.CreateJwksResponseObject(pubKey), 93 + }, nil 94 + } 95 + 96 + func (s *TestServer) run() error { 97 + s.e.GET("/oauth/client-metadata.json", s.handleClientMetadata) 98 + s.e.GET("/oauth/jwks.json", s.handleJwks) 99 + 100 + if err := s.httpd.ListenAndServe(); err != nil { 39 101 return err 40 102 } 41 103 42 104 return nil 43 105 } 44 106 45 - func handleClientMetadata(e echo.Context) error { 46 - e.Response().Header().Add("Content-Type", "application/json") 47 - 107 + func (s *TestServer) handleClientMetadata(e echo.Context) error { 48 108 metadata := map[string]any{ 49 - "client_id": "http://localhost:7070/oauth/oauth-metadata.json", 50 - "client_name": "Atproto Oauth Golang Tester", 51 - "client_uri": "http://localhost:7070", 52 - "logo_uri": "http://localhost:7070/logo.png", 53 - "tos_uri": "http://localhost:7070/tos", 54 - "policy_url": "http://localhost:7070/policy", 55 - "redirect_uris": []string{"http://localhost:7070/callback"}, 56 - "grant_types": []string{"authorization_code", "refresh_token"}, 57 - "response_types": []string{"code"}, 58 - "application_type": "web", 59 - "token_endpoint_auth_method": "private_key_jwt", 60 - "dpop_bound_accesss_tokens": true, 61 - "jwks_uri": "http://localhost:7070/jwks.json", 109 + "client_id": serverMetadataUrl, 110 + "client_name": "Atproto Oauth Golang Tester", 111 + "client_uri": serverUrlRoot, 112 + "logo_uri": fmt.Sprintf("%s/logo.png", serverUrlRoot), 113 + "tos_uri": fmt.Sprintf("%s/tos", serverUrlRoot), 114 + "policy_url": fmt.Sprintf("%s/policy", serverUrlRoot), 115 + "redirect_uris": []string{serverCallbackUrl}, 116 + "grant_types": []string{"authorization_code", "refresh_token"}, 117 + "response_types": []string{"code"}, 118 + "application_type": "web", 119 + "dpop_bound_access_tokens": true, 120 + "jwks_uri": fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot), 121 + "scope": "atproto transition:generic", 122 + "token_endpoint_auth_method": "private_key_jwt", 123 + "token_endpoint_auth_signing_alg": "ES256", 62 124 } 63 125 64 126 return e.JSON(200, metadata) 65 127 } 128 + 129 + func (s *TestServer) handleJwks(e echo.Context) error { 130 + return e.JSON(200, s.jwksResponse) 131 + }
+52
cmd/cmd/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + 7 + oauth "github.com/haileyok/atproto-oauth-golang" 8 + "github.com/urfave/cli/v2" 9 + ) 10 + 11 + func main() { 12 + app := &cli.App{ 13 + Name: "Atproto Oauth Golang Helper", 14 + Commands: []*cli.Command{ 15 + runGenerateJwks, 16 + }, 17 + } 18 + 19 + app.RunAndExitOnError() 20 + } 21 + 22 + var runGenerateJwks = &cli.Command{ 23 + Name: "generate-jwks", 24 + Flags: []cli.Flag{ 25 + &cli.StringFlag{ 26 + Name: "prefix", 27 + Required: false, 28 + }, 29 + }, 30 + Action: func(cmd *cli.Context) error { 31 + var prefix *string 32 + if cmd.String("prefix") != "" { 33 + inputPrefix := cmd.String("prefix") 34 + prefix = &inputPrefix 35 + } 36 + key, err := oauth.GenerateKey(prefix) 37 + if err != nil { 38 + return err 39 + } 40 + 41 + b, err := json.Marshal(key) 42 + if err != nil { 43 + return err 44 + } 45 + 46 + if err := os.WriteFile("./jwks.json", b, 0644); err != nil { 47 + return err 48 + } 49 + 50 + return nil 51 + }, 52 + }
+21 -4
generic.go
··· 22 22 return nil, err 23 23 } 24 24 25 + var kid string 25 26 if kidPrefix != nil { 26 - kid := fmt.Sprintf("%s-%d", *kidPrefix, time.Now().Unix()) 27 + kid = fmt.Sprintf("%s-%d", *kidPrefix, time.Now().Unix()) 27 28 28 - if err := key.Set(jwk.KeyIDKey, kid); err != nil { 29 - return nil, err 30 - } 29 + } else { 30 + kid = fmt.Sprintf("%d", time.Now().Unix()) 31 31 } 32 32 33 + if err := key.Set(jwk.KeyIDKey, kid); err != nil { 34 + return nil, err 35 + } 33 36 return key, nil 34 37 } 35 38 ··· 75 78 76 79 return &pkey, nil 77 80 } 81 + 82 + type JwksResponseObject struct { 83 + Keys []jwk.Key `json:"keys"` 84 + } 85 + 86 + func CreateJwksResponseObject(key jwk.Key) *JwksResponseObject { 87 + return &JwksResponseObject{ 88 + Keys: []jwk.Key{key}, 89 + } 90 + } 91 + 92 + func ParseKeyFromBytes(b []byte) (jwk.Key, error) { 93 + return jwk.ParseKey(b) 94 + }
+38 -25
oauth.go
··· 30 30 31 31 type OauthClientArgs struct { 32 32 H *http.Client 33 - ClientJwk []byte 33 + ClientJwk jwk.Key 34 34 ClientId string 35 35 RedirectUri string 36 36 } ··· 50 50 } 51 51 } 52 52 53 - clientJwk, err := jwk.ParseKey(args.ClientJwk) 54 - if err != nil { 55 - return nil, err 56 - } 57 - 58 - clientPkey, err := getPrivateKey(clientJwk) 53 + clientPkey, err := getPrivateKey(args.ClientJwk) 59 54 if err != nil { 60 55 return nil, fmt.Errorf("could not load private key from provided client jwk: %w", err) 61 56 } 62 57 63 - kid := clientJwk.KeyID() 58 + kid := args.ClientJwk.KeyID() 64 59 65 60 return &OauthClient{ 66 61 h: args.H, ··· 174 169 } 175 170 176 171 func (c *OauthClient) AuthServerDpopJwt(method, url, nonce string, privateJwk jwk.Key) (string, error) { 177 - raw, err := jwk.PublicKeyOf(privateJwk) 178 - if err != nil { 179 - return "", err 180 - } 181 - 182 - pubJwk, err := jwk.FromRaw(raw) 172 + pubJwk, err := privateJwk.PublicKey() 183 173 if err != nil { 184 174 return "", err 185 175 } ··· 189 179 return "", err 190 180 } 191 181 192 - var pubMap map[string]interface{} 182 + var pubMap map[string]any 193 183 if err := json.Unmarshal(b, &pubMap); err != nil { 194 184 return "", err 195 185 } ··· 213 203 token.Header["alg"] = "ES256" 214 204 token.Header["jwk"] = pubMap 215 205 216 - var rawKey interface{} 206 + var rawKey any 217 207 if err := privateJwk.Raw(&rawKey); err != nil { 218 208 return "", err 219 209 } ··· 230 220 PkceVerifier string 231 221 State string 232 222 DpopAuthserverNonce string 233 - Resp map[string]string 223 + Resp map[string]any 234 224 } 235 225 236 226 func (c *OauthClient) SendParAuthRequest(ctx context.Context, authServerUrl string, authServerMeta *OauthAuthorizationMetadata, loginHint, scope string, dpopPrivateKey jwk.Key) (*SendParAuthResponse, error) { ··· 259 249 } 260 250 261 251 // TODO: ?? 262 - nonce := "" 263 - dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, nonce, dpopPrivateKey) 252 + dpopAuthserverNonce := "" 253 + dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey) 264 254 if err != nil { 265 - return nil, err 255 + return nil, fmt.Errorf("error getting dpop proof: %w", err) 266 256 } 267 257 268 258 params := url.Values{ ··· 300 290 } 301 291 defer resp.Body.Close() 302 292 303 - var rmap map[string]string 293 + var rmap map[string]any 304 294 if err := json.NewDecoder(resp.Body).Decode(&rmap); err != nil { 305 295 return nil, err 306 296 } 307 297 308 - // TODO: there's some logic in the flask example where we retry if the server 309 - // asks us to use a dpop nonce. we should add that here eventually, but for now 310 - // we'll skip that 298 + if resp.StatusCode == 400 && rmap["error"] == "use_dpop_nonce" { 299 + dpopAuthserverNonce = resp.Header.Get("DPoP-Nonce") 300 + dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey) 301 + if err != nil { 302 + return nil, err 303 + } 304 + 305 + req2, err := http.NewRequestWithContext(ctx, "POST", parUrl, strings.NewReader(params.Encode())) 306 + if err != nil { 307 + return nil, err 308 + } 309 + 310 + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") 311 + req2.Header.Set("DPoP", dpopProof) 312 + 313 + resp2, err := c.h.Do(req2) 314 + if err != nil { 315 + return nil, err 316 + } 317 + defer resp2.Body.Close() 318 + 319 + rmap = map[string]any{} 320 + if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil { 321 + return nil, err 322 + } 323 + } 311 324 312 325 return &SendParAuthResponse{ 313 326 PkceVerifier: pkceVerifier, 314 327 State: state, 315 - DpopAuthserverNonce: "", // add here later 328 + DpopAuthserverNonce: dpopAuthserverNonce, 316 329 Resp: rmap, 317 330 }, nil 318 331 }
+28 -5
oauth_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "fmt" 7 6 "io" 8 7 "net/http" ··· 23 22 ) 24 23 25 24 func newTestOauthClient() *OauthClient { 26 - prefix := "testing" 27 - testKey, err := GenerateKey(&prefix) 25 + b, err := os.ReadFile("./jwks.json") 28 26 if err != nil { 29 27 panic(err) 30 28 } 31 29 32 - b, err := json.Marshal(testKey) 30 + k, err := ParseKeyFromBytes(b) 33 31 if err != nil { 34 32 panic(err) 35 33 } 36 34 37 35 c, err := NewOauthClient(OauthClientArgs{ 38 - ClientJwk: b, 36 + ClientJwk: k, 39 37 ClientId: serverMetadataUrl, 40 38 RedirectUri: serverCallbackUrl, 41 39 }) ··· 87 85 _, err := GenerateKey(&prefix) 88 86 assert.NoError(err) 89 87 } 88 + 89 + func TestSendParAuthRequest(t *testing.T) { 90 + assert := assert.New(t) 91 + 92 + authserverUrl, err := oauthClient.ResolvePDSAuthServer(ctx, pdsUrl) 93 + meta, err := oauthClient.FetchAuthServerMetadata(ctx, pdsUrl) 94 + if err != nil { 95 + panic(err) 96 + } 97 + 98 + prefix := "testing" 99 + dpopPriv, err := GenerateKey(&prefix) 100 + if err != nil { 101 + panic(err) 102 + } 103 + 104 + parResp, err := oauthClient.SendParAuthRequest(ctx, authserverUrl, meta, "transition:generic", "atproto", dpopPriv) 105 + if err != nil { 106 + panic(err) 107 + } 108 + 109 + assert.NoError(err) 110 + assert.Equal(float64(299), parResp.Resp["expires_in"]) 111 + assert.NotEmpty(parResp.Resp["request_uri"]) 112 + }