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
10## Prerequisites
11There are some prerequisites that you'll need to handle before implementing this OAuth client.
12
13### Private JWK
14If 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
18You 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
22The client metadata will need to be accessible from your domain. An example using `echo` is below.
23
24```go
25func (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
50You will also need to serve your private JWK's __public key__ from your domain. Again, an example is below.
51
52```go
53func (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
75Once you have completed the prerequisites, you can implement and use the client.
76
77### Create a new OAuth Client
78
79Create an OAuth client by calling `oauth.NewClient`
80
81```go
82clientId := "https://yourdomain.com/path/to/client-metadata.json"
83callbackUrl := "https://yourdomain.com/oauth-callback"
84
85b, err := os.ReadFile("./jwks.json")
86if err != nil {
87 return err
88}
89
90k, err := oauth.ParseJWKFromBytes(b)
91if err != nil {
92 return err
93}
94
95cli, err := oauth.NewClient(oauth.ClientArgs{
96 ClientJwk: k,
97 ClientId: clientId,
98 RedirectUri: callbackUrl,
99})
100if err != nil {
101 return err
102}
103```
104
105### Starting Authenticating
106
107There 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
111You 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
114cli := oauth.NewClient()
115userInput := "hailey.at"
116
117// If you already have a did or a URL, you can skip this step
118did, err := resolveHandle(ctx, userInput) // returns did:plc:abc123 or did:web:test.com
119if err != nil {
120 return err
121}
122
123// If you already have a URL, you can skip this step
124service, err := resolveService(ctx, did) // returns https://pds.haileyok.com
125if err != nil {
126 return err
127}
128
129authserver, err := cli.ResolvePdsAuthServer(ctx, service)
130if err != nil {
131 return err
132}
133
134authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver)
135if err != nil {
136 return err
137}
138```
139
140By 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
144You'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
147k, err := oauth.GenerateKey(nil)
148if err != nil {
149 return err
150}
151
152b, err := json.Marshal(k)
153if 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.
163parResp, err := cli.SendParAuthRequest(ctx, authserver, authmeta, handle, scope, dpopPrivateKey)
164if err != nil {
165 return err
166}
167```
168
169#### Store the needed information before redirecting
170
171Some 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
181It 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
185Once 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
188u, _ := url.Parse(meta.AuthorizationEndpoint)
189u.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
196Handling 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
201You'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
204resCode := e.QueryParam("code")
205resIss := e.QueryParam("iss")
206
207itResp, err := cli.InitialTokenRequest(ctx, resCode, resIss, requestInfo.PkceVerifier, requestInfo.DpopAuthserverNonce, requestInfo.privateJwk)
208if err != nil {
209 return err
210}
211```
212
213#### Final checks
214
215Finally, 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
218if itResp.Scope != requestedScope {
219 return fmt.Errorf("bad scope")
220}
221
222if requestInfo.Did == "" {
223 // Do something...
224}
225```
226
227#### Store the response
228
229Now, 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