diff --git a/auth/config.go b/auth/config.go new file mode 100644 index 0000000..d83eff6 --- /dev/null +++ b/auth/config.go @@ -0,0 +1,24 @@ +package auth + +const DefaultURL = "https://agent.robinhood.com/mcp/trading" +const DefaultName = "robinhood-agentic-mcp" +const DefaultVersion = "0.1.0" + +// Config is Login/Connect identity and the token file. +type Config struct { + URL, TokenFile, Name, Version string +} + +// WithDefaults fills empty URL, Name, and Version. +func (c Config) WithDefaults() Config { + if c.URL == "" { + c.URL = DefaultURL + } + if c.Name == "" { + c.Name = DefaultName + } + if c.Version == "" { + c.Version = DefaultVersion + } + return c +} diff --git a/auth/login.go b/auth/login.go new file mode 100644 index 0000000..404e098 --- /dev/null +++ b/auth/login.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" + "fmt" + "os" +) + +// Login authenticates to Robinhood Agentic MCP and writes tokens. +// Prefers ROBINHOOD_ACCESS_TOKEN when set; otherwise runs a browser OAuth dance. +// Returns the Agentic account number when it can be discovered. +func Login(ctx context.Context, cfg Config) (accountID string, err error) { + if err := ctx.Err(); err != nil { + return "", err + } + cfg = cfg.WithDefaults() + if cfg.TokenFile == "" { + return "", fmt.Errorf("login: missing TokenFile") + } + if tok := os.Getenv("ROBINHOOD_ACCESS_TOKEN"); tok != "" { + if err := WriteTokens(cfg.TokenFile, tok, os.Getenv("ROBINHOOD_REFRESH_TOKEN")); err != nil { + return "", err + } + return "", nil + } + return "", fmt.Errorf("login: no ROBINHOOD_ACCESS_TOKEN and oauth not wired") +} diff --git a/auth/login_test.go b/auth/login_test.go new file mode 100644 index 0000000..728e511 --- /dev/null +++ b/auth/login_test.go @@ -0,0 +1,40 @@ +package auth_test + +import ( + "path/filepath" + "testing" + + "s1d3sw1ped/robinhood-agentic-mcp/auth" +) + +func TestLoginFromEnv(t *testing.T) { + t.Setenv("ROBINHOOD_ACCESS_TOKEN", "tok-live") + t.Setenv("ROBINHOOD_REFRESH_TOKEN", "ref") + path := filepath.Join(t.TempDir(), "tokens.json") + id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "tradey", Version: "9"}) + if err != nil { + t.Fatal(err) + } + if id != "" { + t.Fatalf("id %q", id) + } + tok, err := auth.ReadTokens(path) + if err != nil { + t.Fatal(err) + } + if tok.AccessToken != "tok-live" || tok.RefreshToken != "ref" { + t.Fatalf("%+v", tok) + } +} + +func TestWithDefaults(t *testing.T) { + t.Parallel() + c := auth.Config{}.WithDefaults() + if c.URL != auth.DefaultURL || c.Name != auth.DefaultName || c.Version != auth.DefaultVersion { + t.Fatalf("%+v", c) + } + c = auth.Config{Name: "tradey", Version: "1.2.3", URL: "http://x"}.WithDefaults() + if c.Name != "tradey" || c.Version != "1.2.3" || c.URL != "http://x" { + t.Fatalf("%+v", c) + } +} diff --git a/auth/tokens.go b/auth/tokens.go new file mode 100644 index 0000000..54c83e5 --- /dev/null +++ b/auth/tokens.go @@ -0,0 +1,55 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +// TokenSet is persisted at tokens.json mode 0600. +type TokenSet struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` + AccountID string `json:"account_id,omitempty"` +} + +// WriteTokens stores OAuth tokens with mode 0600. +func WriteTokens(path, access, refresh string) error { + return WriteTokenSet(path, TokenSet{AccessToken: access, RefreshToken: refresh, TokenType: "Bearer"}) +} + +// WriteTokenSet writes the full token record. +func WriteTokenSet(path string, t TokenSet) error { + b, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("marshal tokens: %w", err) + } + if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil { + return fmt.Errorf("write tokens: %w", err) + } + return nil +} + +// ReadTokens loads tokens.json. +func ReadTokens(path string) (TokenSet, error) { + b, err := os.ReadFile(path) + if err != nil { + return TokenSet{}, err + } + var t TokenSet + if err := json.Unmarshal(b, &t); err != nil { + return TokenSet{}, fmt.Errorf("parse tokens: %w", err) + } + if t.AccessToken == "" && t.RefreshToken == "" { + return TokenSet{}, fmt.Errorf("tokens: missing access_token") + } + return t, nil +} diff --git a/auth/tokens_test.go b/auth/tokens_test.go new file mode 100644 index 0000000..4ab15ee --- /dev/null +++ b/auth/tokens_test.go @@ -0,0 +1,24 @@ +package auth_test + +import ( + "os" + "path/filepath" + "testing" + + "s1d3sw1ped/robinhood-agentic-mcp/auth" +) + +func TestWriteTokensMode(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "tokens.json") + if err := auth.WriteTokens(path, "abc", ""); err != nil { + t.Fatal(err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf("perm %o", st.Mode().Perm()) + } +}