+1
-1
.env.example
+1
-1
.env.example
+1
.gitignore
+1
.gitignore
+6
-7
Makefile
+6
-7
Makefile
···
16
17
.PHONY: test
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
25
26
.PHONY: lint
27
lint: ## Verify code style and run static checks
···
37
go build ./...
38
39
.PHONY: test-server
40
-
test-server:
41
go run ./cmd/client_test
42
43
.env:
44
if [ ! -f ".env" ]; then cp example.dev.env .env; fi
···
16
17
.PHONY: test
18
test: ## Run tests
19
+
go clean -testcache && go test -v ./...
20
21
.PHONY: lint
22
lint: ## Verify code style and run static checks
···
32
go build ./...
33
34
.PHONY: test-server
35
+
test-server: ## Run the test server
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
41
42
.env:
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
package main
2
3
import (
4
"fmt"
5
"log/slog"
6
"net/http"
7
8
"github.com/labstack/echo/v4"
9
slogecho "github.com/samber/slog-echo"
10
"github.com/urfave/cli/v2"
11
)
12
13
func main() {
···
16
Action: run,
17
}
18
19
app.RunAndExitOnError()
20
}
21
22
func run(cmd *cli.Context) error {
23
e := echo.New()
24
25
e.Use(slogecho.New(slog.Default()))
26
27
fmt.Println("atproto oauth golang tester server")
28
29
-
e.GET("/oauth/client-metadata.json", handleClientMetadata)
30
31
-
httpd := http.Server{
32
-
Addr: ":7070",
33
Handler: e,
34
}
35
36
fmt.Println("starting http server...")
37
38
-
if err := httpd.ListenAndServe(); err != nil {
39
return err
40
}
41
42
return nil
43
}
44
45
-
func handleClientMetadata(e echo.Context) error {
46
-
e.Response().Header().Add("Content-Type", "application/json")
47
-
48
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",
62
}
63
64
return e.JSON(200, metadata)
65
}
···
1
package main
2
3
import (
4
+
"context"
5
"fmt"
6
"log/slog"
7
"net/http"
8
+
"os"
9
10
+
oauth "github.com/haileyok/atproto-oauth-golang"
11
+
_ "github.com/joho/godotenv/autoload"
12
"github.com/labstack/echo/v4"
13
+
"github.com/lestrrat-go/jwx/v2/jwk"
14
slogecho "github.com/samber/slog-echo"
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")
25
)
26
27
func main() {
···
30
Action: run,
31
}
32
33
+
if serverUrlRoot == "" {
34
+
panic(fmt.Errorf("no server url root set in env file"))
35
+
}
36
+
37
app.RunAndExitOnError()
38
}
39
40
+
type TestServer struct {
41
+
httpd *http.Server
42
+
e *echo.Echo
43
+
jwksResponse *oauth.JwksResponseObject
44
+
}
45
+
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) {
58
e := echo.New()
59
60
e.Use(slogecho.New(slog.Default()))
61
62
fmt.Println("atproto oauth golang tester server")
63
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
+
}
71
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,
84
Handler: e,
85
}
86
87
fmt.Println("starting http server...")
88
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 {
101
return err
102
}
103
104
return nil
105
}
106
107
+
func (s *TestServer) handleClientMetadata(e echo.Context) error {
108
metadata := map[string]any{
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",
124
}
125
126
return e.JSON(200, metadata)
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
return nil, err
23
}
24
25
+
var kid string
26
if kidPrefix != nil {
27
+
kid = fmt.Sprintf("%s-%d", *kidPrefix, time.Now().Unix())
28
29
+
} else {
30
+
kid = fmt.Sprintf("%d", time.Now().Unix())
31
}
32
33
+
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
34
+
return nil, err
35
+
}
36
return key, nil
37
}
38
···
78
79
return &pkey, nil
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
31
type OauthClientArgs struct {
32
H *http.Client
33
-
ClientJwk []byte
34
ClientId string
35
RedirectUri string
36
}
···
50
}
51
}
52
53
-
clientJwk, err := jwk.ParseKey(args.ClientJwk)
54
-
if err != nil {
55
-
return nil, err
56
-
}
57
-
58
-
clientPkey, err := getPrivateKey(clientJwk)
59
if err != nil {
60
return nil, fmt.Errorf("could not load private key from provided client jwk: %w", err)
61
}
62
63
-
kid := clientJwk.KeyID()
64
65
return &OauthClient{
66
h: args.H,
···
174
}
175
176
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)
183
if err != nil {
184
return "", err
185
}
···
189
return "", err
190
}
191
192
-
var pubMap map[string]interface{}
193
if err := json.Unmarshal(b, &pubMap); err != nil {
194
return "", err
195
}
···
213
token.Header["alg"] = "ES256"
214
token.Header["jwk"] = pubMap
215
216
-
var rawKey interface{}
217
if err := privateJwk.Raw(&rawKey); err != nil {
218
return "", err
219
}
···
230
PkceVerifier string
231
State string
232
DpopAuthserverNonce string
233
-
Resp map[string]string
234
}
235
236
func (c *OauthClient) SendParAuthRequest(ctx context.Context, authServerUrl string, authServerMeta *OauthAuthorizationMetadata, loginHint, scope string, dpopPrivateKey jwk.Key) (*SendParAuthResponse, error) {
···
259
}
260
261
// TODO: ??
262
-
nonce := ""
263
-
dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, nonce, dpopPrivateKey)
264
if err != nil {
265
-
return nil, err
266
}
267
268
params := url.Values{
···
300
}
301
defer resp.Body.Close()
302
303
-
var rmap map[string]string
304
if err := json.NewDecoder(resp.Body).Decode(&rmap); err != nil {
305
return nil, err
306
}
307
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
311
312
return &SendParAuthResponse{
313
PkceVerifier: pkceVerifier,
314
State: state,
315
-
DpopAuthserverNonce: "", // add here later
316
Resp: rmap,
317
}, nil
318
}
···
30
31
type OauthClientArgs struct {
32
H *http.Client
33
+
ClientJwk jwk.Key
34
ClientId string
35
RedirectUri string
36
}
···
50
}
51
}
52
53
+
clientPkey, err := getPrivateKey(args.ClientJwk)
54
if err != nil {
55
return nil, fmt.Errorf("could not load private key from provided client jwk: %w", err)
56
}
57
58
+
kid := args.ClientJwk.KeyID()
59
60
return &OauthClient{
61
h: args.H,
···
169
}
170
171
func (c *OauthClient) AuthServerDpopJwt(method, url, nonce string, privateJwk jwk.Key) (string, error) {
172
+
pubJwk, err := privateJwk.PublicKey()
173
if err != nil {
174
return "", err
175
}
···
179
return "", err
180
}
181
182
+
var pubMap map[string]any
183
if err := json.Unmarshal(b, &pubMap); err != nil {
184
return "", err
185
}
···
203
token.Header["alg"] = "ES256"
204
token.Header["jwk"] = pubMap
205
206
+
var rawKey any
207
if err := privateJwk.Raw(&rawKey); err != nil {
208
return "", err
209
}
···
220
PkceVerifier string
221
State string
222
DpopAuthserverNonce string
223
+
Resp map[string]any
224
}
225
226
func (c *OauthClient) SendParAuthRequest(ctx context.Context, authServerUrl string, authServerMeta *OauthAuthorizationMetadata, loginHint, scope string, dpopPrivateKey jwk.Key) (*SendParAuthResponse, error) {
···
249
}
250
251
// TODO: ??
252
+
dpopAuthserverNonce := ""
253
+
dpopProof, err := c.AuthServerDpopJwt("POST", parUrl, dpopAuthserverNonce, dpopPrivateKey)
254
if err != nil {
255
+
return nil, fmt.Errorf("error getting dpop proof: %w", err)
256
}
257
258
params := url.Values{
···
290
}
291
defer resp.Body.Close()
292
293
+
var rmap map[string]any
294
if err := json.NewDecoder(resp.Body).Decode(&rmap); err != nil {
295
return nil, err
296
}
297
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
+
}
324
325
return &SendParAuthResponse{
326
PkceVerifier: pkceVerifier,
327
State: state,
328
+
DpopAuthserverNonce: dpopAuthserverNonce,
329
Resp: rmap,
330
}, nil
331
}
+28
-5
oauth_test.go
+28
-5
oauth_test.go
···
2
3
import (
4
"context"
5
-
"encoding/json"
6
"fmt"
7
"io"
8
"net/http"
···
23
)
24
25
func newTestOauthClient() *OauthClient {
26
-
prefix := "testing"
27
-
testKey, err := GenerateKey(&prefix)
28
if err != nil {
29
panic(err)
30
}
31
32
-
b, err := json.Marshal(testKey)
33
if err != nil {
34
panic(err)
35
}
36
37
c, err := NewOauthClient(OauthClientArgs{
38
-
ClientJwk: b,
39
ClientId: serverMetadataUrl,
40
RedirectUri: serverCallbackUrl,
41
})
···
87
_, err := GenerateKey(&prefix)
88
assert.NoError(err)
89
}
···
2
3
import (
4
"context"
5
"fmt"
6
"io"
7
"net/http"
···
22
)
23
24
func newTestOauthClient() *OauthClient {
25
+
b, err := os.ReadFile("./jwks.json")
26
if err != nil {
27
panic(err)
28
}
29
30
+
k, err := ParseKeyFromBytes(b)
31
if err != nil {
32
panic(err)
33
}
34
35
c, err := NewOauthClient(OauthClientArgs{
36
+
ClientJwk: k,
37
ClientId: serverMetadataUrl,
38
RedirectUri: serverCallbackUrl,
39
})
···
85
_, err := GenerateKey(&prefix)
86
assert.NoError(err)
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
+
}