From 036af3282e628cec72345ec8922750a575387476 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Tue, 7 Jul 2026 15:03:52 -0400 Subject: [PATCH] atproto, cli: add oauth commands --- atproto/auth.go | 147 ++++++++++++++++++++++++++++++++++++ atproto/resolve.go | 10 ++- atproto/store.go | 95 +++++++++++++++++++++++ go.mod | 2 + go.sum | 5 ++ internal/cli/auth.go | 8 ++ internal/cli/auth_login.go | 109 ++++++++++++++++++++++++++ internal/cli/auth_logout.go | 22 ++++++ internal/cli/auth_status.go | 20 +++++ internal/cli/root.go | 28 +++++++ 10 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 atproto/auth.go create mode 100644 atproto/store.go create mode 100644 internal/cli/auth.go create mode 100644 internal/cli/auth_login.go create mode 100644 internal/cli/auth_logout.go create mode 100644 internal/cli/auth_status.go diff --git a/atproto/auth.go b/atproto/auth.go new file mode 100644 index 0000000..bdf4ccd --- /dev/null +++ b/atproto/auth.go @@ -0,0 +1,147 @@ +package atproto + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +var ErrNotAuthenticated = errors.New("not authenticated") + +// DefaultScopes are requested for a CLI session. transition:generic grants +// broad repository write access equivalent to an app password. +var DefaultScopes = []string{"atproto", "transition:generic"} + +type AuthManager struct { + App *oauth.ClientApp + Store *FileStore + state authState + statePath string +} + +type authState struct { + CurrentDID string `json:"current_did,omitempty"` + CurrentSession string `json:"current_session,omitempty"` +} + +// ConfigDir returns the configuration directory for tg. +// +// It respects the XDG Base Directory Specification: if XDG_CONFIG_HOME is set, +// it uses that directory; otherwise it falls back to ~/.config/tg. +func ConfigDir() (string, error) { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "tg"), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "tg"), nil +} + +// NewAuthManager creates an AuthManager. callbackURL must be reachable by the +// user's browser during login. +func NewAuthManager(callbackURL string, dir string) (*AuthManager, error) { + config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) + config.UserAgent = "tg" + + store := NewFileStore(filepath.Join(dir, "oauth")) + manager := &AuthManager{ + App: oauth.NewClientApp(&config, store), + Store: store, + statePath: filepath.Join(dir, "auth.json"), + } + if err := manager.loadState(); err != nil { + return nil, fmt.Errorf("load auth state: %w", err) + } + return manager, nil +} + +func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { + return m.App.StartAuthFlow(ctx, identifier) +} + +func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error { + session, err := m.App.ProcessCallback(ctx, query) + if err != nil { + return err + } + + m.state.CurrentDID = session.AccountDID.String() + m.state.CurrentSession = session.SessionID + return m.saveState() +} + +func (m *AuthManager) CurrentDID() syntax.DID { + if m.state.CurrentDID == "" { + return syntax.DID("") + } + did, err := syntax.ParseDID(m.state.CurrentDID) + if err != nil { + return syntax.DID("") + } + return did +} + +func (m *AuthManager) IsAuthenticated() bool { + return m.state.CurrentDID != "" && m.state.CurrentSession != "" +} + +func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { + if !m.IsAuthenticated() { + return nil, ErrNotAuthenticated + } + return m.App.ResumeSession(ctx, m.CurrentDID(), m.state.CurrentSession) +} + +func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, error) { + session, err := m.CurrentSession(ctx) + if err != nil { + return nil, err + } + return session.APIClient(), nil +} + +func (m *AuthManager) Logout(ctx context.Context) error { + if !m.IsAuthenticated() { + return nil + } + if err := m.App.Logout(ctx, m.CurrentDID(), m.state.CurrentSession); err != nil { + return err + } + + m.state = authState{} + return m.saveState() +} + +func (m *AuthManager) loadState() error { + data, err := os.ReadFile(m.statePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return json.Unmarshal(data, &m.state) +} + +func (m *AuthManager) saveState() error { + if err := os.MkdirAll(filepath.Dir(m.statePath), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(m.state, "", " ") + if err != nil { + return err + } + return os.WriteFile(m.statePath, data, 0o600) +} diff --git a/atproto/resolve.go b/atproto/resolve.go index 5cd977c..3d89438 100644 --- a/atproto/resolve.go +++ b/atproto/resolve.go @@ -9,8 +9,10 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" ) -// Resolver wraps an identity.Directory to provide typed handle and -// DID resolution with a sane default timeout. +const resolveTimeout = 3 * time.Second + +// Resolver wraps an identity.Directory to provide typed handle and DID +// resolution with a default timeout. type Resolver struct { Directory identity.Directory } @@ -21,7 +23,7 @@ func (r *Resolver) ResolveHandle(ctx context.Context, rawHandle string) (*identi return nil, fmt.Errorf("parse handle %q: %w", rawHandle, err) } - resolveCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) defer cancel() ident, err := r.Directory.LookupHandle(resolveCtx, handle) @@ -38,7 +40,7 @@ func (r *Resolver) ResolveDID(ctx context.Context, didStr string) (*identity.Ide return nil, fmt.Errorf("parse DID %q: %w", didStr, err) } - resolveCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) defer cancel() ident, err := r.Directory.LookupDID(resolveCtx, did) diff --git a/atproto/store.go b/atproto/store.go new file mode 100644 index 0000000..d6cba90 --- /dev/null +++ b/atproto/store.go @@ -0,0 +1,95 @@ +package atproto + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// FileStore implements oauth.ClientAuthStore on the local filesystem. +type FileStore struct { + Directory string +} + +func NewFileStore(dir string) *FileStore { + return &FileStore{Directory: dir} +} + +func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + data, err := os.ReadFile(s.sessionPath(did, sessionID)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("session not found: %w", err) + } + return nil, err + } + + var session oauth.ClientSessionData + if err := json.Unmarshal(data, &session); err != nil { + return nil, fmt.Errorf("decode session: %w", err) + } + return &session, nil +} + +func (s *FileStore) SaveSession(ctx context.Context, session oauth.ClientSessionData) error { + path := s.sessionPath(session.AccountDID, session.SessionID) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(session, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + return os.Remove(s.sessionPath(did, sessionID)) +} + +func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + data, err := os.ReadFile(s.requestPath(state)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("auth request not found: %w", err) + } + return nil, err + } + + var info oauth.AuthRequestData + if err := json.Unmarshal(data, &info); err != nil { + return nil, fmt.Errorf("decode auth request: %w", err) + } + return &info, nil +} + +func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + path := s.requestPath(info.State) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { + return os.Remove(s.requestPath(state)) +} + +func (s *FileStore) sessionPath(did syntax.DID, sessionID string) string { + return filepath.Join(s.Directory, "sessions", did.String(), sessionID+".json") +} + +func (s *FileStore) requestPath(state string) string { + return filepath.Join(s.Directory, "requests", state+".json") +} diff --git a/go.mod b/go.mod index 29c40ca..93b52cc 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.4.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect diff --git a/go.sum b/go.sum index 3d0cdf9..512ed14 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,13 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 0000000..b9e0ca4 --- /dev/null +++ b/internal/cli/auth.go @@ -0,0 +1,8 @@ +package cli + +import "github.com/spf13/cobra" + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage authentication", +} diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go new file mode 100644 index 0000000..d765e6b --- /dev/null +++ b/internal/cli/auth_login.go @@ -0,0 +1,109 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "os/exec" + "runtime" + + "github.com/spf13/cobra" +) + +var authLoginCmd = &cobra.Command{ + Use: "login [handle]", + Short: "Log in to atproto via OAuth", + Long: `Log in to atproto via OAuth using a local browser callback.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if auth == nil { + return fmt.Errorf("auth is not available") + } + + identifier := "" + if len(args) == 1 { + identifier = args[0] + } + if identifier == "" { + return fmt.Errorf("handle or DID required") + } + + server, resultChannel, err := runCallbackServer() + if err != nil { + return err + } + defer server.Shutdown(context.Background()) + + ctx := cmd.Context() + loginURL, err := auth.StartLogin(ctx, identifier) + if err != nil { + return err + } + + fmt.Println("Opening browser to complete login...") + if err := openBrowser(loginURL); err != nil { + fmt.Printf("Could not open browser. Open this URL manually:\n%s\n", loginURL) + } + + select { + case err := <-resultChannel: + if err != nil { + return err + } + fmt.Printf("Logged in as %s\n", auth.CurrentDID()) + return nil + case <-ctx.Done(): + return ctx.Err() + } + }, +} + +// runCallbackServer starts the local HTTP server that receives the OAuth +// redirect after the user approves the login in their browser. +func runCallbackServer() (*http.Server, <-chan error, error) { + resultChannel := make(chan error, 1) + + serveMux := http.NewServeMux() + serveMux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + if err := auth.FinishLogin(r.Context(), r.URL.Query()); err != nil { + resultChannel <- err + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resultChannel <- nil + fmt.Fprintln(w, "Authenticated successfully. You can close this tab.") + }) + + server := &http.Server{ + Addr: oauthCallbackAddr, + Handler: serveMux, + } + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + resultChannel <- fmt.Errorf("callback server: %w", err) + } + }() + + return server, resultChannel, nil +} + +// openBrowser launches the user's default browser to url. +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{url} + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + default: + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} diff --git a/internal/cli/auth_logout.go b/internal/cli/auth_logout.go new file mode 100644 index 0000000..6af4f95 --- /dev/null +++ b/internal/cli/auth_logout.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Log out of your AT Protocol account", + RunE: func(cmd *cobra.Command, args []string) error { + if auth == nil { + return fmt.Errorf("auth is not available") + } + if err := auth.Logout(cmd.Context()); err != nil { + return err + } + fmt.Println("Logged out.") + return nil + }, +} diff --git a/internal/cli/auth_status.go b/internal/cli/auth_status.go new file mode 100644 index 0000000..24ba14c --- /dev/null +++ b/internal/cli/auth_status.go @@ -0,0 +1,20 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show authentication status", + RunE: func(cmd *cobra.Command, args []string) error { + if auth == nil || !auth.IsAuthenticated() { + fmt.Println("Not logged in.") + return nil + } + fmt.Printf("Logged in as %s\n", auth.CurrentDID()) + return nil + }, +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 7f9b1d9..e97bd8b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,7 +1,9 @@ package cli import ( + "fmt" "log/slog" + "os" "github.com/alyraffauf/tg/atproto" "github.com/alyraffauf/tg/tangled" @@ -10,12 +12,18 @@ import ( "github.com/spf13/cobra" ) +const ( + oauthCallbackAddr = "127.0.0.1:8095" + oauthCallbackURL = "http://" + oauthCallbackAddr + "/callback" +) + var ( resolver = &atproto.Resolver{Directory: identity.DefaultDirectory()} client = &tangled.Tangled{ Client: &xrpc.Client{Host: "https://api.tangled.org"}, Logger: slog.Default(), } + auth *atproto.AuthManager ) var rootCmd = &cobra.Command{ @@ -28,6 +36,13 @@ func Execute() error { } func init() { + initAuth() + + rootCmd.AddCommand(authCmd) + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authStatusCmd) + rootCmd.AddCommand(issueCmd) issueCmd.AddCommand(issueListCmd) @@ -39,3 +54,16 @@ func init() { repoCmd.AddCommand(repoListCmd) repoCmd.AddCommand(repoCloneCmd) } + +func initAuth() { + dir, err := atproto.ConfigDir() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not determine config dir: %v\n", err) + return + } + + auth, err = atproto.NewAuthManager(oauthCallbackURL, dir) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: auth initialization failed: %v\n", err) + } +} -- 2.51.2