A terminal client for Tangled.
tui go tangled cli
5

Configure Feed

Select the types of activity you want to include in your feed.

atproto, cli: add oauth commands

Aly Raffauf (Jul 7, 2026, 3:03 PM EDT) 036af328 bbdeaa56

+442 -4
+147
atproto/auth.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "net/url" 9 + "os" 10 + "path/filepath" 11 + 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + var ErrNotAuthenticated = errors.New("not authenticated") 18 + 19 + // DefaultScopes are requested for a CLI session. transition:generic grants 20 + // broad repository write access equivalent to an app password. 21 + var DefaultScopes = []string{"atproto", "transition:generic"} 22 + 23 + type AuthManager struct { 24 + App *oauth.ClientApp 25 + Store *FileStore 26 + state authState 27 + statePath string 28 + } 29 + 30 + type authState struct { 31 + CurrentDID string `json:"current_did,omitempty"` 32 + CurrentSession string `json:"current_session,omitempty"` 33 + } 34 + 35 + // ConfigDir returns the configuration directory for tg. 36 + // 37 + // It respects the XDG Base Directory Specification: if XDG_CONFIG_HOME is set, 38 + // it uses that directory; otherwise it falls back to ~/.config/tg. 39 + func ConfigDir() (string, error) { 40 + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { 41 + return filepath.Join(dir, "tg"), nil 42 + } 43 + 44 + home, err := os.UserHomeDir() 45 + if err != nil { 46 + return "", err 47 + } 48 + return filepath.Join(home, ".config", "tg"), nil 49 + } 50 + 51 + // NewAuthManager creates an AuthManager. callbackURL must be reachable by the 52 + // user's browser during login. 53 + func NewAuthManager(callbackURL string, dir string) (*AuthManager, error) { 54 + config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) 55 + config.UserAgent = "tg" 56 + 57 + store := NewFileStore(filepath.Join(dir, "oauth")) 58 + manager := &AuthManager{ 59 + App: oauth.NewClientApp(&config, store), 60 + Store: store, 61 + statePath: filepath.Join(dir, "auth.json"), 62 + } 63 + if err := manager.loadState(); err != nil { 64 + return nil, fmt.Errorf("load auth state: %w", err) 65 + } 66 + return manager, nil 67 + } 68 + 69 + func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { 70 + return m.App.StartAuthFlow(ctx, identifier) 71 + } 72 + 73 + func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error { 74 + session, err := m.App.ProcessCallback(ctx, query) 75 + if err != nil { 76 + return err 77 + } 78 + 79 + m.state.CurrentDID = session.AccountDID.String() 80 + m.state.CurrentSession = session.SessionID 81 + return m.saveState() 82 + } 83 + 84 + func (m *AuthManager) CurrentDID() syntax.DID { 85 + if m.state.CurrentDID == "" { 86 + return syntax.DID("") 87 + } 88 + did, err := syntax.ParseDID(m.state.CurrentDID) 89 + if err != nil { 90 + return syntax.DID("") 91 + } 92 + return did 93 + } 94 + 95 + func (m *AuthManager) IsAuthenticated() bool { 96 + return m.state.CurrentDID != "" && m.state.CurrentSession != "" 97 + } 98 + 99 + func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { 100 + if !m.IsAuthenticated() { 101 + return nil, ErrNotAuthenticated 102 + } 103 + return m.App.ResumeSession(ctx, m.CurrentDID(), m.state.CurrentSession) 104 + } 105 + 106 + func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, error) { 107 + session, err := m.CurrentSession(ctx) 108 + if err != nil { 109 + return nil, err 110 + } 111 + return session.APIClient(), nil 112 + } 113 + 114 + func (m *AuthManager) Logout(ctx context.Context) error { 115 + if !m.IsAuthenticated() { 116 + return nil 117 + } 118 + if err := m.App.Logout(ctx, m.CurrentDID(), m.state.CurrentSession); err != nil { 119 + return err 120 + } 121 + 122 + m.state = authState{} 123 + return m.saveState() 124 + } 125 + 126 + func (m *AuthManager) loadState() error { 127 + data, err := os.ReadFile(m.statePath) 128 + if err != nil { 129 + if os.IsNotExist(err) { 130 + return nil 131 + } 132 + return err 133 + } 134 + return json.Unmarshal(data, &m.state) 135 + } 136 + 137 + func (m *AuthManager) saveState() error { 138 + if err := os.MkdirAll(filepath.Dir(m.statePath), 0o700); err != nil { 139 + return err 140 + } 141 + 142 + data, err := json.MarshalIndent(m.state, "", " ") 143 + if err != nil { 144 + return err 145 + } 146 + return os.WriteFile(m.statePath, data, 0o600) 147 + }
+6 -4
atproto/resolve.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 ) 11 11 12 - // Resolver wraps an identity.Directory to provide typed handle and 13 - // DID resolution with a sane default timeout. 12 + const resolveTimeout = 3 * time.Second 13 + 14 + // Resolver wraps an identity.Directory to provide typed handle and DID 15 + // resolution with a default timeout. 14 16 type Resolver struct { 15 17 Directory identity.Directory 16 18 } ··· 21 23 return nil, fmt.Errorf("parse handle %q: %w", rawHandle, err) 22 24 } 23 25 24 - resolveCtx, cancel := context.WithTimeout(ctx, 3*time.Second) 26 + resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) 25 27 defer cancel() 26 28 27 29 ident, err := r.Directory.LookupHandle(resolveCtx, handle) ··· 38 40 return nil, fmt.Errorf("parse DID %q: %w", didStr, err) 39 41 } 40 42 41 - resolveCtx, cancel := context.WithTimeout(ctx, 3*time.Second) 43 + resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) 42 44 defer cancel() 43 45 44 46 ident, err := r.Directory.LookupDID(resolveCtx, did)
+95
atproto/store.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // FileStore implements oauth.ClientAuthStore on the local filesystem. 15 + type FileStore struct { 16 + Directory string 17 + } 18 + 19 + func NewFileStore(dir string) *FileStore { 20 + return &FileStore{Directory: dir} 21 + } 22 + 23 + func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 24 + data, err := os.ReadFile(s.sessionPath(did, sessionID)) 25 + if err != nil { 26 + if os.IsNotExist(err) { 27 + return nil, fmt.Errorf("session not found: %w", err) 28 + } 29 + return nil, err 30 + } 31 + 32 + var session oauth.ClientSessionData 33 + if err := json.Unmarshal(data, &session); err != nil { 34 + return nil, fmt.Errorf("decode session: %w", err) 35 + } 36 + return &session, nil 37 + } 38 + 39 + func (s *FileStore) SaveSession(ctx context.Context, session oauth.ClientSessionData) error { 40 + path := s.sessionPath(session.AccountDID, session.SessionID) 41 + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 42 + return err 43 + } 44 + 45 + data, err := json.MarshalIndent(session, "", " ") 46 + if err != nil { 47 + return err 48 + } 49 + return os.WriteFile(path, data, 0o600) 50 + } 51 + 52 + func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 53 + return os.Remove(s.sessionPath(did, sessionID)) 54 + } 55 + 56 + func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 57 + data, err := os.ReadFile(s.requestPath(state)) 58 + if err != nil { 59 + if os.IsNotExist(err) { 60 + return nil, fmt.Errorf("auth request not found: %w", err) 61 + } 62 + return nil, err 63 + } 64 + 65 + var info oauth.AuthRequestData 66 + if err := json.Unmarshal(data, &info); err != nil { 67 + return nil, fmt.Errorf("decode auth request: %w", err) 68 + } 69 + return &info, nil 70 + } 71 + 72 + func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 73 + path := s.requestPath(info.State) 74 + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 75 + return err 76 + } 77 + 78 + data, err := json.MarshalIndent(info, "", " ") 79 + if err != nil { 80 + return err 81 + } 82 + return os.WriteFile(path, data, 0o600) 83 + } 84 + 85 + func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 86 + return os.Remove(s.requestPath(state)) 87 + } 88 + 89 + func (s *FileStore) sessionPath(did syntax.DID, sessionID string) string { 90 + return filepath.Join(s.Directory, "sessions", did.String(), sessionID+".json") 91 + } 92 + 93 + func (s *FileStore) requestPath(state string) string { 94 + return filepath.Join(s.Directory, "requests", state+".json") 95 + }
+2
go.mod
··· 15 15 github.com/go-logr/logr v1.4.1 // indirect 16 16 github.com/go-logr/stdr v1.2.2 // indirect 17 17 github.com/gogo/protobuf v1.3.2 // indirect 18 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 19 + github.com/google/go-querystring v1.1.0 // indirect 18 20 github.com/google/uuid v1.4.0 // indirect 19 21 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 20 22 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
+5
go.sum
··· 23 23 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 24 24 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 25 25 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 26 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 27 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 28 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 26 29 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 27 30 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 31 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 32 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 28 33 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 29 34 github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= 30 35 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+8
internal/cli/auth.go
··· 1 + package cli 2 + 3 + import "github.com/spf13/cobra" 4 + 5 + var authCmd = &cobra.Command{ 6 + Use: "auth", 7 + Short: "Manage authentication", 8 + }
+109
internal/cli/auth_login.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "os/exec" 8 + "runtime" 9 + 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + var authLoginCmd = &cobra.Command{ 14 + Use: "login [handle]", 15 + Short: "Log in to atproto via OAuth", 16 + Long: `Log in to atproto via OAuth using a local browser callback.`, 17 + Args: cobra.MaximumNArgs(1), 18 + RunE: func(cmd *cobra.Command, args []string) error { 19 + if auth == nil { 20 + return fmt.Errorf("auth is not available") 21 + } 22 + 23 + identifier := "" 24 + if len(args) == 1 { 25 + identifier = args[0] 26 + } 27 + if identifier == "" { 28 + return fmt.Errorf("handle or DID required") 29 + } 30 + 31 + server, resultChannel, err := runCallbackServer() 32 + if err != nil { 33 + return err 34 + } 35 + defer server.Shutdown(context.Background()) 36 + 37 + ctx := cmd.Context() 38 + loginURL, err := auth.StartLogin(ctx, identifier) 39 + if err != nil { 40 + return err 41 + } 42 + 43 + fmt.Println("Opening browser to complete login...") 44 + if err := openBrowser(loginURL); err != nil { 45 + fmt.Printf("Could not open browser. Open this URL manually:\n%s\n", loginURL) 46 + } 47 + 48 + select { 49 + case err := <-resultChannel: 50 + if err != nil { 51 + return err 52 + } 53 + fmt.Printf("Logged in as %s\n", auth.CurrentDID()) 54 + return nil 55 + case <-ctx.Done(): 56 + return ctx.Err() 57 + } 58 + }, 59 + } 60 + 61 + // runCallbackServer starts the local HTTP server that receives the OAuth 62 + // redirect after the user approves the login in their browser. 63 + func runCallbackServer() (*http.Server, <-chan error, error) { 64 + resultChannel := make(chan error, 1) 65 + 66 + serveMux := http.NewServeMux() 67 + serveMux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 68 + if err := auth.FinishLogin(r.Context(), r.URL.Query()); err != nil { 69 + resultChannel <- err 70 + http.Error(w, err.Error(), http.StatusBadRequest) 71 + return 72 + } 73 + resultChannel <- nil 74 + fmt.Fprintln(w, "Authenticated successfully. You can close this tab.") 75 + }) 76 + 77 + server := &http.Server{ 78 + Addr: oauthCallbackAddr, 79 + Handler: serveMux, 80 + } 81 + 82 + go func() { 83 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 84 + resultChannel <- fmt.Errorf("callback server: %w", err) 85 + } 86 + }() 87 + 88 + return server, resultChannel, nil 89 + } 90 + 91 + // openBrowser launches the user's default browser to url. 92 + func openBrowser(url string) error { 93 + var cmd string 94 + var args []string 95 + 96 + switch runtime.GOOS { 97 + case "darwin": 98 + cmd = "open" 99 + args = []string{url} 100 + case "windows": 101 + cmd = "cmd" 102 + args = []string{"/c", "start", url} 103 + default: 104 + cmd = "xdg-open" 105 + args = []string{url} 106 + } 107 + 108 + return exec.Command(cmd, args...).Start() 109 + }
+22
internal/cli/auth_logout.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + var authLogoutCmd = &cobra.Command{ 10 + Use: "logout", 11 + Short: "Log out of your AT Protocol account", 12 + RunE: func(cmd *cobra.Command, args []string) error { 13 + if auth == nil { 14 + return fmt.Errorf("auth is not available") 15 + } 16 + if err := auth.Logout(cmd.Context()); err != nil { 17 + return err 18 + } 19 + fmt.Println("Logged out.") 20 + return nil 21 + }, 22 + }
+20
internal/cli/auth_status.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + var authStatusCmd = &cobra.Command{ 10 + Use: "status", 11 + Short: "Show authentication status", 12 + RunE: func(cmd *cobra.Command, args []string) error { 13 + if auth == nil || !auth.IsAuthenticated() { 14 + fmt.Println("Not logged in.") 15 + return nil 16 + } 17 + fmt.Printf("Logged in as %s\n", auth.CurrentDID()) 18 + return nil 19 + }, 20 + }
+28
internal/cli/root.go
··· 1 1 package cli 2 2 3 3 import ( 4 + "fmt" 4 5 "log/slog" 6 + "os" 5 7 6 8 "github.com/alyraffauf/tg/atproto" 7 9 "github.com/alyraffauf/tg/tangled" 8 10 "github.com/bluesky-social/indigo/atproto/identity" 9 11 "github.com/bluesky-social/indigo/xrpc" 10 12 "github.com/spf13/cobra" 13 + ) 14 + 15 + const ( 16 + oauthCallbackAddr = "127.0.0.1:8095" 17 + oauthCallbackURL = "http://" + oauthCallbackAddr + "/callback" 11 18 ) 12 19 13 20 var ( ··· 16 23 Client: &xrpc.Client{Host: "https://api.tangled.org"}, 17 24 Logger: slog.Default(), 18 25 } 26 + auth *atproto.AuthManager 19 27 ) 20 28 21 29 var rootCmd = &cobra.Command{ ··· 28 36 } 29 37 30 38 func init() { 39 + initAuth() 40 + 41 + rootCmd.AddCommand(authCmd) 42 + authCmd.AddCommand(authLoginCmd) 43 + authCmd.AddCommand(authLogoutCmd) 44 + authCmd.AddCommand(authStatusCmd) 45 + 31 46 rootCmd.AddCommand(issueCmd) 32 47 issueCmd.AddCommand(issueListCmd) 33 48 ··· 39 54 repoCmd.AddCommand(repoListCmd) 40 55 repoCmd.AddCommand(repoCloneCmd) 41 56 } 57 + 58 + func initAuth() { 59 + dir, err := atproto.ConfigDir() 60 + if err != nil { 61 + fmt.Fprintf(os.Stderr, "warning: could not determine config dir: %v\n", err) 62 + return 63 + } 64 + 65 + auth, err = atproto.NewAuthManager(oauthCallbackURL, dir) 66 + if err != nil { 67 + fmt.Fprintf(os.Stderr, "warning: auth initialization failed: %v\n", err) 68 + } 69 + }