this repo has no description
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```bash
10go get github.com/haileyok/atproto-oauth-golang
11```
12
13## Prerequisites
14There are some prerequisites that you'll need to handle before implementing this OAuth client.
15
16### Private JWK
17If 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
18
19`make jwks`
20
21You will need to read the JWK from your application and parse it using `oauth.ParseJWKFromBytes`.
22
23### Serve `client-metadata.json` from your application
24
25The client metadata will need to be accessible from your domain. An example using `echo` is below.
26
27```go
28func (s *TestServer) handleClientMetadata(e echo.Context) error {
29 metadata := map[string]any{
30 "client_id": serverMetadataUrl,
31 "client_name": "Atproto Oauth Golang Tester",
32 "client_uri": serverUrlRoot,
33 "logo_uri": fmt.Sprintf("%s/logo.png", serverUrlRoot),
34 "tos_uri": fmt.Sprintf("%s/tos", serverUrlRoot),
35 "policy_url": fmt.Sprintf("%s/policy", serverUrlRoot),
36 "redirect_uris": []string{serverCallbackUrl},
37 "grant_types": []string{"authorization_code", "refresh_token"},
38 "response_types": []string{"code"},
39 "application_type": "web",
40 "dpop_bound_access_tokens": true,
41 "jwks_uri": fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot),
42 "scope": "atproto transition:generic",
43 "token_endpoint_auth_method": "private_key_jwt",
44 "token_endpoint_auth_signing_alg": "ES256",
45 }
46
47 return e.JSON(200, metadata)
48}
49```
50
51### Serve `jwks.json`
52
53You will also need to serve your private JWK's __public key__ from your domain. Again, an example is below.
54
55```go
56func (s *TestServer) handleJwks(e echo.Context) error {
57 b, err := os.ReadFile("./jwk.json")
58 if err != nil {
59 return err
60 }
61
62 k, err := helpers.ParseJWKFromBytes(b)
63 if err != nil {
64 return err
65 }
66
67 pubKey, err := k.PublicKey()
68 if err != nil {
69 return err
70 }
71
72 return e.JSON(200, helpers.CreateJwksResponseObject(pubKey))
73}
74```
75
76## Usage
77
78Once you have completed the prerequisites, you can implement and use the client.
79
80### Create a new OAuth Client
81
82Create an OAuth client by calling `oauth.NewClient`
83
84```go
85clientId := "https://yourdomain.com/path/to/client-metadata.json"
86callbackUrl := "https://yourdomain.com/oauth-callback"
87
88b, err := os.ReadFile("./jwks.json")
89if err != nil {
90 return err
91}
92
93k, err := helpers.ParseJWKFromBytes(b)
94if err != nil {
95 return err
96}
97
98cli, err := helpers.NewClient(oauth.ClientArgs{
99 ClientJwk: k,
100 ClientId: clientId,
101 RedirectUri: callbackUrl,
102})
103if err != nil {
104 return err
105}
106```
107
108### Starting Authenticating
109
110There are examples of the authentication flow inside of `cmd/client_tester/handle_auth.go`, however we'll talk about some general points here.
111
112#### Determining the user's PDS
113
114You 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.
115
116```go
117cli := oauth.NewClient()
118userInput := "hailey.at"
119
120// If you already have a did or a URL, you can skip this step
121did, err := resolveHandle(ctx, userInput) // returns did:plc:abc123 or did:web:test.com
122if err != nil {
123 return err
124}
125
126// If you already have a URL, you can skip this step
127service, err := resolveService(ctx, did) // returns https://pds.haileyok.com
128if err != nil {
129 return err
130}
131
132authserver, err := cli.ResolvePdsAuthServer(ctx, service)
133if err != nil {
134 return err
135}
136
137authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver)
138if err != nil {
139 return err
140}
141```
142
143By this point, you will have the necessary information to direct the user where they need to go.
144
145#### Create a private DPoP JWK for the user
146
147You'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.
148
149```go
150k, err := helpers.GenerateKey(nil)
151if err != nil {
152 return err
153}
154
155b, err := json.Marshal(k)
156if err != nil {
157 return err
158}
159```
160
161#### Make the PAR request
162
163```go
164// Note: the login hint - here `handle` - should only be set if you have a DID or handle. Leave it empty if all you
165// have is the PDS url.
166parResp, err := cli.SendParAuthRequest(ctx, authserver, authmeta, handle, scope, dpopPrivateKey)
167if err != nil {
168 return err
169}
170```
171
172#### Store the needed information before redirecting
173
174Some items will need to be stored for later when the PDS redirects to your application.
175
176- The user's DID, if you have it
177- The user's PDS url
178- The authserver issuer
179- The `state` value from the PAR request
180- The PKCE verifier from the PAR rquest
181- The DPoP autherserver nonce from the PAR request
182- The DPoP private JWK thhat you generated
183
184It 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`.
185
186#### Redirect
187
188Once 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.
189
190```go
191u, _ := url.Parse(meta.AuthorizationEndpoint)
192u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(yourClientId), parResp.RequestUri)
193
194// Redirect the user to created url
195```
196
197### Callback handling
198
199Handling the response is pretty easy, though you'll want to check a few things once you receive the response.
200
201- Ensure that `state`, `iss`, and `code` are present in the `GET` parameters
202- Ensure that the `state` value matches the `state` value you stored before redirection
203
204You'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.
205
206```go
207resCode := e.QueryParam("code")
208resIss := e.QueryParam("iss")
209
210itResp, err := cli.InitialTokenRequest(ctx, resCode, resIss, requestInfo.PkceVerifier, requestInfo.DpopAuthserverNonce, requestInfo.privateJwk)
211if err != nil {
212 return err
213}
214```
215
216#### Final checks
217
218Finally, 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`.
219
220```go
221if itResp.Scope != requestedScope {
222 return fmt.Errorf("bad scope")
223}
224
225if requestInfo.Did == "" {
226 // Do something...
227}
228```
229
230#### Store the response
231
232Now, 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.
233
234### Refreshing the token
235
236The acess token you receive will expire after one hour and you will need to refresh it. You may choose to create a helper method that will refresh the token as necessary whenever you fetch the authentication information from your store. For an example, see `cmd/client_test/user.go`.
237
238## Making requests
239
240You may have some experience using the atproto SDK's helper methods from `indigo`. For example, you may be able to call `ActorGetProfile()` to fetch a user's profile. Currently, the atproto SDK does not support OAuth however, and will need some
241changes. In the meantime, I have added a custom XRPC client to this repo that can be used with OAuth sessions created in this library.
242
243### Creating an XRPC client
244
245Similar to the `indigo/xrpc` package, you can create an XRPC client like so
246
247```go
248client := &oauth.XrpcClient{
249 OnDpopPdsNonceChanged: func(did, newNonce string) {
250 // Handle updating your store with the new nonce
251 },
252}
253```
254
255The `OnDpopPdsNonceChanged` callback will fire whenever an authenticated request results in an updated DPoP PDS nonce. You should update your store with this nonce for future requests.
256
257### Making requests
258
259Instead of using "helpers", for now you should make requests by simply calling `Do()` on the XRPC client. You will need to pass `XrpcAuthedRequestArgs` to the function to perform authenticated requests.
260If the parameter is `nil`, the request will be made unauthenticated. A few examples are below.
261
262#### Creating authentication arguments
263
264```go
265// Get your user's session - however you are doing that - and retrieve their did
266
267// Grab the oauth session from your database
268oauthSession, err := s.getOauthSession(e.Request().Context(), did)
269
270// Parse the user's JWK to pass into arguments
271privateJwk, err := helpers.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk))
272if err != nil {
273 return nil, false, err
274}
275
276return &oauth.XrpcAuthedRequestArgs{
277 Did: oauthSession.Did,
278 AccessToken: oauthSession.AccessToken,
279 PdsUrl: oauthSession.PdsUrl,
280 Issuer: oauthSession.AuthserverIss,
281 DpopPdsNonce: oauthSession.DpopPdsNonce,
282 DpopPrivateJwk: privateJwk,
283}, nil
284```
285
286#### Making a post
287
288```go
289authArgs, err := s.getOauthSessionAuthArgs(e)
290if err != nil {
291 return err
292}
293
294post := bsky.FeedPost{
295 Text: "hello from atproto golang oauth client",
296 CreatedAt: syntax.DatetimeNow().String(),
297}
298
299input := atproto.RepoCreateRecord_Input{
300 Collection: "app.bsky.feed.post",
301 Repo: authArgs.Did,
302 Record: &util.LexiconTypeDecoder{Val: &post},
303}
304
305var out atproto.RepoCreateRecord_Output
306if err := s.xrpcCli.Do(e.Request().Context(), authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil {
307 return err
308}
309```
310
311#### Getting a profile
312
313```go
314authArgs, err := s.getOauthSessionAuthArgs(e)
315if err != nil {
316 return err
317}
318
319var out bsky.ActorDefs_ProfileViewDetailed
320if err := s.xrpcCli.Do(e.Request().Context(), authArgs, xrpc.Query, "", "app.bsky.actor.getProfile", map[string]any{"actor": authArgs.Did}, nil, &out); err != nil {
321 return err
322}
323```