Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
1package handler
2
3import (
4 "database/sql"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "net/http"
10 "net/url"
11 "os"
12 "strings"
13 "time"
14
15 "github.com/limeleaf/diffdown/internal/atproto"
16 "github.com/limeleaf/diffdown/internal/atproto/dpop"
17 "github.com/limeleaf/diffdown/internal/auth"
18 "github.com/limeleaf/diffdown/internal/model"
19)
20
21func baseURL() string {
22 if u := os.Getenv("DIFFDOWN_BASE_URL"); u != "" {
23 return strings.TrimRight(u, "/")
24 }
25 return "http://127.0.0.1:8080"
26}
27
28// ClientMetadata serves the OAuth client metadata document.
29func (h *Handler) ClientMetadata(w http.ResponseWriter, r *http.Request) {
30 base := baseURL()
31 meta := map[string]interface{}{
32 "client_id": base + "/client-metadata.json",
33 "client_name": "Diffdown",
34 "client_uri": base,
35 "redirect_uris": []string{base + "/auth/atproto/callback"},
36 "grant_types": []string{"authorization_code", "refresh_token"},
37 "response_types": []string{"code"},
38 "scope": "atproto transition:generic",
39 "token_endpoint_auth_method": "none",
40 "dpop_bound_access_tokens": true,
41 "application_type": "web",
42 }
43 w.Header().Set("Content-Type", "application/json")
44 w.Header().Set("Cache-Control", "no-store")
45 json.NewEncoder(w).Encode(meta)
46}
47
48// ATProtoLoginPage renders the handle-entry form.
49func (h *Handler) ATProtoLoginPage(w http.ResponseWriter, r *http.Request) {
50 next := r.URL.Query().Get("next")
51 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Next: next})
52}
53
54// ATProtoLoginSubmit starts the ATProto OAuth flow.
55func (h *Handler) ATProtoLoginSubmit(w http.ResponseWriter, r *http.Request) {
56 handle := strings.TrimSpace(r.FormValue("handle"))
57 next := r.FormValue("next")
58 if handle == "" {
59 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Handle is required", Next: next})
60 return
61 }
62
63 // 1. Resolve identity
64 did, err := atproto.ResolveHandle(handle)
65 if err != nil {
66 log.Printf("ATProto: resolve handle %s: %v", handle, err)
67 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Could not resolve handle — check it and try again", Next: next})
68 return
69 }
70
71 pds, err := atproto.ResolvePDS(did)
72 if err != nil {
73 log.Printf("ATProto: resolve PDS for %s: %v", did, err)
74 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Could not reach your PDS", Next: next})
75 return
76 }
77
78 meta, err := atproto.FetchAuthServerMeta(pds)
79 if err != nil {
80 log.Printf("ATProto: fetch auth meta from %s: %v", pds, err)
81 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "PDS does not support ATProto OAuth", Next: next})
82 return
83 }
84
85 // 2. Generate DPoP key pair and PKCE
86 kp, err := dpop.Generate()
87 if err != nil {
88 log.Printf("ATProto: generate DPoP key: %v", err)
89 http.Error(w, "Internal error", 500)
90 return
91 }
92 keyJSON, err := kp.MarshalPrivate()
93 if err != nil {
94 log.Printf("ATProto: marshal DPoP key: %v", err)
95 http.Error(w, "Internal error", 500)
96 return
97 }
98
99 verifier := auth.PKCEVerifier()
100 challenge := auth.PKCEChallenge(verifier)
101 state := auth.GenerateToken()
102 redirectURI := baseURL() + "/auth/atproto/callback"
103 clientID := baseURL() + "/client-metadata.json"
104
105 // 3. POST PAR request
106 parProof, err := kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, "", "")
107 if err != nil {
108 log.Printf("ATProto: build PAR DPoP proof: %v", err)
109 http.Error(w, "Internal error", 500)
110 return
111 }
112
113 parParams := url.Values{
114 "response_type": {"code"},
115 "client_id": {clientID},
116 "redirect_uri": {redirectURI},
117 "scope": {"atproto transition:generic"},
118 "state": {state},
119 "code_challenge": {challenge},
120 "code_challenge_method": {"S256"},
121 "login_hint": {handle},
122 }
123
124 req, _ := http.NewRequest("POST", meta.PushedAuthorizationRequestEndpoint, strings.NewReader(parParams.Encode()))
125 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
126 req.Header.Set("DPoP", parProof)
127
128 resp, err := http.DefaultClient.Do(req)
129 if err != nil {
130 log.Printf("ATProto: PAR request failed: %v", err)
131 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"})
132 return
133 }
134 defer resp.Body.Close()
135
136 // Handle DPoP nonce requirement
137 nonce := resp.Header.Get("DPoP-Nonce")
138 if resp.StatusCode == http.StatusBadRequest && nonce != "" {
139 parProof, err = kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, nonce, "")
140 if err != nil {
141 http.Error(w, "Internal error", 500)
142 return
143 }
144 req2, _ := http.NewRequest("POST", meta.PushedAuthorizationRequestEndpoint, strings.NewReader(parParams.Encode()))
145 req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
146 req2.Header.Set("DPoP", parProof)
147 resp.Body.Close()
148 resp, err = http.DefaultClient.Do(req2)
149 if err != nil {
150 log.Printf("ATProto: PAR retry failed: %v", err)
151 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"})
152 return
153 }
154 defer resp.Body.Close()
155 nonce = resp.Header.Get("DPoP-Nonce")
156 }
157
158 if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
159 body, _ := io.ReadAll(resp.Body)
160 log.Printf("ATProto: PAR response %d: %s", resp.StatusCode, body)
161 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"})
162 return
163 }
164
165 var parResp struct {
166 RequestURI string `json:"request_uri"`
167 }
168 if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil || parResp.RequestURI == "" {
169 log.Printf("ATProto: decode PAR response: %v", err)
170 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Unexpected response from PDS"})
171 return
172 }
173
174 // 4. Store state in session
175 sess := auth.GetSession(r)
176 sess.Values["atproto_state"] = state
177 sess.Values["atproto_pkce_verifier"] = verifier
178 sess.Values["atproto_dpop_key"] = string(keyJSON)
179 sess.Values["atproto_dpop_nonce"] = nonce
180 sess.Values["atproto_token_endpoint"] = meta.TokenEndpoint
181 sess.Values["atproto_did"] = did
182 sess.Values["atproto_handle"] = handle
183 sess.Values["atproto_pds_url"] = pds
184 if next != "" {
185 sess.Values["atproto_next"] = next
186 }
187 sess.Save(r, w)
188
189 authURL := meta.AuthorizationEndpoint + "?client_id=" + url.QueryEscape(clientID) + "&request_uri=" + url.QueryEscape(parResp.RequestURI)
190 http.Redirect(w, r, authURL, http.StatusFound)
191}
192
193// ATProtoCallback handles the redirect back from the PDS after user approval.
194func (h *Handler) ATProtoCallback(w http.ResponseWriter, r *http.Request) {
195 sess := auth.GetSession(r)
196
197 // 1. Validate state
198 state := r.URL.Query().Get("state")
199 expectedState, _ := sess.Values["atproto_state"].(string)
200 if state == "" || state != expectedState {
201 http.Error(w, "Invalid state", http.StatusBadRequest)
202 return
203 }
204
205 code := r.URL.Query().Get("code")
206 if code == "" {
207 errMsg := r.URL.Query().Get("error_description")
208 if errMsg == "" {
209 errMsg = r.URL.Query().Get("error")
210 }
211 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: fmt.Sprintf("Authorization denied: %s", errMsg)})
212 return
213 }
214
215 verifier, _ := sess.Values["atproto_pkce_verifier"].(string)
216 keyJSON, _ := sess.Values["atproto_dpop_key"].(string)
217 nonce, _ := sess.Values["atproto_dpop_nonce"].(string)
218 tokenEndpoint, _ := sess.Values["atproto_token_endpoint"].(string)
219 expectedDID, _ := sess.Values["atproto_did"].(string)
220 pdsURL, _ := sess.Values["atproto_pds_url"].(string)
221 next, _ := sess.Values["atproto_next"].(string)
222
223 // Clean up session state
224 delete(sess.Values, "atproto_state")
225 delete(sess.Values, "atproto_pkce_verifier")
226 delete(sess.Values, "atproto_dpop_key")
227 delete(sess.Values, "atproto_dpop_nonce")
228 delete(sess.Values, "atproto_token_endpoint")
229 delete(sess.Values, "atproto_did")
230 delete(sess.Values, "atproto_pds_url")
231 delete(sess.Values, "atproto_next")
232
233 // 2. Exchange code for tokens
234 kp, err := dpop.UnmarshalPrivate([]byte(keyJSON))
235 if err != nil {
236 log.Printf("ATProto callback: unmarshal DPoP key: %v", err)
237 http.Error(w, "Internal error", 500)
238 return
239 }
240
241 redirectURI := baseURL() + "/auth/atproto/callback"
242 clientID := baseURL() + "/client-metadata.json"
243
244 tokenParams := url.Values{
245 "grant_type": {"authorization_code"},
246 "code": {code},
247 "redirect_uri": {redirectURI},
248 "client_id": {clientID},
249 "code_verifier": {verifier},
250 }
251
252 tokenProof, err := kp.Proof("POST", tokenEndpoint, nonce, "")
253 if err != nil {
254 log.Printf("ATProto callback: build token DPoP proof: %v", err)
255 http.Error(w, "Internal error", 500)
256 return
257 }
258
259 tokenReq, _ := http.NewRequest("POST", tokenEndpoint, strings.NewReader(tokenParams.Encode()))
260 tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
261 tokenReq.Header.Set("DPoP", tokenProof)
262
263 tokenResp, err := http.DefaultClient.Do(tokenReq)
264 if err != nil {
265 log.Printf("ATProto callback: token request: %v", err)
266 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"})
267 return
268 }
269 defer tokenResp.Body.Close()
270
271 // Handle nonce refresh on token endpoint
272 newNonce := tokenResp.Header.Get("DPoP-Nonce")
273 if tokenResp.StatusCode == http.StatusBadRequest && newNonce != "" {
274 tokenProof, err = kp.Proof("POST", tokenEndpoint, newNonce, "")
275 if err != nil {
276 http.Error(w, "Internal error", 500)
277 return
278 }
279 tokenReq2, _ := http.NewRequest("POST", tokenEndpoint, strings.NewReader(tokenParams.Encode()))
280 tokenReq2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
281 tokenReq2.Header.Set("DPoP", tokenProof)
282 tokenResp.Body.Close()
283 tokenResp, err = http.DefaultClient.Do(tokenReq2)
284 if err != nil {
285 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"})
286 return
287 }
288 defer tokenResp.Body.Close()
289 newNonce = tokenResp.Header.Get("DPoP-Nonce")
290 }
291
292 if tokenResp.StatusCode != http.StatusOK {
293 body, _ := io.ReadAll(tokenResp.Body)
294 log.Printf("ATProto callback: token response %d: %s", tokenResp.StatusCode, body)
295 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"})
296 return
297 }
298
299 var tokenBody struct {
300 Sub string `json:"sub"`
301 AccessToken string `json:"access_token"`
302 RefreshToken string `json:"refresh_token"`
303 ExpiresIn int `json:"expires_in"`
304 }
305 if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil {
306 log.Printf("ATProto callback: decode token response: %v", err)
307 http.Error(w, "Internal error", 500)
308 return
309 }
310
311 // Capture final nonce from response
312 if newNonce == "" {
313 newNonce = nonce
314 }
315
316 // 3. Verify sub matches the DID we resolved at the start
317 if tokenBody.Sub == "" {
318 http.Error(w, "Token missing sub claim", http.StatusBadRequest)
319 return
320 }
321 if tokenBody.Sub != expectedDID {
322 log.Printf("ATProto callback: sub mismatch: got %s, expected %s", tokenBody.Sub, expectedDID)
323 http.Error(w, "DID mismatch", http.StatusBadRequest)
324 return
325 }
326
327 // 4. Find or create user
328 user, err := h.DB.GetUserByDID(tokenBody.Sub)
329 if err == sql.ErrNoRows {
330 user = &model.User{
331 DID: tokenBody.Sub,
332 }
333 if err := h.DB.CreateUser(user); err != nil {
334 log.Printf("ATProto callback: create user: %v", err)
335 http.Error(w, "Internal error", 500)
336 return
337 }
338 } else if err != nil {
339 log.Printf("ATProto callback: get user by DID: %v", err)
340 http.Error(w, "Internal error", 500)
341 return
342 }
343
344 // 5. Store ATProto session with tokens
345 expiresAt := time.Now().Add(time.Duration(tokenBody.ExpiresIn) * time.Second)
346 atSession := &model.ATProtoSession{
347 UserID: user.ID,
348 DID: tokenBody.Sub,
349 PDSURL: pdsURL,
350 AccessToken: tokenBody.AccessToken,
351 RefreshToken: tokenBody.RefreshToken,
352 DPoPKeyJWK: keyJSON,
353 DPoPNonce: newNonce,
354 TokenEndpoint: tokenEndpoint,
355 ExpiresAt: expiresAt,
356 }
357 if err := h.DB.UpsertATProtoSession(atSession); err != nil {
358 log.Printf("ATProto callback: upsert session: %v", err)
359 http.Error(w, "Internal error", 500)
360 return
361 }
362
363 auth.SetUserID(w, r, user.ID)
364 sess.Save(r, w)
365
366 // Redirect to the originally requested URL, or home.
367 // Only allow local paths to prevent open redirect.
368 if next != "" && strings.HasPrefix(next, "/") {
369 http.Redirect(w, r, next, http.StatusSeeOther)
370 return
371 }
372 http.Redirect(w, r, "/", http.StatusSeeOther)
373}