Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 103 lines 3.1 kB view raw
1package atproto 2 3import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "log" 8 "net/http" 9 "net/url" 10 "os" 11 "strings" 12 "time" 13 14 "github.com/limeleaf/diffdown/internal/atproto/dpop" 15 "github.com/limeleaf/diffdown/internal/db" 16 "github.com/limeleaf/diffdown/internal/model" 17) 18 19// RefreshAccessToken refreshes the access token for the given ATProto session. 20func RefreshAccessToken(database *db.DB, session *model.ATProtoSession) error { 21 kp, err := dpop.UnmarshalPrivate([]byte(session.DPoPKeyJWK)) 22 if err != nil { 23 return fmt.Errorf("unmarshal DPoP key: %w", err) 24 } 25 26 base := os.Getenv("DIFFDOWN_BASE_URL") 27 if base == "" { 28 base = "http://127.0.0.1:8080" 29 } 30 clientID := strings.TrimRight(base, "/") + "/client-metadata.json" 31 32 params := url.Values{ 33 "grant_type": {"refresh_token"}, 34 "refresh_token": {session.RefreshToken}, 35 "client_id": {clientID}, 36 } 37 38 proof, err := kp.Proof("POST", session.TokenEndpoint, session.DPoPNonce, "") 39 if err != nil { 40 return fmt.Errorf("build DPoP proof: %w", err) 41 } 42 43 req, _ := http.NewRequest("POST", session.TokenEndpoint, strings.NewReader(params.Encode())) 44 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 45 req.Header.Set("DPoP", proof) 46 47 resp, err := http.DefaultClient.Do(req) 48 if err != nil { 49 return fmt.Errorf("refresh request: %w", err) 50 } 51 defer resp.Body.Close() 52 53 // Handle nonce retry 54 newNonce := resp.Header.Get("DPoP-Nonce") 55 if resp.StatusCode == http.StatusBadRequest && newNonce != "" { 56 proof, err = kp.Proof("POST", session.TokenEndpoint, newNonce, "") 57 if err != nil { 58 return fmt.Errorf("build DPoP proof (retry): %w", err) 59 } 60 req2, _ := http.NewRequest("POST", session.TokenEndpoint, strings.NewReader(params.Encode())) 61 req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") 62 req2.Header.Set("DPoP", proof) 63 resp.Body.Close() 64 resp, err = http.DefaultClient.Do(req2) 65 if err != nil { 66 return fmt.Errorf("refresh retry: %w", err) 67 } 68 defer resp.Body.Close() 69 newNonce = resp.Header.Get("DPoP-Nonce") 70 } 71 72 if resp.StatusCode != http.StatusOK { 73 body, _ := io.ReadAll(resp.Body) 74 return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, body) 75 } 76 77 var tokenBody struct { 78 AccessToken string `json:"access_token"` 79 RefreshToken string `json:"refresh_token"` 80 ExpiresIn int `json:"expires_in"` 81 } 82 if err := json.NewDecoder(resp.Body).Decode(&tokenBody); err != nil { 83 return fmt.Errorf("decode refresh response: %w", err) 84 } 85 86 if newNonce == "" { 87 newNonce = session.DPoPNonce 88 } 89 90 expiresAt := time.Now().Add(time.Duration(tokenBody.ExpiresIn) * time.Second) 91 if err := database.UpdateATProtoTokens(session.UserID, tokenBody.AccessToken, tokenBody.RefreshToken, newNonce, expiresAt); err != nil { 92 return fmt.Errorf("update tokens in DB: %w", err) 93 } 94 95 // Update in-memory session 96 session.AccessToken = tokenBody.AccessToken 97 session.RefreshToken = tokenBody.RefreshToken 98 session.DPoPNonce = newNonce 99 session.ExpiresAt = expiresAt 100 101 log.Printf("ATProto: refreshed token for user %s", session.UserID) 102 return nil 103}