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