Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat: implement ATProto OAuth flow (PAR + PKCE + DPoP)

Add ATProto OAuth handlers (ClientMetadata, ATProtoLoginPage,
ATProtoLoginSubmit, ATProtoCallback) with full PAR + PKCE + DPoP
support; remove defunct OAuthGitHub/OAuthGoogle handlers and update
server routes accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+345 -30
+4 -2
cmd/server/main.go
··· 68 68 mux.HandleFunc("GET /auth/register", h.RegisterPage) 69 69 mux.HandleFunc("POST /auth/register", h.RegisterSubmit) 70 70 mux.HandleFunc("POST /auth/logout", h.Logout) 71 - mux.HandleFunc("GET /auth/github", h.OAuthGitHub) 72 - mux.HandleFunc("GET /auth/google", h.OAuthGoogle) 71 + mux.HandleFunc("GET /client-metadata.json", h.ClientMetadata) 72 + mux.HandleFunc("GET /auth/atproto", h.ATProtoLoginPage) 73 + mux.HandleFunc("POST /auth/atproto", h.ATProtoLoginSubmit) 74 + mux.HandleFunc("GET /auth/atproto/callback", h.ATProtoCallback) 73 75 74 76 // Dashboard 75 77 mux.HandleFunc("GET /", h.Dashboard)
+1 -6
go.mod
··· 10 10 github.com/sergi/go-diff v1.3.1 11 11 github.com/yuin/goldmark v1.7.1 12 12 golang.org/x/crypto v0.22.0 13 - golang.org/x/oauth2 v0.19.0 14 13 ) 15 14 16 - require ( 17 - cloud.google.com/go/compute v1.20.1 // indirect 18 - cloud.google.com/go/compute/metadata v0.2.3 // indirect 19 - github.com/gorilla/securecookie v1.1.2 // indirect 20 - ) 15 + require github.com/gorilla/securecookie v1.1.2 // indirect
-8
go.sum
··· 1 - cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= 2 - cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= 3 - cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 4 - cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 5 1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 2 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 3 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 4 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 9 5 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 10 - github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 11 - github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 6 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 13 7 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 14 8 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= ··· 34 28 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 35 29 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 36 30 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 37 - golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= 38 - golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= 39 31 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 40 32 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 33 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+340
internal/handler/atproto.go
··· 1 + package handler 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "os" 11 + "strings" 12 + 13 + "github.com/limeleaf/markdownhub/internal/atproto" 14 + "github.com/limeleaf/markdownhub/internal/atproto/dpop" 15 + "github.com/limeleaf/markdownhub/internal/auth" 16 + "github.com/limeleaf/markdownhub/internal/model" 17 + ) 18 + 19 + func baseURL() string { 20 + if u := os.Getenv("BASE_URL"); u != "" { 21 + return strings.TrimRight(u, "/") 22 + } 23 + return "http://localhost:8080" 24 + } 25 + 26 + // ClientMetadata serves the OAuth client metadata document. 27 + // The client_id in ATProto OAuth must be a URL pointing to this document. 28 + func (h *Handler) ClientMetadata(w http.ResponseWriter, r *http.Request) { 29 + base := baseURL() 30 + meta := map[string]interface{}{ 31 + "client_id": base + "/client-metadata.json", 32 + "client_name": "MarkdownHub", 33 + "client_uri": base, 34 + "redirect_uris": []string{base + "/auth/atproto/callback"}, 35 + "grant_types": []string{"authorization_code", "refresh_token"}, 36 + "response_types": []string{"code"}, 37 + "scope": "atproto", 38 + "token_endpoint_auth_method": "none", 39 + "dpop_bound_access_tokens": true, 40 + "application_type": "web", 41 + } 42 + w.Header().Set("Content-Type", "application/json") 43 + json.NewEncoder(w).Encode(meta) 44 + } 45 + 46 + // ATProtoLoginPage renders the handle-entry form. 47 + func (h *Handler) ATProtoLoginPage(w http.ResponseWriter, r *http.Request) { 48 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky"}) 49 + } 50 + 51 + // ATProtoLoginSubmit starts the ATProto OAuth flow: 52 + // 1. Resolve handle → DID → PDS → auth server metadata 53 + // 2. Generate DPoP key + PKCE 54 + // 3. POST PAR request 55 + // 4. Store state in session, redirect user to authorization endpoint 56 + func (h *Handler) ATProtoLoginSubmit(w http.ResponseWriter, r *http.Request) { 57 + handle := strings.TrimSpace(r.FormValue("handle")) 58 + if handle == "" { 59 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Handle is required"}) 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"}) 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"}) 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"}) 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"}, 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 (server may return 400 with nonce on first try) 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 + log.Printf("ATProto: PAR response %d", resp.StatusCode) 160 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"}) 161 + return 162 + } 163 + 164 + var parResp struct { 165 + RequestURI string `json:"request_uri"` 166 + } 167 + if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil || parResp.RequestURI == "" { 168 + log.Printf("ATProto: decode PAR response: %v", err) 169 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Unexpected response from PDS"}) 170 + return 171 + } 172 + 173 + // 4. Store state in session 174 + sess := auth.GetSession(r) 175 + sess.Values["atproto_state"] = state 176 + sess.Values["atproto_pkce_verifier"] = verifier 177 + sess.Values["atproto_dpop_key"] = string(keyJSON) 178 + sess.Values["atproto_dpop_nonce"] = nonce 179 + sess.Values["atproto_token_endpoint"] = meta.TokenEndpoint 180 + sess.Values["atproto_did"] = did 181 + sess.Values["atproto_handle"] = handle 182 + sess.Save(r, w) 183 + 184 + // Redirect user to authorization endpoint 185 + authURL := meta.AuthorizationEndpoint + "?client_id=" + url.QueryEscape(clientID) + "&request_uri=" + url.QueryEscape(parResp.RequestURI) 186 + http.Redirect(w, r, authURL, http.StatusFound) 187 + } 188 + 189 + // ATProtoCallback handles the redirect back from the PDS after user approval: 190 + // 1. Validate state 191 + // 2. Exchange code for tokens (with DPoP) 192 + // 3. Verify sub (DID) 193 + // 4. Find or create user, set session 194 + func (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 + handle, _ := sess.Values["atproto_handle"].(string) 221 + 222 + // Clean up session state 223 + delete(sess.Values, "atproto_state") 224 + delete(sess.Values, "atproto_pkce_verifier") 225 + delete(sess.Values, "atproto_dpop_key") 226 + delete(sess.Values, "atproto_dpop_nonce") 227 + delete(sess.Values, "atproto_token_endpoint") 228 + delete(sess.Values, "atproto_did") 229 + delete(sess.Values, "atproto_handle") 230 + 231 + // 2. Exchange code for tokens 232 + kp, err := dpop.UnmarshalPrivate([]byte(keyJSON)) 233 + if err != nil { 234 + log.Printf("ATProto callback: unmarshal DPoP key: %v", err) 235 + http.Error(w, "Internal error", 500) 236 + return 237 + } 238 + 239 + redirectURI := baseURL() + "/auth/atproto/callback" 240 + clientID := baseURL() + "/client-metadata.json" 241 + 242 + tokenParams := url.Values{ 243 + "grant_type": {"authorization_code"}, 244 + "code": {code}, 245 + "redirect_uri": {redirectURI}, 246 + "client_id": {clientID}, 247 + "code_verifier": {verifier}, 248 + } 249 + 250 + tokenProof, err := kp.Proof("POST", tokenEndpoint, nonce) 251 + if err != nil { 252 + log.Printf("ATProto callback: build token DPoP proof: %v", err) 253 + http.Error(w, "Internal error", 500) 254 + return 255 + } 256 + 257 + tokenReq, _ := http.NewRequest("POST", tokenEndpoint, strings.NewReader(tokenParams.Encode())) 258 + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") 259 + tokenReq.Header.Set("DPoP", tokenProof) 260 + 261 + tokenResp, err := http.DefaultClient.Do(tokenReq) 262 + if err != nil { 263 + log.Printf("ATProto callback: token request: %v", err) 264 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) 265 + return 266 + } 267 + defer tokenResp.Body.Close() 268 + 269 + // Handle nonce refresh on token endpoint 270 + newNonce := tokenResp.Header.Get("DPoP-Nonce") 271 + if tokenResp.StatusCode == http.StatusBadRequest && newNonce != "" { 272 + tokenProof, err = kp.Proof("POST", tokenEndpoint, newNonce) 273 + if err != nil { 274 + http.Error(w, "Internal error", 500) 275 + return 276 + } 277 + tokenReq2, _ := http.NewRequest("POST", tokenEndpoint, strings.NewReader(tokenParams.Encode())) 278 + tokenReq2.Header.Set("Content-Type", "application/x-www-form-urlencoded") 279 + tokenReq2.Header.Set("DPoP", tokenProof) 280 + tokenResp.Body.Close() 281 + tokenResp, err = http.DefaultClient.Do(tokenReq2) 282 + if err != nil { 283 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) 284 + return 285 + } 286 + defer tokenResp.Body.Close() 287 + } 288 + 289 + if tokenResp.StatusCode != http.StatusOK { 290 + log.Printf("ATProto callback: token response %d", tokenResp.StatusCode) 291 + h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) 292 + return 293 + } 294 + 295 + var tokenBody struct { 296 + Sub string `json:"sub"` 297 + } 298 + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil { 299 + log.Printf("ATProto callback: decode token response: %v", err) 300 + http.Error(w, "Internal error", 500) 301 + return 302 + } 303 + 304 + // 3. Verify sub matches the DID we resolved at the start 305 + if tokenBody.Sub == "" { 306 + http.Error(w, "Token missing sub claim", http.StatusBadRequest) 307 + return 308 + } 309 + if tokenBody.Sub != expectedDID { 310 + log.Printf("ATProto callback: sub mismatch: got %s, expected %s", tokenBody.Sub, expectedDID) 311 + http.Error(w, "DID mismatch", http.StatusBadRequest) 312 + return 313 + } 314 + 315 + // 4. Find or create user 316 + user, err := h.DB.GetUserByDID(tokenBody.Sub) 317 + if err == sql.ErrNoRows { 318 + if handle == "" { 319 + handle = tokenBody.Sub 320 + } 321 + did := tokenBody.Sub 322 + user = &model.User{ 323 + Name: handle, 324 + DID: &did, 325 + } 326 + if err := h.DB.CreateUser(user); err != nil { 327 + log.Printf("ATProto callback: create user: %v", err) 328 + http.Error(w, "Internal error", 500) 329 + return 330 + } 331 + } else if err != nil { 332 + log.Printf("ATProto callback: get user by DID: %v", err) 333 + http.Error(w, "Internal error", 500) 334 + return 335 + } 336 + 337 + auth.SetUserID(w, r, user.ID) 338 + sess.Save(r, w) 339 + http.Redirect(w, r, "/", http.StatusSeeOther) 340 + }
-14
internal/handler/handler.go
··· 125 125 http.Redirect(w, r, "/", http.StatusSeeOther) 126 126 } 127 127 128 - // --- OAuth handlers --- 129 - 130 - func (h *Handler) OAuthGitHub(w http.ResponseWriter, r *http.Request) { 131 - state := auth.SetOAuthState(w, r) 132 - url := auth.GitHubConfig().AuthCodeURL(state) 133 - http.Redirect(w, r, url, http.StatusTemporaryRedirect) 134 - } 135 - 136 - func (h *Handler) OAuthGoogle(w http.ResponseWriter, r *http.Request) { 137 - state := auth.SetOAuthState(w, r) 138 - url := auth.GoogleConfig().AuthCodeURL(state) 139 - http.Redirect(w, r, url, http.StatusTemporaryRedirect) 140 - } 141 - 142 128 // --- Dashboard --- 143 129 144 130 func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {