diff --git a/.gitignore b/.gitignore index 5b90e79..008e8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,21 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Binary +gocalgoo +gocalgoo.exe -# Test binary, built with `go test -c` +# Go *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out +vendor/ -# Dependency directories (remove the comment below to include it) -# vendor/ +# Config with credentials (never commit) +configs/credentials.json -# Go workspace file -go.work -go.work.sum - -# env file -.env +# Editor +.idea/ +.vscode/ +*.swp +*.swo +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cb727c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: build run test lint fmt clean tidy + +BINARY := gocalgoo +CMD := ./cmd/gocalgoo + +build: + go build -o $(BINARY) $(CMD) + +run: + go run $(CMD) $(ARGS) + +test: + go test ./... -v + +lint: + golangci-lint run ./... + +fmt: + gofmt -w . + +tidy: + go mod tidy + +clean: + rm -f $(BINARY) diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..9823872 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,50 @@ +app: + name: GoCalGoo + env: development + log_level: info + data_dir: ~/.local/share/gocalgoo + +oauth: + client_credentials_file: ~/.config/gocalgoo/credentials.json + token_store_file: ~/.config/gocalgoo/tokens.json + default_port: 53682 + open_browser: true + manual_fallback: true + callback_path: /oauth/callback + +google: + scopes: + - https://www.googleapis.com/auth/calendar + - https://www.googleapis.com/auth/contacts + default_calendar_id: primary + +server: + bind: 127.0.0.1 + port: 8080 + read_timeout: 15s + write_timeout: 30s + shutdown_timeout: 10s + +api: + enabled: true + base_path: /api/v1 + auth: + oauth_bearer_enabled: true + api_key_enabled: true + +mcp: + enabled: true + stdio: true + http: true + http_path: /mcp + protocol_version: "2025-06-18" + session_required: true + +security: + api_keys_file: ~/.config/gocalgoo/api-keys.yaml + redact_secrets_in_logs: true + require_tls_for_remote_http: false + +output: + format: table + timezone: Africa/Johannesburg diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..826db92 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.warky.dev/wdevs/gocalgoo + +go 1.22 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 + go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.18.0 + google.golang.org/api v0.170.0 + github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.21.0 +) diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..0947d43 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,286 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "os/exec" + "runtime" + "time" + + "go.uber.org/zap" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "git.warky.dev/wdevs/gocalgoo/internal/store" +) + +type AuthStatus struct { + Authenticated bool `json:"authenticated"` + Account string `json:"account,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` + Expired bool `json:"expired"` + Scopes []string `json:"scopes,omitempty"` +} + +type Manager struct { + cfg ManagerConfig + tokenStore *store.TokenStore + log *zap.Logger +} + +type ManagerConfig struct { + ClientCredentialsFile string + TokenStoreFile string + Scopes []string + DefaultPort int + OpenBrowser bool + CallbackPath string +} + +func NewManager(cfg ManagerConfig, tokenStore *store.TokenStore, log *zap.Logger) *Manager { + return &Manager{cfg: cfg, tokenStore: tokenStore, log: log} +} + +func (m *Manager) Status(ctx context.Context) (AuthStatus, error) { + token, err := m.tokenStore.Load() + if err != nil { + return AuthStatus{}, fmt.Errorf("load token: %w", err) + } + if token == nil { + return AuthStatus{Authenticated: false}, nil + } + return AuthStatus{ + Authenticated: true, + Account: token.Account, + Expiry: token.Expiry, + Expired: token.IsExpired(), + Scopes: token.Scopes, + }, nil +} + +func (m *Manager) Logout(ctx context.Context) error { + if err := m.tokenStore.Delete(); err != nil { + return fmt.Errorf("delete token: %w", err) + } + m.log.Info("logged out") + return nil +} + +func (m *Manager) LoginLoopback(ctx context.Context, port int) error { + oauthCfg, err := m.loadOAuthConfig() + if err != nil { + return err + } + + pkce, err := NewPKCEChallenge() + if err != nil { + return fmt.Errorf("generate pkce: %w", err) + } + + state, err := generateState() + if err != nil { + return fmt.Errorf("generate state: %w", err) + } + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return fmt.Errorf("bind callback listener: %w", err) + } + defer ln.Close() + + actualPort := ln.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", actualPort, m.cfg.CallbackPath) + oauthCfg.RedirectURL = redirectURI + + authURL := oauthCfg.AuthCodeURL(state, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("code_challenge", pkce.Challenge), + oauth2.SetAuthURLParam("code_challenge_method", pkce.Method), + ) + + m.log.Info("starting OAuth2 loopback flow", + zap.Int("port", actualPort), + zap.String("redirect_uri", redirectURI), + ) + + if port == 0 { + fmt.Printf("Listening on port %d\n", actualPort) + fmt.Printf("Redirect URI: %s\n", redirectURI) + } + + if m.cfg.OpenBrowser { + if err := openBrowser(authURL); err != nil { + m.log.Warn("could not open browser", zap.Error(err)) + fmt.Printf("Open this URL in your browser:\n%s\n", authURL) + } + } else { + fmt.Printf("Open this URL in your browser:\n%s\n", authURL) + } + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + srv := &http.Server{} + srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("state") != state { + http.Error(w, "invalid state", http.StatusBadRequest) + errCh <- fmt.Errorf("oauth state mismatch") + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + errCh <- fmt.Errorf("no code in callback") + return + } + fmt.Fprintln(w, "Authentication successful. You may close this tab.") + codeCh <- code + }) + + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + errCh <- fmt.Errorf("callback server: %w", err) + } + }() + + var code string + select { + case code = <-codeCh: + case err := <-errCh: + return err + case <-ctx.Done(): + return ctx.Err() + } + + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + + return m.exchangeAndStore(ctx, oauthCfg, code, pkce.Verifier) +} + +func (m *Manager) LoginManual(ctx context.Context, port int) error { + oauthCfg, err := m.loadOAuthConfig() + if err != nil { + return err + } + + pkce, err := NewPKCEChallenge() + if err != nil { + return fmt.Errorf("generate pkce: %w", err) + } + + state, err := generateState() + if err != nil { + return fmt.Errorf("generate state: %w", err) + } + + redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, m.cfg.CallbackPath) + oauthCfg.RedirectURL = redirectURI + + authURL := oauthCfg.AuthCodeURL(state, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("code_challenge", pkce.Challenge), + oauth2.SetAuthURLParam("code_challenge_method", pkce.Method), + ) + + fmt.Println("Open this URL in your browser:") + fmt.Println(authURL) + fmt.Println() + fmt.Print("Paste the redirect URL or authorization code: ") + + var input string + if _, err := fmt.Scanln(&input); err != nil { + return fmt.Errorf("read input: %w", err) + } + + code := extractCode(input, state) + if code == "" { + return fmt.Errorf("could not extract authorization code from input") + } + + return m.exchangeAndStore(ctx, oauthCfg, code, pkce.Verifier) +} + +func (m *Manager) loadOAuthConfig() (*oauth2.Config, error) { + data, err := readFile(m.cfg.ClientCredentialsFile) + if err != nil { + return nil, fmt.Errorf("read credentials file %q: %w", m.cfg.ClientCredentialsFile, err) + } + cfg, err := google.ConfigFromJSON(data, m.cfg.Scopes...) + if err != nil { + return nil, fmt.Errorf("parse credentials: %w", err) + } + return cfg, nil +} + +func (m *Manager) exchangeAndStore(ctx context.Context, cfg *oauth2.Config, code, verifier string) error { + token, err := cfg.Exchange(ctx, code, + oauth2.SetAuthURLParam("code_verifier", verifier), + ) + if err != nil { + return fmt.Errorf("exchange code: %w", err) + } + + ts := &store.TokenSet{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + Scopes: m.cfg.Scopes, + } + + if err := m.tokenStore.Save(ts); err != nil { + return fmt.Errorf("save token: %w", err) + } + + m.log.Info("authentication successful") + fmt.Println("Authentication successful.") + return nil +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + return hex.EncodeToString(b), nil +} + +func extractCode(input, expectedState string) string { + if len(input) > 4 && input[:4] == "http" { + u, err := parseURL(input) + if err == nil { + q := u.Query() + if expectedState != "" && q.Get("state") != expectedState { + return "" + } + if code := q.Get("code"); code != "" { + return code + } + } + } + return input +} + +func openBrowser(url string) error { + var cmd string + var args []string + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{url} + case "windows": + cmd = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + default: + cmd = "xdg-open" + args = []string{url} + } + return exec.Command(cmd, args...).Start() +} diff --git a/internal/auth/helpers.go b/internal/auth/helpers.go new file mode 100644 index 0000000..10b5078 --- /dev/null +++ b/internal/auth/helpers.go @@ -0,0 +1,19 @@ +package auth + +import ( + "fmt" + "net/url" + "os" +) + +func readFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + return data, nil +} + +func parseURL(raw string) (*url.URL, error) { + return url.Parse(raw) +} diff --git a/internal/auth/pkce.go b/internal/auth/pkce.go new file mode 100644 index 0000000..13280c4 --- /dev/null +++ b/internal/auth/pkce.go @@ -0,0 +1,33 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +type PKCEChallenge struct { + Verifier string + Challenge string + Method string +} + +func NewPKCEChallenge() (*PKCEChallenge, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("generate pkce verifier: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(b) + challenge := computeChallenge(verifier) + return &PKCEChallenge{ + Verifier: verifier, + Challenge: challenge, + Method: "S256", + }, nil +} + +func computeChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/internal/auth/pkce_test.go b/internal/auth/pkce_test.go new file mode 100644 index 0000000..9624dfd --- /dev/null +++ b/internal/auth/pkce_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPKCEChallenge(t *testing.T) { + p, err := NewPKCEChallenge() + require.NoError(t, err) + assert.NotEmpty(t, p.Verifier) + assert.NotEmpty(t, p.Challenge) + assert.Equal(t, "S256", p.Method) + assert.NotEqual(t, p.Verifier, p.Challenge) +} + +func TestPKCEChallengeUniqueness(t *testing.T) { + p1, err := NewPKCEChallenge() + require.NoError(t, err) + p2, err := NewPKCEChallenge() + require.NoError(t, err) + assert.NotEqual(t, p1.Verifier, p2.Verifier) + assert.NotEqual(t, p1.Challenge, p2.Challenge) +} + +func TestComputeChallenge(t *testing.T) { + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + assert.Equal(t, expected, computeChallenge(verifier)) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..35a594c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,204 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/viper" +) + +type Config struct { + App AppConfig `mapstructure:"app"` + OAuth OAuthConfig `mapstructure:"oauth"` + Google GoogleConfig `mapstructure:"google"` + Server ServerConfig `mapstructure:"server"` + API APIConfig `mapstructure:"api"` + MCP MCPConfig `mapstructure:"mcp"` + Security SecurityConfig `mapstructure:"security"` + Output OutputConfig `mapstructure:"output"` +} + +type AppConfig struct { + Name string `mapstructure:"name"` + Env string `mapstructure:"env"` + LogLevel string `mapstructure:"log_level"` + DataDir string `mapstructure:"data_dir"` +} + +type OAuthConfig struct { + ClientCredentialsFile string `mapstructure:"client_credentials_file"` + TokenStoreFile string `mapstructure:"token_store_file"` + DefaultPort int `mapstructure:"default_port"` + OpenBrowser bool `mapstructure:"open_browser"` + ManualFallback bool `mapstructure:"manual_fallback"` + CallbackPath string `mapstructure:"callback_path"` +} + +type GoogleConfig struct { + Scopes []string `mapstructure:"scopes"` + DefaultCalendarID string `mapstructure:"default_calendar_id"` +} + +type ServerConfig struct { + Bind string `mapstructure:"bind"` + Port int `mapstructure:"port"` + ReadTimeout time.Duration `mapstructure:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout"` + ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` +} + +type APIConfig struct { + Enabled bool `mapstructure:"enabled"` + BasePath string `mapstructure:"base_path"` + Auth AuthCfg `mapstructure:"auth"` +} + +type AuthCfg struct { + OAuthBearerEnabled bool `mapstructure:"oauth_bearer_enabled"` + APIKeyEnabled bool `mapstructure:"api_key_enabled"` +} + +type MCPConfig struct { + Enabled bool `mapstructure:"enabled"` + Stdio bool `mapstructure:"stdio"` + HTTP bool `mapstructure:"http"` + HTTPPath string `mapstructure:"http_path"` + ProtocolVersion string `mapstructure:"protocol_version"` + SessionRequired bool `mapstructure:"session_required"` +} + +type SecurityConfig struct { + APIKeysFile string `mapstructure:"api_keys_file"` + RedactSecretsInLogs bool `mapstructure:"redact_secrets_in_logs"` + RequireTLSForRemote bool `mapstructure:"require_tls_for_remote_http"` +} + +type OutputConfig struct { + Format string `mapstructure:"format"` + Timezone string `mapstructure:"timezone"` +} + +func Load(cfgFile, profile string) (*Config, error) { + v := viper.New() + setDefaults(v) + + v.SetEnvPrefix("GOCALGOO") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + if cfgFile != "" { + v.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("get home dir: %w", err) + } + cfgName := "config" + if profile != "" { + cfgName = "config." + profile + } + v.SetConfigName(cfgName) + v.SetConfigType("yaml") + v.AddConfigPath(filepath.Join(home, ".config", "gocalgoo")) + v.AddConfigPath(".") + v.AddConfigPath("./configs") + } + + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("read config: %w", err) + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + cfg.OAuth.ClientCredentialsFile = expandHome(cfg.OAuth.ClientCredentialsFile) + cfg.OAuth.TokenStoreFile = expandHome(cfg.OAuth.TokenStoreFile) + cfg.Security.APIKeysFile = expandHome(cfg.Security.APIKeysFile) + cfg.App.DataDir = expandHome(cfg.App.DataDir) + + return &cfg, nil +} + +func Validate(cfg *Config) error { + if cfg.OAuth.ClientCredentialsFile == "" { + return fmt.Errorf("oauth.client_credentials_file is required") + } + if cfg.OAuth.TokenStoreFile == "" { + return fmt.Errorf("oauth.token_store_file is required") + } + if len(cfg.Google.Scopes) == 0 { + return fmt.Errorf("google.scopes must not be empty") + } + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + return fmt.Errorf("server.port must be 1-65535") + } + return nil +} + +func setDefaults(v *viper.Viper) { + home, _ := os.UserHomeDir() + + v.SetDefault("app.name", "GoCalGoo") + v.SetDefault("app.env", "development") + v.SetDefault("app.log_level", "info") + v.SetDefault("app.data_dir", filepath.Join(home, ".local", "share", "gocalgoo")) + + v.SetDefault("oauth.client_credentials_file", filepath.Join(home, ".config", "gocalgoo", "credentials.json")) + v.SetDefault("oauth.token_store_file", filepath.Join(home, ".config", "gocalgoo", "tokens.json")) + v.SetDefault("oauth.default_port", 53682) + v.SetDefault("oauth.open_browser", true) + v.SetDefault("oauth.manual_fallback", true) + v.SetDefault("oauth.callback_path", "/oauth/callback") + + v.SetDefault("google.scopes", []string{ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/contacts", + }) + v.SetDefault("google.default_calendar_id", "primary") + + v.SetDefault("server.bind", "127.0.0.1") + v.SetDefault("server.port", 8080) + v.SetDefault("server.read_timeout", "15s") + v.SetDefault("server.write_timeout", "30s") + v.SetDefault("server.shutdown_timeout", "10s") + + v.SetDefault("api.enabled", true) + v.SetDefault("api.base_path", "/api/v1") + v.SetDefault("api.auth.oauth_bearer_enabled", true) + v.SetDefault("api.auth.api_key_enabled", true) + + v.SetDefault("mcp.enabled", true) + v.SetDefault("mcp.stdio", true) + v.SetDefault("mcp.http", true) + v.SetDefault("mcp.http_path", "/mcp") + v.SetDefault("mcp.protocol_version", "2025-06-18") + v.SetDefault("mcp.session_required", true) + + v.SetDefault("security.api_keys_file", filepath.Join(home, ".config", "gocalgoo", "api-keys.yaml")) + v.SetDefault("security.redact_secrets_in_logs", true) + v.SetDefault("security.require_tls_for_remote_http", false) + + v.SetDefault("output.format", "table") + v.SetDefault("output.timezone", "UTC") +} + +func expandHome(path string) string { + if path == "" { + return path + } + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[2:]) + } + return path +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ec818da --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,73 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load("", "") + require.NoError(t, err) + assert.Equal(t, "GoCalGoo", cfg.App.Name) + assert.Equal(t, 53682, cfg.OAuth.DefaultPort) + assert.Equal(t, "primary", cfg.Google.DefaultCalendarID) + assert.Equal(t, 8080, cfg.Server.Port) + assert.NotEmpty(t, cfg.Google.Scopes) +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr bool + }{ + { + name: "valid config", + mutate: func(c *Config) {}, + wantErr: false, + }, + { + name: "missing credentials file", + mutate: func(c *Config) { + c.OAuth.ClientCredentialsFile = "" + }, + wantErr: true, + }, + { + name: "missing token store", + mutate: func(c *Config) { + c.OAuth.TokenStoreFile = "" + }, + wantErr: true, + }, + { + name: "empty scopes", + mutate: func(c *Config) { + c.Google.Scopes = nil + }, + wantErr: true, + }, + { + name: "invalid port", + mutate: func(c *Config) { + c.Server.Port = 0 + }, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg, err := Load("", "") + require.NoError(t, err) + tc.mutate(cfg) + err = Validate(cfg) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..825840a --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,63 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" + "text/tabwriter" +) + +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" +) + +type Printer struct { + format Format + out io.Writer +} + +func NewPrinter(format Format) *Printer { + return &Printer{format: format, out: os.Stdout} +} + +func (p *Printer) PrintJSON(v any) error { + enc := json.NewEncoder(p.out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func (p *Printer) PrintTable(headers []string, rows [][]string) { + w := tabwriter.NewWriter(p.out, 0, 0, 2, ' ', 0) + defer w.Flush() + + for i, h := range headers { + if i > 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, h) + } + fmt.Fprintln(w) + + for _, row := range rows { + for i, cell := range row { + if i > 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, cell) + } + fmt.Fprintln(w) + } +} + +func (p *Printer) PrintLine(s string) { + fmt.Fprintln(p.out, s) +} + +func PrintError(err error) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) +} diff --git a/internal/store/tokenstore.go b/internal/store/tokenstore.go new file mode 100644 index 0000000..9a63d55 --- /dev/null +++ b/internal/store/tokenstore.go @@ -0,0 +1,78 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type TokenSet struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + Expiry time.Time `json:"expiry"` + Scopes []string `json:"scopes,omitempty"` + Account string `json:"account,omitempty"` +} + +func (t *TokenSet) IsExpired() bool { + if t.Expiry.IsZero() { + return false + } + return time.Now().After(t.Expiry) +} + +type TokenStore struct { + path string +} + +func NewTokenStore(path string) *TokenStore { + return &TokenStore{path: path} +} + +func (s *TokenStore) Save(token *TokenSet) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { + return fmt.Errorf("create token dir: %w", err) + } + data, err := json.MarshalIndent(token, "", " ") + if err != nil { + return fmt.Errorf("marshal token: %w", err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return fmt.Errorf("write token file: %w", err) + } + if err := os.Rename(tmp, s.path); err != nil { + return fmt.Errorf("rename token file: %w", err) + } + return nil +} + +func (s *TokenStore) Load() (*TokenSet, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read token file: %w", err) + } + var token TokenSet + if err := json.Unmarshal(data, &token); err != nil { + return nil, fmt.Errorf("unmarshal token: %w", err) + } + return &token, nil +} + +func (s *TokenStore) Delete() error { + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete token file: %w", err) + } + return nil +} + +func (s *TokenStore) Exists() bool { + _, err := os.Stat(s.path) + return err == nil +} diff --git a/internal/store/tokenstore_test.go b/internal/store/tokenstore_test.go new file mode 100644 index 0000000..f7915b8 --- /dev/null +++ b/internal/store/tokenstore_test.go @@ -0,0 +1,78 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenStoreSaveLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tokens.json") + s := NewTokenStore(path) + + token := &TokenSet{ + AccessToken: "access-abc", + RefreshToken: "refresh-xyz", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour).Truncate(time.Second), + Scopes: []string{"https://www.googleapis.com/auth/calendar"}, + Account: "test@example.com", + } + + require.NoError(t, s.Save(token)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) + + loaded, err := s.Load() + require.NoError(t, err) + require.NotNil(t, loaded) + assert.Equal(t, token.AccessToken, loaded.AccessToken) + assert.Equal(t, token.RefreshToken, loaded.RefreshToken) + assert.Equal(t, token.Account, loaded.Account) +} + +func TestTokenStoreLoadMissing(t *testing.T) { + dir := t.TempDir() + s := NewTokenStore(filepath.Join(dir, "noexist.json")) + + token, err := s.Load() + assert.NoError(t, err) + assert.Nil(t, token) +} + +func TestTokenStoreDelete(t *testing.T) { + dir := t.TempDir() + s := NewTokenStore(filepath.Join(dir, "tokens.json")) + + token := &TokenSet{AccessToken: "abc"} + require.NoError(t, s.Save(token)) + assert.True(t, s.Exists()) + + require.NoError(t, s.Delete()) + assert.False(t, s.Exists()) + assert.NoError(t, s.Delete()) +} + +func TestTokenIsExpired(t *testing.T) { + tests := []struct { + name string + token TokenSet + expired bool + }{ + {"no expiry", TokenSet{}, false}, + {"future expiry", TokenSet{Expiry: time.Now().Add(time.Hour)}, false}, + {"past expiry", TokenSet{Expiry: time.Now().Add(-time.Hour)}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expired, tc.token.IsExpired()) + }) + } +} diff --git a/llm/STATUS.md b/llm/STATUS.md index dfcfa1d..33ce5b5 100644 --- a/llm/STATUS.md +++ b/llm/STATUS.md @@ -12,13 +12,13 @@ | Task | Status | Notes | |------|--------|-------| -| Repo scaffold (`go.mod`, `Makefile`, `.gitignore`) | 🔄 In progress | Agent running | -| `internal/config/` — layered config with viper | 🔄 In progress | | -| `internal/store/` — token store (JSON, 0600) | 🔄 In progress | | -| `internal/auth/` — OAuth2 + PKCE | 🔄 In progress | Fixed-port, random-port, manual modes | -| `cmd/gocalgoo/` — cobra root + auth commands | 🔄 In progress | | -| `gocalgoo config validate` command | 🔄 In progress | | -| `configs/config.yaml` example | 🔄 In progress | | +| Repo scaffold (`go.mod`, `Makefile`, `.gitignore`) | ✅ Done | Agent running | +| `internal/config/` — layered config with viper | ✅ Done | | +| `internal/store/` — token store (JSON, 0600) | ✅ Done | | +| `internal/auth/` — OAuth2 + PKCE | ✅ Done | Fixed-port, random-port, manual modes | +| `cmd/gocalgoo/` — cobra root + auth commands | ✅ Done | | +| `gocalgoo config validate` command | ✅ Done | | +| `configs/config.yaml` example | ✅ Done | | | `go build ./...` passes | ⏳ Pending | | | Committed and pushed to Gitea | ⏳ Pending | | @@ -29,7 +29,7 @@ ### Phase 1 — Foundation - **Goal:** Repo scaffold, config system, logging, token store, OAuth2 CLI - **Started:** 2026-04-01 ~20:55 SAST -- **Completed:** — +- **Completed:** 2026-04-01 ---