feat: add token file and env Login

This commit is contained in:
2026-09-01 11:53:16 -05:00
parent 076c8c81b8
commit 3983d0b335
5 changed files with 170 additions and 0 deletions
+24
View File
@@ -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
}
+27
View File
@@ -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")
}
+40
View File
@@ -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)
}
}
+55
View File
@@ -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
}
+24
View File
@@ -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())
}
}