feat: add token file and env Login
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user