Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 373 lines 13 kB view raw
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}