+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
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
+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
+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
+1
-1
generic.go
+37
-28
oauth.go
+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
+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
}