+4
Makefile
+4
Makefile
+230
README.md
+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
+10
-16
cmd/client_test/handle_auth.go
···
16
)
17
18
func (s *TestServer) handleLoginSubmit(e echo.Context) error {
19
-
authInput := e.FormValue("auth-input")
20
if authInput == "" {
21
return e.Redirect(302, "/login?e=auth-input-empty")
22
}
23
24
var service string
25
var did string
26
27
if strings.HasPrefix("https://", authInput) {
28
u, err := url.Parse(authInput)
···
57
}
58
59
service = maybeService
60
}
61
62
-
authserver, err := s.oauthClient.ResolvePDSAuthServer(ctx, service)
63
if err != nil {
64
return err
65
}
···
79
return err
80
}
81
82
-
parResp, err := s.oauthClient.SendParAuthRequest(
83
-
ctx,
84
-
authserver,
85
-
meta,
86
-
"",
87
-
scope,
88
-
dpopPrivateKey,
89
-
)
90
91
oauthRequest := &OauthRequest{
92
State: parResp.State,
···
103
}
104
105
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
-
)
111
112
sess, err := session.Get("session", e)
113
if err != nil {
···
165
return fmt.Errorf("incoming iss did not match authserver iss")
166
}
167
168
-
jwk, err := oauth.ParseKeyFromBytes([]byte(oauthRequest.DpopPrivateJwk))
169
if err != nil {
170
return err
171
}
···
16
)
17
18
func (s *TestServer) handleLoginSubmit(e echo.Context) error {
19
+
authInput := strings.ToLower(e.FormValue("auth-input"))
20
if authInput == "" {
21
return e.Redirect(302, "/login?e=auth-input-empty")
22
}
23
24
var service string
25
var did string
26
+
var loginHint string
27
28
if strings.HasPrefix("https://", authInput) {
29
u, err := url.Parse(authInput)
···
58
}
59
60
service = maybeService
61
+
loginHint = authInput
62
}
63
64
+
authserver, err := s.oauthClient.ResolvePdsAuthServer(ctx, service)
65
if err != nil {
66
return err
67
}
···
81
return err
82
}
83
84
+
parResp, err := s.oauthClient.SendParAuthRequest(ctx, authserver, meta, loginHint, scope, dpopPrivateKey)
85
+
if err != nil {
86
+
return err
87
+
}
88
89
oauthRequest := &OauthRequest{
90
State: parResp.State,
···
101
}
102
103
u, _ := url.Parse(meta.AuthorizationEndpoint)
104
+
u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(serverMetadataUrl), parResp.RequestUri)
105
106
sess, err := session.Get("session", e)
107
if err != nil {
···
159
return fmt.Errorf("incoming iss did not match authserver iss")
160
}
161
162
+
jwk, err := oauth.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
163
if err != nil {
164
return err
165
}
+1
-2
cmd/client_test/main.go
+1
-2
cmd/client_test/main.go
···
14
_ "github.com/joho/godotenv/autoload"
15
"github.com/labstack/echo-contrib/session"
16
"github.com/labstack/echo/v4"
17
-
"github.com/lestrrat-go/jwx/v2/jwk"
18
slogecho "github.com/samber/slog-echo"
19
"github.com/urfave/cli/v2"
20
"gorm.io/driver/sqlite"
···
101
return nil, err
102
}
103
104
-
k, err := jwk.ParseKey(b)
105
if err != nil {
106
return nil, err
107
}
···
14
_ "github.com/joho/godotenv/autoload"
15
"github.com/labstack/echo-contrib/session"
16
"github.com/labstack/echo/v4"
17
slogecho "github.com/samber/slog-echo"
18
"github.com/urfave/cli/v2"
19
"gorm.io/driver/sqlite"
···
100
return nil, err
101
}
102
103
+
k, err := oauth.ParseJWKFromBytes(b)
104
if err != nil {
105
return nil, err
106
}
+2
-2
cmd/client_test/user.go
+2
-2
cmd/client_test/user.go
···
21
}
22
23
if oauthSession.Expiration.Sub(time.Now()) <= 5*time.Minute {
24
-
privateJwk, err := oauth.ParseKeyFromBytes([]byte(oauthSession.DpopPrivateJwk))
25
if err != nil {
26
return nil, err
27
}
···
59
60
oauthSession, err := s.getOauthSession(e.Request().Context(), did)
61
62
-
privateJwk, err := oauth.ParseKeyFromBytes([]byte(oauthSession.DpopPrivateJwk))
63
if err != nil {
64
return nil, false, err
65
}
···
21
}
22
23
if oauthSession.Expiration.Sub(time.Now()) <= 5*time.Minute {
24
+
privateJwk, err := oauth.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk))
25
if err != nil {
26
return nil, err
27
}
···
59
60
oauthSession, err := s.getOauthSession(e.Request().Context(), did)
61
62
+
privateJwk, err := oauth.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk))
63
if err != nil {
64
return nil, false, err
65
}
+1
-1
generic.go
+1
-1
generic.go
+37
-28
oauth.go
+37
-28
oauth.go
···
62
}, nil
63
}
64
65
-
func (c *Client) ResolvePDSAuthServer(ctx context.Context, ustr string) (string, error) {
66
u, err := isSafeAndParsed(ustr)
67
if err != nil {
68
return "", err
···
234
235
clientAssertion, err := c.ClientAssertionJwt(authServerUrl)
236
if err != nil {
237
-
return nil, err
238
}
239
240
dpopAuthserverNonce := ""
···
283
return nil, err
284
}
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
-
}
292
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
-
}
302
303
-
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
304
-
req2.Header.Set("DPoP", dpopProof)
305
306
-
resp2, err := c.h.Do(req2)
307
-
if err != nil {
308
-
return nil, err
309
-
}
310
-
defer resp2.Body.Close()
311
312
-
rmap = map[string]any{}
313
-
if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil {
314
-
return nil, err
315
}
316
}
317
···
319
PkceVerifier: pkceVerifier,
320
State: state,
321
DpopAuthserverNonce: dpopAuthserverNonce,
322
-
Resp: rmap,
323
}, nil
324
}
325
···
62
}, nil
63
}
64
65
+
func (c *Client) ResolvePdsAuthServer(ctx context.Context, ustr string) (string, error) {
66
u, err := isSafeAndParsed(ustr)
67
if err != nil {
68
return "", err
···
234
235
clientAssertion, err := c.ClientAssertionJwt(authServerUrl)
236
if err != nil {
237
+
return nil, fmt.Errorf("error getting client assertion: %w", err)
238
}
239
240
dpopAuthserverNonce := ""
···
283
return nil, err
284
}
285
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
+
}
303
304
+
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
305
+
req2.Header.Set("DPoP", dpopProof)
306
307
+
resp2, err := c.h.Do(req2)
308
+
if err != nil {
309
+
return nil, err
310
+
}
311
+
defer resp2.Body.Close()
312
313
+
rmap = map[string]any{}
314
+
if err := json.NewDecoder(resp2.Body).Decode(&rmap); err != nil {
315
+
return nil, err
316
+
}
317
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"])
323
}
324
}
325
···
327
PkceVerifier: pkceVerifier,
328
State: state,
329
DpopAuthserverNonce: dpopAuthserverNonce,
330
+
ExpiresIn: rmap["expires_in"].(float64),
331
+
RequestUri: rmap["request_uri"].(string),
332
}, nil
333
}
334
+5
-5
oauth_test.go
+5
-5
oauth_test.go
···
27
panic(err)
28
}
29
30
-
k, err := ParseKeyFromBytes(b)
31
if err != nil {
32
panic(err)
33
}
···
61
func TestResolvePDSAuthServer(t *testing.T) {
62
assert := assert.New(t)
63
64
-
authServer, err := oauthClient.ResolvePDSAuthServer(ctx, pdsUrl)
65
66
assert.NoError(err)
67
assert.NotEmpty(authServer)
···
88
func TestSendParAuthRequest(t *testing.T) {
89
assert := assert.New(t)
90
91
-
authserverUrl, err := oauthClient.ResolvePDSAuthServer(ctx, pdsUrl)
92
meta, err := oauthClient.FetchAuthServerMetadata(ctx, pdsUrl)
93
if err != nil {
94
panic(err)
···
106
}
107
108
assert.NoError(err)
109
-
assert.Equal(float64(299), parResp.Resp["expires_in"])
110
-
assert.NotEmpty(parResp.Resp["request_uri"])
111
}
···
27
panic(err)
28
}
29
30
+
k, err := ParseJWKFromBytes(b)
31
if err != nil {
32
panic(err)
33
}
···
61
func TestResolvePDSAuthServer(t *testing.T) {
62
assert := assert.New(t)
63
64
+
authServer, err := oauthClient.ResolvePdsAuthServer(ctx, pdsUrl)
65
66
assert.NoError(err)
67
assert.NotEmpty(authServer)
···
88
func TestSendParAuthRequest(t *testing.T) {
89
assert := assert.New(t)
90
91
+
authserverUrl, err := oauthClient.ResolvePdsAuthServer(ctx, pdsUrl)
92
meta, err := oauthClient.FetchAuthServerMetadata(ctx, pdsUrl)
93
if err != nil {
94
panic(err)
···
106
}
107
108
assert.NoError(err)
109
+
assert.Equal(float64(299), parResp.ExpiresIn)
110
+
assert.NotEmpty(parResp.RequestUri)
111
}
+2
-1
types.go
+2
-1
types.go