+1
-1
.env.example
+1
-1
.env.example
+6
-7
Makefile
+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
+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
+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
+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
+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
+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
+
}