package handler import ( "database/sql" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "strings" "time" "github.com/limeleaf/diffdown/internal/atproto" "github.com/limeleaf/diffdown/internal/atproto/dpop" "github.com/limeleaf/diffdown/internal/auth" "github.com/limeleaf/diffdown/internal/model" ) func baseURL() string { if u := os.Getenv("DIFFDOWN_BASE_URL"); u != "" { return strings.TrimRight(u, "/") } return "http://127.0.0.1:8080" } // ClientMetadata serves the OAuth client metadata document. func (h *Handler) ClientMetadata(w http.ResponseWriter, r *http.Request) { base := baseURL() meta := map[string]interface{}{ "client_id": base + "/client-metadata.json", "client_name": "Diffdown", "client_uri": base, "redirect_uris": []string{base + "/auth/atproto/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "atproto transition:generic", "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": true, "application_type": "web", } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(meta) } // ATProtoLoginPage renders the handle-entry form. func (h *Handler) ATProtoLoginPage(w http.ResponseWriter, r *http.Request) { next := r.URL.Query().Get("next") h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Next: next}) } // ATProtoLoginSubmit starts the ATProto OAuth flow. func (h *Handler) ATProtoLoginSubmit(w http.ResponseWriter, r *http.Request) { handle := strings.TrimSpace(r.FormValue("handle")) next := r.FormValue("next") if handle == "" { h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Handle is required", Next: next}) return } // 1. Resolve identity did, err := atproto.ResolveHandle(handle) if err != nil { log.Printf("ATProto: resolve handle %s: %v", handle, err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Could not resolve handle — check it and try again", Next: next}) return } pds, err := atproto.ResolvePDS(did) if err != nil { log.Printf("ATProto: resolve PDS for %s: %v", did, err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Could not reach your PDS", Next: next}) return } meta, err := atproto.FetchAuthServerMeta(pds) if err != nil { log.Printf("ATProto: fetch auth meta from %s: %v", pds, err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "PDS does not support ATProto OAuth", Next: next}) return } // 2. Generate DPoP key pair and PKCE kp, err := dpop.Generate() if err != nil { log.Printf("ATProto: generate DPoP key: %v", err) http.Error(w, "Internal error", 500) return } keyJSON, err := kp.MarshalPrivate() if err != nil { log.Printf("ATProto: marshal DPoP key: %v", err) http.Error(w, "Internal error", 500) return } verifier := auth.PKCEVerifier() challenge := auth.PKCEChallenge(verifier) state := auth.GenerateToken() redirectURI := baseURL() + "/auth/atproto/callback" clientID := baseURL() + "/client-metadata.json" // 3. POST PAR request parProof, err := kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, "", "") if err != nil { log.Printf("ATProto: build PAR DPoP proof: %v", err) http.Error(w, "Internal error", 500) return } parParams := url.Values{ "response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirectURI}, "scope": {"atproto transition:generic"}, "state": {state}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, "login_hint": {handle}, } req, _ := http.NewRequest("POST", meta.PushedAuthorizationRequestEndpoint, strings.NewReader(parParams.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("DPoP", parProof) resp, err := http.DefaultClient.Do(req) if err != nil { log.Printf("ATProto: PAR request failed: %v", err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"}) return } defer resp.Body.Close() // Handle DPoP nonce requirement nonce := resp.Header.Get("DPoP-Nonce") if resp.StatusCode == http.StatusBadRequest && nonce != "" { parProof, err = kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, nonce, "") if err != nil { http.Error(w, "Internal error", 500) return } req2, _ := http.NewRequest("POST", meta.PushedAuthorizationRequestEndpoint, strings.NewReader(parParams.Encode())) req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") req2.Header.Set("DPoP", parProof) resp.Body.Close() resp, err = http.DefaultClient.Do(req2) if err != nil { log.Printf("ATProto: PAR retry failed: %v", err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"}) return } defer resp.Body.Close() nonce = resp.Header.Get("DPoP-Nonce") } if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) log.Printf("ATProto: PAR response %d: %s", resp.StatusCode, body) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Authorization request failed"}) return } var parResp struct { RequestURI string `json:"request_uri"` } if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil || parResp.RequestURI == "" { log.Printf("ATProto: decode PAR response: %v", err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Unexpected response from PDS"}) return } // 4. Store state in session sess := auth.GetSession(r) sess.Values["atproto_state"] = state sess.Values["atproto_pkce_verifier"] = verifier sess.Values["atproto_dpop_key"] = string(keyJSON) sess.Values["atproto_dpop_nonce"] = nonce sess.Values["atproto_token_endpoint"] = meta.TokenEndpoint sess.Values["atproto_did"] = did sess.Values["atproto_handle"] = handle sess.Values["atproto_pds_url"] = pds if next != "" { sess.Values["atproto_next"] = next } sess.Save(r, w) authURL := meta.AuthorizationEndpoint + "?client_id=" + url.QueryEscape(clientID) + "&request_uri=" + url.QueryEscape(parResp.RequestURI) http.Redirect(w, r, authURL, http.StatusFound) } // ATProtoCallback handles the redirect back from the PDS after user approval. func (h *Handler) ATProtoCallback(w http.ResponseWriter, r *http.Request) { sess := auth.GetSession(r) // 1. Validate state state := r.URL.Query().Get("state") expectedState, _ := sess.Values["atproto_state"].(string) if state == "" || state != expectedState { http.Error(w, "Invalid state", http.StatusBadRequest) return } code := r.URL.Query().Get("code") if code == "" { errMsg := r.URL.Query().Get("error_description") if errMsg == "" { errMsg = r.URL.Query().Get("error") } h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: fmt.Sprintf("Authorization denied: %s", errMsg)}) return } verifier, _ := sess.Values["atproto_pkce_verifier"].(string) keyJSON, _ := sess.Values["atproto_dpop_key"].(string) nonce, _ := sess.Values["atproto_dpop_nonce"].(string) tokenEndpoint, _ := sess.Values["atproto_token_endpoint"].(string) expectedDID, _ := sess.Values["atproto_did"].(string) pdsURL, _ := sess.Values["atproto_pds_url"].(string) next, _ := sess.Values["atproto_next"].(string) // Clean up session state delete(sess.Values, "atproto_state") delete(sess.Values, "atproto_pkce_verifier") delete(sess.Values, "atproto_dpop_key") delete(sess.Values, "atproto_dpop_nonce") delete(sess.Values, "atproto_token_endpoint") delete(sess.Values, "atproto_did") delete(sess.Values, "atproto_pds_url") delete(sess.Values, "atproto_next") // 2. Exchange code for tokens kp, err := dpop.UnmarshalPrivate([]byte(keyJSON)) if err != nil { log.Printf("ATProto callback: unmarshal DPoP key: %v", err) http.Error(w, "Internal error", 500) return } redirectURI := baseURL() + "/auth/atproto/callback" clientID := baseURL() + "/client-metadata.json" tokenParams := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "client_id": {clientID}, "code_verifier": {verifier}, } tokenProof, err := kp.Proof("POST", tokenEndpoint, nonce, "") if err != nil { log.Printf("ATProto callback: build token DPoP proof: %v", err) http.Error(w, "Internal error", 500) return } tokenReq, _ := http.NewRequest("POST", tokenEndpoint, strings.NewReader(tokenParams.Encode())) tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") tokenReq.Header.Set("DPoP", tokenProof) tokenResp, err := http.DefaultClient.Do(tokenReq) if err != nil { log.Printf("ATProto callback: token request: %v", err) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) return } defer tokenResp.Body.Close() // Handle nonce refresh on token endpoint newNonce := tokenResp.Header.Get("DPoP-Nonce") if tokenResp.StatusCode == http.StatusBadRequest && newNonce != "" { tokenProof, err = kp.Proof("POST", tokenEndpoint, newNonce, "") if err != nil { http.Error(w, "Internal error", 500) return } tokenReq2, _ := http.NewRequest("POST", tokenEndpoint, strings.NewReader(tokenParams.Encode())) tokenReq2.Header.Set("Content-Type", "application/x-www-form-urlencoded") tokenReq2.Header.Set("DPoP", tokenProof) tokenResp.Body.Close() tokenResp, err = http.DefaultClient.Do(tokenReq2) if err != nil { h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) return } defer tokenResp.Body.Close() newNonce = tokenResp.Header.Get("DPoP-Nonce") } if tokenResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(tokenResp.Body) log.Printf("ATProto callback: token response %d: %s", tokenResp.StatusCode, body) h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) return } var tokenBody struct { Sub string `json:"sub"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil { log.Printf("ATProto callback: decode token response: %v", err) http.Error(w, "Internal error", 500) return } // Capture final nonce from response if newNonce == "" { newNonce = nonce } // 3. Verify sub matches the DID we resolved at the start if tokenBody.Sub == "" { http.Error(w, "Token missing sub claim", http.StatusBadRequest) return } if tokenBody.Sub != expectedDID { log.Printf("ATProto callback: sub mismatch: got %s, expected %s", tokenBody.Sub, expectedDID) http.Error(w, "DID mismatch", http.StatusBadRequest) return } // 4. Find or create user user, err := h.DB.GetUserByDID(tokenBody.Sub) if err == sql.ErrNoRows { user = &model.User{ DID: tokenBody.Sub, } if err := h.DB.CreateUser(user); err != nil { log.Printf("ATProto callback: create user: %v", err) http.Error(w, "Internal error", 500) return } } else if err != nil { log.Printf("ATProto callback: get user by DID: %v", err) http.Error(w, "Internal error", 500) return } // 5. Store ATProto session with tokens expiresAt := time.Now().Add(time.Duration(tokenBody.ExpiresIn) * time.Second) atSession := &model.ATProtoSession{ UserID: user.ID, DID: tokenBody.Sub, PDSURL: pdsURL, AccessToken: tokenBody.AccessToken, RefreshToken: tokenBody.RefreshToken, DPoPKeyJWK: keyJSON, DPoPNonce: newNonce, TokenEndpoint: tokenEndpoint, ExpiresAt: expiresAt, } if err := h.DB.UpsertATProtoSession(atSession); err != nil { log.Printf("ATProto callback: upsert session: %v", err) http.Error(w, "Internal error", 500) return } auth.SetUserID(w, r, user.ID) sess.Save(r, w) // Redirect to the originally requested URL, or home. // Only allow local paths to prevent open redirect. if next != "" && strings.HasPrefix(next, "/") { http.Redirect(w, r, next, http.StatusSeeOther) return } http.Redirect(w, r, "/", http.StatusSeeOther) }