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 := oauth.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, oauth.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 := oauth.ParseJWKFromBytes(b)
94if err != nil {
95 return err
96}
97
98cli, err := oauth.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 := oauth.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&requires_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