diff --git a/CLAUDE.md b/CLAUDE.md index 9d00d3e..04f098c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ go/ ├── cmd/ │ ├── root.go # Root command + global flags + --version │ ├── register.go # Blank-imports all product packages (triggers init()) +│ ├── agentbase/ # grn agentbase (gated: -tags agentbase) +│ │ ├── agentbase.go # AgentbaseCmd subcommand root (self-registers) +│ │ ├── identity.go # identity group (login/workload/outbound-auth) +│ │ ├── context.go # context group (switch/current/headers/decorators) +│ │ └── helpers.go # mustLoadConfig / newAuthProvider │ ├── configure/ │ │ ├── configure.go # Interactive setup │ │ ├── list.go # grn configure list @@ -66,6 +71,14 @@ go/ │ ├── resources/ │ │ └── vserver/ │ │ └── vserver.go # vserver resource completers (vpc/subnet/ssh-key/security-group/disk-type) +│ ├── agentbase/ # self-contained agentbase stack (own auth/config/client) +│ │ ├── auth/ # OAuth2 v2 clientcredentials +│ │ ├── client/ # bearer-token HTTP client +│ │ ├── config/ # ./.greennode.json loader +│ │ ├── identity/ # identity API client + models +│ │ ├── cliinput/ # interactive prompts +│ │ ├── jsonslice/ # typed JSON slice helper +│ │ └── output/ # table/json/id + color + banner │ └── validator/validator.go # ID format validation ├── go.mod, go.sum ``` @@ -114,6 +127,10 @@ VKS wires its flags centrally in `cmd/vks/completion.go` `registerCompletions()` 5. Add `_endpoint` for each region in `internal/config/config.go` REGIONS 6. root.go needs no change — it mounts everything in the registry +Note: `cmd/agentbase` is gated behind the opt-in `agentbase` build tag +(`cmd/register_agentbase.go`), the inverse of the `!vks_only` pattern — it is +excluded from default and release builds while still in development. + ## Security rules - **Credential masking**: `configure list` and `configure get` mask client_id/client_secret (last 4 chars only) @@ -129,6 +146,9 @@ VKS wires its flags centrally in `cmd/vks/completion.go` `registerCompletions()` cd go CGO_ENABLED=0 go build -o grn . +# Build WITH the agentbase subcommand (dev/internal only; excluded from release): +CGO_ENABLED=0 go build -tags agentbase -o grn . + # Cross-compile GOOS=linux GOARCH=amd64 go build -o grn-linux-amd64 . GOOS=darwin GOARCH=arm64 go build -o grn-darwin-arm64 . diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 06a087f..a40f906 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -79,6 +79,22 @@ A new product (e.g. `vserver`) is mounted without touching `root.go`: `root.go` iterates `cli.Services()` and never needs editing per service. +## Gated subcommand: `agentbase` + +`cmd/agentbase/` is a self-contained service stack that is **gated OFF** in the +default and release builds: it only compiles when you pass `-tags agentbase` +(see `cmd/register_agentbase.go`). This is the inverse of the `!vks_only` +pattern — agentbase is *included* only by an opt-in tag, so it stays out of +public binaries while still in development. Unlike `vks`/`vserver`, it does +not reuse the shared `internal/cli` infrastructure: it ships its own v2 +OAuth2 client-credentials auth (`internal/agentbase/auth/`), its own +`./.greennode.json` config loader (`internal/agentbase/config/`), and its own +HTTP client + output helpers. `AgentbaseCmd` self-registers via +`cli.RegisterService(...)` in `init()`, so once the tag flips to default-on +it will appear under `grn` with no further wiring. Public command-reference +pages (`docs/commands/agentbase/*`) and `mkdocs.yml` nav entries are +intentionally omitted until that flip. + ## Writing a command Follow the existing `cmd/vks/*.go` files. Each command: diff --git a/go/cmd/agentbase/agentbase.go b/go/cmd/agentbase/agentbase.go new file mode 100644 index 0000000..fd97fe0 --- /dev/null +++ b/go/cmd/agentbase/agentbase.go @@ -0,0 +1,86 @@ +// Package agentbase implements the `grn agentbase` subcommand group for the +// GreenNode AgentBase platform. +// +// It is intentionally self-contained: it carries its own OAuth2 (v2 +// client-credentials) auth, its own ./.greennode.json config, and its own HTTP +// client. It does NOT share state with grn's core (the v1 TokenManager, the INI +// config in ~/.greennode, or the retry/refresh HTTP client). The two stacks are +// fully independent. +// +// Compiled in ONLY with `-tags agentbase`. The default grn binary and the +// public release build (`-tags vks_only`) both exclude it while agentbase is +// still under development. +package agentbase + +import ( + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/vngcloud/greennode-cli/internal/agentbase/cliinput" + "github.com/vngcloud/greennode-cli/internal/agentbase/output" + "github.com/vngcloud/greennode-cli/internal/cli" +) + +// Persistent-flag targets for the `grn agentbase` subtree. The --output flag +// shadows grn's inherited root --output for this subtree only (cobra lets a +// child flag shadow an inherited persistent flag); the other grn root flags are +// inherited but inert — agentbase never reads them. +var ( + interactiveMode bool + envOverride string + outputFormat string +) + +const greennodeASCIIArt = ` + _____ _____ ______ ______ _ _ _ _ ____ _____ ______ + / ____| __ \| ____| ____| \ | | \ | |/ __ \| __ \| ____| + | | __| |__) | |__ | |__ | \| | \| | | | | | | | |__ + | | |_ | _ /| __| | __| | . ` + "`" + ` | . ` + "`" + ` | | | | | | | __| + | |__| | | \ \| |____| |____| |\ | |\ | |__| | |__| | |____ + \_____|_| \_\______|______|_| \_|_| \_|\____/|_____/|______| AGENTBASE +` + +func printBanner() { + color.New(color.FgGreen, color.Bold).Fprint(os.Stderr, greennodeASCIIArt) +} + +// skipBannerCommands suppresses the ASCII banner for non-product commands. +var skipBannerCommands = map[string]bool{ + "help": true, + "completion": true, +} + +// AgentbaseCmd is the `grn agentbase` subcommand. Its init() self-registers it +// with grn's service registry (cli.RegisterService), mirroring cmd/vks—so +// mounting requires no edit to root.go or main.go, only a build-tagged blank +// import in cmd/register_agentbase.go. +var AgentbaseCmd = &cobra.Command{ + Use: "agentbase", + Short: "GreenNode AgentBase platform", + SilenceUsage: true, + SilenceErrors: true, + Long: `Manage the GreenNode AgentBase platform: agent identities and outbound +authentication providers (Phase 1). Runtime, memory, and deploy commands arrive +in later phases. + +Configuration is read from ./.greennode.json in the current working directory +(separate from grn's ~/.greennode profile config). Run 'grn agentbase context +current' to see the active environment and endpoints.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + output.SetFormat(output.ParseFormat(outputFormat)) + if !skipBannerCommands[cmd.Name()] && output.GetFormat() == output.FormatTable { + printBanner() + } + cliinput.SetInteractive(interactiveMode) + }, +} + +func init() { + AgentbaseCmd.PersistentFlags().BoolVarP(&interactiveMode, "interactive", "i", false, "Prompt for missing inputs instead of requiring flags") + AgentbaseCmd.PersistentFlags().StringVar(&envOverride, "env", "", `Target environment: "dev" or "prod" (overrides GREENNODE_ENV and ./.greennode.json)`) + AgentbaseCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", `Output format: "table", "json", or "id"`) + + cli.RegisterService(AgentbaseCmd) +} diff --git a/go/cmd/agentbase/agentbase_test.go b/go/cmd/agentbase/agentbase_test.go new file mode 100644 index 0000000..98ecc1e --- /dev/null +++ b/go/cmd/agentbase/agentbase_test.go @@ -0,0 +1,71 @@ +package agentbase + +import ( + "testing" + + "github.com/vngcloud/greennode-cli/internal/agentbase/jsonslice" +) + +// TestAgentbaseCmd_HasContextSubtree verifies the scaffold mounted the `context` +// group under `grn agentbase` with its expected children. No network, no creds. +func TestAgentbaseCmd_HasContextSubtree(t *testing.T) { + contextCmd, _, err := AgentbaseCmd.Find([]string{"context"}) + if err != nil { + t.Fatalf("agentbase has no 'context' subcommand: %v", err) + } + for _, want := range []string{"switch", "current", "headers", "decorators"} { + if _, _, err := contextCmd.Find([]string{want}); err != nil { + t.Errorf("context missing subcommand %q: %v", want, err) + } + } +} + +// TestAgentbaseCmd_PersistentFlags verifies the agentbase-specific persistent +// flags (and that -i/-o shorthands exist; --output shadows grn's root flag). +func TestAgentbaseCmd_PersistentFlags(t *testing.T) { + for _, flag := range []string{"interactive", "env", "output"} { + if AgentbaseCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("agentbase missing persistent flag %q", flag) + } + } + if AgentbaseCmd.PersistentFlags().ShorthandLookup("i") == nil { + t.Error("agentbase missing -i shorthand for --interactive") + } + if AgentbaseCmd.PersistentFlags().ShorthandLookup("o") == nil { + t.Error("agentbase missing -o shorthand for --output") + } +} + +// TestAgentbaseCmd_HasIdentitySubtree verifies the identity group and its +// workload CRUD subtree mounted under `grn agentbase`. +func TestAgentbaseCmd_HasIdentitySubtree(t *testing.T) { + identityCmd, _, err := AgentbaseCmd.Find([]string{"identity"}) + if err != nil { + t.Fatalf("agentbase has no 'identity' subcommand: %v", err) + } + for _, want := range []string{"login", "logout", "whoami", "workload", "outbound-auth"} { + if _, _, err := identityCmd.Find([]string{want}); err != nil { + t.Errorf("identity missing subcommand %q: %v", want, err) + } + } + workloadCmd, _, err := identityCmd.Find([]string{"workload"}) + if err != nil { + t.Fatalf("identity has no 'workload' subcommand: %v", err) + } + for _, want := range []string{"create", "list", "get", "update", "use", "delete"} { + if _, _, err := workloadCmd.Find([]string{want}); err != nil { + t.Errorf("workload missing subcommand %q: %v", want, err) + } + } +} + +// TestJoinStrings_jsonsliceArray ports the agentbase helper test for joinStrings +// (defined in identity.go). +func TestJoinStrings_jsonsliceArray(t *testing.T) { + if got := joinStrings(jsonslice.Array[string]{"a", "b"}, ", "); got != "a, b" { + t.Errorf("got %q", got) + } + if got := joinStrings(jsonslice.Array[string]{}, "|"); got != "" { + t.Errorf("empty: got %q", got) + } +} diff --git a/go/cmd/agentbase/context.go b/go/cmd/agentbase/context.go new file mode 100644 index 0000000..48fbc7a --- /dev/null +++ b/go/cmd/agentbase/context.go @@ -0,0 +1,128 @@ +package agentbase + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/vngcloud/greennode-cli/internal/agentbase/config" + "github.com/vngcloud/greennode-cli/internal/agentbase/output" +) + +var contextCmd = &cobra.Command{ + Use: "context", + Short: "Manage the active environment context", + Long: `Manage the active environment context (dev or prod). + +The environment controls which API endpoints are used for all commands. +Resolution order: GREENNODE_ENV env var → ./.greennode.json → default (prod)`, +} + +var contextSwitchCmd = &cobra.Command{ + Use: "switch ", + Short: "Switch the active environment", + Long: `Switch the active environment context to 'dev' or 'prod'. + +This writes the 'env' field to ./.greennode.json. +To override without modifying the config file, set GREENNODE_ENV instead.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + env := config.Env(args[0]) + if err := config.SetEnv(env); err != nil { + return err + } + fmt.Fprintf(os.Stdout, "Switched to environment: %s\n", env) + return nil + }, +} + +var contextCurrentCmd = &cobra.Command{ + Use: "current", + Short: "Show the active environment and resolved endpoints", + Long: `Display the currently active environment and all resolved API base URLs.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadWithEnv(envOverride) + if err != nil { + return err + } + + source := "config file (./.greennode.json)" + switch { + case envOverride != "": + source = "--env flag" + case os.Getenv("GREENNODE_ENV") != "": + source = "environment variable (GREENNODE_ENV)" + } + + fmt.Fprintf(os.Stdout, "Environment : %s\n", cfg.Env) + fmt.Fprintf(os.Stdout, "Source : %s\n\n", source) + + output.Table( + []string{"Service", "Base URL"}, + [][]string{ + {"Identity", cfg.Endpoints.Identity}, + {"Runtime", cfg.Endpoints.Runtime}, + {"Memory", cfg.Endpoints.Memory}, + {"OAuth2 Token", cfg.Endpoints.OAuth2Token}, + }, + ) + return nil + }, +} + +var contextHeadersCmd = &cobra.Command{ + Use: "headers", + Short: "Show platform request headers reference", + Long: `Display the standard X-GreenNode-AgentBase-* HTTP request headers used by the platform.`, + Run: func(cmd *cobra.Command, args []string) { + output.Table( + []string{"Header", "Description"}, + [][]string{ + {"X-GreenNode-AgentBase-Session-Id", "Unique session identifier for conversation continuity"}, + {"X-GreenNode-AgentBase-Request-Id", "Unique request identifier for tracing"}, + {"X-GreenNode-AgentBase-Access-Token", "User access token for 3LO OAuth2 flows"}, + {"X-GreenNode-AgentBase-User-Id", "User identifier forwarded to the agent"}, + {"X-GreenNode-AgentBase-OAuth2-Callback-Url", "Callback URL for OAuth2 redirect flows"}, + {"Authorization", "Bearer token (client credentials OAuth2 token)"}, + {"X-GreenNode-AgentBase-Custom-*", "Arbitrary custom headers forwarded to the agent"}, + }, + ) + }, +} + +var contextDecoratorsCmd = &cobra.Command{ + Use: "decorators", + Short: "Show SDK decorator reference", + Long: `Display the GreenNode AgentBase SDK decorators and their purpose.`, + Run: func(cmd *cobra.Command, args []string) { + output.Table( + []string{"Decorator", "Module", "Description"}, + [][]string{ + { + "@entrypoint", + "GreenNodeAgentBaseApp", + "Registers the main handler. Extracts AgentBase context from incoming request headers.", + }, + { + "@requires_api_key", + "identity", + "Fetches a static or delegated API key before the handler is invoked.", + }, + { + "@requires_access_token", + "identity", + "Fetches an M2M (client credentials) or 3LO OAuth2 token before the handler is invoked.", + }, + }, + ) + }, +} + +func init() { + AgentbaseCmd.AddCommand(contextCmd) + contextCmd.AddCommand(contextSwitchCmd) + contextCmd.AddCommand(contextCurrentCmd) + contextCmd.AddCommand(contextHeadersCmd) + contextCmd.AddCommand(contextDecoratorsCmd) +} diff --git a/go/cmd/agentbase/helpers.go b/go/cmd/agentbase/helpers.go new file mode 100644 index 0000000..a83218c --- /dev/null +++ b/go/cmd/agentbase/helpers.go @@ -0,0 +1,36 @@ +package agentbase + +import ( + "fmt" + "os" + + "github.com/vngcloud/greennode-cli/internal/agentbase/auth" + "github.com/vngcloud/greennode-cli/internal/agentbase/config" +) + +// mustLoadConfig loads config, applying the --env flag override when set, and +// exits on any error. Commands use this (not config.Load() directly) so the +// persistent --env flag is respected. +func mustLoadConfig() *config.Config { + cfg, err := config.LoadWithEnv(envOverride) + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + return cfg +} + +// mustLoadConfigWithCreds loads config and ensures credentials are present. +func mustLoadConfigWithCreds() *config.Config { + cfg := mustLoadConfig() + if err := cfg.RequireCredentials(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + return cfg +} + +// newAuthProvider builds an auth provider from the loaded config. +func newAuthProvider(cfg *config.Config) *auth.Provider { + return auth.NewProvider(cfg.ClientID, cfg.ClientSecret, cfg.Endpoints.OAuth2Token) +} diff --git a/go/cmd/agentbase/identity.go b/go/cmd/agentbase/identity.go new file mode 100644 index 0000000..38a39b2 --- /dev/null +++ b/go/cmd/agentbase/identity.go @@ -0,0 +1,1285 @@ +package agentbase + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/vngcloud/greennode-cli/internal/agentbase/auth" + "github.com/vngcloud/greennode-cli/internal/agentbase/cliinput" + "github.com/vngcloud/greennode-cli/internal/agentbase/config" + identitypkg "github.com/vngcloud/greennode-cli/internal/agentbase/identity" + "github.com/vngcloud/greennode-cli/internal/agentbase/jsonslice" + "github.com/vngcloud/greennode-cli/internal/agentbase/output" +) + +// --- root identity command --- + +var identityCmd = &cobra.Command{ + Use: "identity", + Short: "Manage authentication and agent identities", + Long: `Login, logout, manage agent identities, and configure outbound authentication providers.`, +} + +// --- identity login --- + +var identityLoginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate with the GreenNode platform", + Long: `Authenticate with the GreenNode AgentBase platform using OAuth2 client credentials. + +Your Client ID and Client Secret are saved to ./.greennode.json. + +Credentials can be supplied via flags, environment variables (GREENNODE_CLIENT_ID / +GREENNODE_CLIENT_SECRET), or interactively with --interactive.`, + RunE: func(cmd *cobra.Command, args []string) error { + clientID, _ := cmd.Flags().GetString("client-id") + if clientID == "" { + clientID = os.Getenv("GREENNODE_CLIENT_ID") + } + secret, _ := cmd.Flags().GetString("client-secret") + if secret == "" { + secret = os.Getenv("GREENNODE_CLIENT_SECRET") + } + + var err error + clientID, err = cliinput.RequireOrPromptString(clientID, "--client-id", "Client ID") + if err != nil { + return err + } + secret, err = cliinput.RequireOrPromptSecret(secret, "--client-secret", "Client Secret") + if err != nil { + return err + } + + // Validate credentials by fetching a token. + cfg, _ := config.Load() + tokenURL := cfg.Endpoints.OAuth2Token + provider := auth.NewProvider(clientID, secret, tokenURL) + if _, err := provider.AccessToken(context.Background()); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + if err := config.SaveCredentials(clientID, secret); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + output.Success("Logged in successfully") + return nil + }, +} + +// --- identity logout --- + +var identityLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Clear stored credentials", + Long: `Remove your Client ID and Client Secret from ./.greennode.json. + +If you set them via GREENNODE_CLIENT_ID / GREENNODE_CLIENT_SECRET environment variables, +you must unset those separately.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.ClearCredentials(); err != nil { + return err + } + output.Success("Logged out. Credentials cleared from ./.greennode.json") + return nil + }, +} + +// --- identity whoami --- + +var identityWhoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "Show the currently active credentials", + Long: `Display the current environment, client ID, and agent identity.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := mustLoadConfig() + output.Table([]string{"Field", "Value"}, [][]string{ + {"Environment", string(cfg.Env)}, + {"Client ID", output.StrOrDash(cfg.ClientID)}, + {"Agent Identity", output.StrOrDash(cfg.AgentIdentity)}, + }) + return nil + }, +} + +// --- identity config --- + +var identityConfigCmd = &cobra.Command{ + Use: "config", + Short: "Show identity configuration", + Long: `Display the current identity configuration including endpoints and credentials.`, +} + +var identityConfigShowCmd = &cobra.Command{ + Use: "show", + Short: "Display the current configuration", + Long: `Display the current identity configuration including environment, credentials, and endpoint URLs.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := mustLoadConfig() + secret := "-" + if cfg.ClientSecret != "" { + secret = "***" + cfg.ClientSecret[max(0, len(cfg.ClientSecret)-4):] + } + output.Table([]string{"Key", "Value"}, [][]string{ + {"environment", string(cfg.Env)}, + {"client_id", output.StrOrDash(cfg.ClientID)}, + {"client_secret", secret}, + {"agent_identity", output.StrOrDash(cfg.AgentIdentity)}, + {"identity_url", cfg.Endpoints.Identity}, + {"runtime_url", cfg.Endpoints.Runtime}, + {"memory_url", cfg.Endpoints.Memory}, + {"oauth2_token_url", cfg.Endpoints.OAuth2Token}, + }) + return nil + }, +} + +// --- identity workload --- + +var workloadCmd = &cobra.Command{ + Use: "workload", + Short: "Manage agent workload identities", + Long: `Create, list, get, update, and delete agent workload identities used to represent digital identities for agents accessing external services.`, +} + +var ( + workloadCreateName string + workloadCreateSetCurrent bool +) + +var workloadCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new agent identity", + Long: `Create a new agent identity for the authenticated user. + +Agent identities are used to represent digital identities for agents accessing +external services. The name must be 3-50 characters and match the pattern +^[a-zA-Z0-9_-]+$.`, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + workloadCreateName, err = cliinput.RequireOrPromptString(workloadCreateName, "--name", "Agent identity name") + if err != nil { + return err + } + + desc, _ := cmd.Flags().GetString("description") + urls, _ := cmd.Flags().GetStringArray("allowed-return-url") + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + req := &identitypkg.CreateAgentIdentityRequest{Name: workloadCreateName} + if cmd.Flags().Changed("description") { + req.Description = &desc + } + if cmd.Flags().Changed("allowed-return-url") { + req.AllowedReturnURLs = jsonslice.Array[string](urls) + } + identity, err := client.CreateAgentIdentity(ctx, req) + if err != nil { + return err + } + if workloadCreateSetCurrent { + if err := config.SaveAgentIdentity(str(identity.Name)); err != nil { + output.Warn("Identity created but failed to save as current: " + err.Error()) + } + } + return output.PrintResource(identity, func() string { return str(identity.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(identity.ID))}, + {"Name", output.StrOrDash(str(identity.Name))}, + {"Description", output.StrOrDash(str(identity.Description))}, + {"Created", formatTime(identity.CreatedAt)}, + {"Updated", formatTime(identity.UpdatedAt)}, + }) + fmt.Fprintln(os.Stdout, "\nAllowed Return URLs:") + if len(identity.AllowedReturnURLs) == 0 { + fmt.Fprintln(os.Stdout, " (none)") + } else { + rows := make([][]string, len(identity.AllowedReturnURLs)) + for i, u := range identity.AllowedReturnURLs { + rows[i] = []string{u} + } + output.Table([]string{"URL"}, rows) + } + return nil + }) + }, +} + +var workloadListCmd = &cobra.Command{ + Use: "list", + Short: "List agent identities", + Long: `Retrieve a paginated list of all agent identities owned by the authenticated user.`, + RunE: func(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + size, _ := cmd.Flags().GetInt("size") + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.ListAgentIdentities(ctx, page-1, size) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + if len(resp.Content) == 0 { + fmt.Fprintln(cmd.ErrOrStderr(), "No items found.") + return nil + } + rows := make([][]string, len(resp.Content)) + for i, id := range resp.Content { + rows[i] = []string{str(id.ID), str(id.Name), output.StrOrDash(str(id.Description))} + } + output.Table([]string{"ID", "Name", "Description"}, rows) + if resp.Page != nil && resp.TotalPages != nil && resp.TotalElements != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Page %d of %d (%d total items)\n", *resp.Page+1, *resp.TotalPages, *resp.TotalElements) + } + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + if len(resp.Content) > 0 { + output.PrintID(str(resp.Content[0].ID)) + } + } + return nil + }, +} + +var workloadGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get an agent identity by name", + Long: `Retrieve a specific agent identity by its name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + id, err := client.GetAgentIdentity(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(id, func() string { return str(id.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(id.ID))}, + {"Name", output.StrOrDash(str(id.Name))}, + {"Description", output.StrOrDash(str(id.Description))}, + {"Created", formatTime(id.CreatedAt)}, + {"Updated", formatTime(id.UpdatedAt)}, + }) + fmt.Fprintln(os.Stdout, "\nAllowed Return URLs:") + if len(id.AllowedReturnURLs) == 0 { + fmt.Fprintln(os.Stdout, " (none)") + } else { + rows := make([][]string, len(id.AllowedReturnURLs)) + for i, u := range id.AllowedReturnURLs { + rows[i] = []string{u} + } + output.Table([]string{"URL"}, rows) + } + return nil + }) + }, +} + +var workloadUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an agent identity", + Long: `Update an existing agent identity. Only description and allowed return URLs can be modified.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + desc, _ := cmd.Flags().GetString("description") + urls, _ := cmd.Flags().GetStringArray("allowed-return-url") + req := &identitypkg.UpdateAgentIdentityRequest{} + if cmd.Flags().Changed("description") { + req.Description = &desc + } + if cmd.Flags().Changed("allowed-return-url") { + req.AllowedReturnURLs = jsonslice.Array[string](urls) + } + id, err := client.UpdateAgentIdentity(ctx, args[0], req) + if err != nil { + return err + } + return output.PrintResource(id, func() string { return str(id.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(id.ID))}, + {"Name", output.StrOrDash(str(id.Name))}, + {"Description", output.StrOrDash(str(id.Description))}, + {"Created", formatTime(id.CreatedAt)}, + {"Updated", formatTime(id.UpdatedAt)}, + }) + fmt.Fprintln(os.Stdout, "\nAllowed Return URLs:") + if len(id.AllowedReturnURLs) == 0 { + fmt.Fprintln(os.Stdout, " (none)") + } else { + rows := make([][]string, len(id.AllowedReturnURLs)) + for i, u := range id.AllowedReturnURLs { + rows[i] = []string{u} + } + output.Table([]string{"URL"}, rows) + } + return nil + }) + }, +} + +var workloadUseCmd = &cobra.Command{ + Use: "use ", + Short: "Set the current agent identity", + Long: `Set the given agent identity name as the current identity in ./.greennode.json.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.SaveAgentIdentity(args[0]); err != nil { + return err + } + output.Successf("Current agent identity set to: %s", args[0]) + return nil + }, +} + +var workloadDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an agent identity", + Long: `Delete an agent identity by name. The identity will be soft-deleted.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + if err := client.DeleteAgentIdentity(ctx, args[0]); err != nil { + return err + } + return output.PrintDeletedID(args[0]) + }, +} + +// --- outbound-auth --- + +var outboundAuthCmd = &cobra.Command{ + Use: "outbound-auth", + Short: "Manage outbound authentication providers", + Long: `Manage static API key, delegated API key, and OAuth2 providers for agent outbound authentication.`, +} + +// --- outbound-auth static --- + +var staticCmd = &cobra.Command{ + Use: "static", + Short: "Manage static API key providers", + Long: `Create, list, get, update, and delete static API key providers for outbound authentication.`, +} + +var staticCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a static API key provider", + Long: `Create a new static API key provider. The name must be 3-50 characters and match +the pattern ^[a-zA-Z0-9_-]+$. Both name and API key are required.`, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + var err error + name, err = cliinput.RequireOrPromptString(name, "--name", "Provider name") + if err != nil { + return err + } + + apikey, _ := cmd.Flags().GetString("apikey") + apikey, err = cliinput.RequireOrPromptSecret(apikey, "--apikey", "API Key") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.CreateApikeyProvider(ctx, &identitypkg.CreateApikeyProviderRequest{Name: name, Apikey: apikey}) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var staticListCmd = &cobra.Command{ + Use: "list", + Short: "List static API key providers", + Long: `Retrieve a paginated list of static API key providers.`, + RunE: func(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + size, _ := cmd.Flags().GetInt("size") + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.ListApikeyProviders(ctx, page-1, size) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + if len(resp.Content) == 0 { + fmt.Fprintln(cmd.ErrOrStderr(), "No items found.") + return nil + } + rows := make([][]string, len(resp.Content)) + for i, p := range resp.Content { + rows[i] = []string{str(p.ID), str(p.Name), str(p.Status)} + } + output.Table([]string{"ID", "Name", "Status"}, rows) + if resp.Page != nil && resp.TotalPages != nil && resp.TotalElements != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Page %d of %d (%d total items)\n", *resp.Page+1, *resp.TotalPages, *resp.TotalElements) + } + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + if len(resp.Content) > 0 { + output.PrintID(str(resp.Content[0].ID)) + } + } + return nil + }, +} + +var staticGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a static API key provider", + Long: `Retrieve a static API key provider by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.GetApikeyProvider(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var staticUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a static API key provider", + Long: `Update the API key value of an existing static API key provider.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apikey, _ := cmd.Flags().GetString("apikey") + var err error + apikey, err = cliinput.RequireOrPromptSecret(apikey, "--apikey", "New API Key") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + if err := client.UpdateApikeyProvider(ctx, args[0], &identitypkg.UpdateApikeyProviderRequest{Apikey: apikey}); err != nil { + return err + } + // Fetch updated resource for detail output + resp, err := client.GetApikeyProvider(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var staticDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a static API key provider", + Long: `Delete a static API key provider by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + if err := client.DeleteApikeyProvider(ctx, args[0]); err != nil { + return err + } + return output.PrintDeletedID(args[0]) + }, +} + +var staticGetKeyCmd = &cobra.Command{ + Use: "get-key ", + Short: "Get the API key for an agent identity", + Long: `Retrieve the API key assigned to a specific agent identity from a static API key provider.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.GetApikeyForAgentIdentity(ctx, args[0], args[1]) + if err != nil { + return err + } + switch output.GetFormat() { + case output.FormatTable: + output.Table([]string{"Field", "Value"}, [][]string{ + {"API Key", output.StrOrDash(str(resp.Apikey))}, + }) + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + output.PrintID(str(resp.Apikey)) + } + return nil + }, +} + +// --- outbound-auth delegated --- + +var delegatedCmd = &cobra.Command{ + Use: "delegated", + Short: "Manage delegated API key providers", + Long: `Create, list, get, and delete delegated API key providers for user-provided API keys for external services.`, +} + +var delegatedCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a delegated API key provider", + Long: `Create a new delegated API key provider. The name must be 3-50 characters and +match the pattern ^[a-zA-Z0-9_-]+$.`, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + var err error + name, err = cliinput.RequireOrPromptString(name, "--name", "Provider name") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.CreateDelegatedProvider(ctx, &identitypkg.CreateDelegatedApiKeyProviderRequest{Name: name}) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var delegatedListCmd = &cobra.Command{ + Use: "list", + Short: "List delegated API key providers", + Long: `Retrieve a paginated list of delegated API key providers.`, + RunE: func(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + size, _ := cmd.Flags().GetInt("size") + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.ListDelegatedProviders(ctx, page-1, size) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + if len(resp.Content) == 0 { + fmt.Fprintln(cmd.ErrOrStderr(), "No items found.") + return nil + } + rows := make([][]string, len(resp.Content)) + for i, p := range resp.Content { + rows[i] = []string{str(p.ID), str(p.Name), str(p.Status)} + } + output.Table([]string{"ID", "Name", "Status"}, rows) + if resp.Page != nil && resp.TotalPages != nil && resp.TotalElements != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Page %d of %d (%d total items)\n", *resp.Page+1, *resp.TotalPages, *resp.TotalElements) + } + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + if len(resp.Content) > 0 { + output.PrintID(str(resp.Content[0].ID)) + } + } + return nil + }, +} + +var delegatedGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a delegated API key provider", + Long: `Retrieve a delegated API key provider by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.GetDelegatedProvider(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var delegatedDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a delegated API key provider", + Long: `Delete a delegated API key provider by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + if err := client.DeleteDelegatedProvider(ctx, args[0]); err != nil { + return err + } + return output.PrintDeletedID(args[0]) + }, +} + +var delegatedGetKeyCmd = &cobra.Command{ + Use: "get-key ", + Short: "Obtain a delegated API key for an agent identity", + Long: `Obtain a delegated API key for an agent identity from a delegated API key provider. + +Required flags: --agent-user-id and --return-url. +Optional flags: --custom-state, --session-id, --force-delegation.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + agentUserID, _ := cmd.Flags().GetString("agent-user-id") + returnURL, _ := cmd.Flags().GetString("return-url") + + var err error + agentUserID, err = cliinput.RequireOrPromptString(agentUserID, "--agent-user-id", "Agent user ID") + if err != nil { + return err + } + returnURL, err = cliinput.RequireOrPromptString(returnURL, "--return-url", "Return URL") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + req := &identitypkg.GetDelegatedApiKeyRequest{ + AgentUserID: agentUserID, + ReturnURL: returnURL, + } + if cmd.Flags().Changed("custom-state") { + v, _ := cmd.Flags().GetString("custom-state") + req.CustomState = &v + } + if cmd.Flags().Changed("session-id") { + v, _ := cmd.Flags().GetString("session-id") + req.SessionID = &v + } + if cmd.Flags().Changed("force-delegation") { + v, _ := cmd.Flags().GetBool("force-delegation") + req.ForceDelegation = &v + } + resp, err := client.GetDelegatedApiKey(ctx, args[0], args[1], req) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + output.Table([]string{"Field", "Value"}, [][]string{ + {"API Key", output.StrOrDash(str(resp.Apikey))}, + {"Authorization URL", output.StrOrDash(str(resp.AuthorizationURL))}, + {"Session ID", output.StrOrDash(str(resp.SessionID))}, + {"Status", output.StrOrDash(str(resp.Status))}, + }) + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + output.PrintID(str(resp.SessionID)) + } + return nil + }, +} + +// --- outbound-auth oauth2 --- + +var oauth2Cmd = &cobra.Command{ + Use: "oauth2", + Short: "Manage OAuth2 providers", + Long: `Create, list, get, update, and delete OAuth2 providers, and retrieve M2M and 3-legged OAuth2 tokens.`, +} + +var oauth2CreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an OAuth2 provider", + Long: `Create a new OAuth2 provider. The name must be 3-50 characters and match the +pattern ^[a-zA-Z0-9_-]+$. All of name, client-id, client-secret, authorization-url, +and token-url are required.`, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + clientID, _ := cmd.Flags().GetString("client-id") + clientSecret, _ := cmd.Flags().GetString("client-secret") + authURL, _ := cmd.Flags().GetString("authorization-url") + tokenURL, _ := cmd.Flags().GetString("token-url") + + var err error + name, err = cliinput.RequireOrPromptString(name, "--name", "Provider name") + if err != nil { + return err + } + clientID, err = cliinput.RequireOrPromptString(clientID, "--client-id", "Client ID") + if err != nil { + return err + } + clientSecret, err = cliinput.RequireOrPromptSecret(clientSecret, "--client-secret", "Client Secret") + if err != nil { + return err + } + authURL, err = cliinput.RequireOrPromptString(authURL, "--authorization-url", "Authorization URL") + if err != nil { + return err + } + tokenURL, err = cliinput.RequireOrPromptString(tokenURL, "--token-url", "Token URL") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + req := &identitypkg.CreateOauth2ProviderRequest{ + Name: name, + ClientID: clientID, + ClientSecret: clientSecret, + AuthorizationURL: authURL, + TokenURL: tokenURL, + } + resp, err := client.CreateOauth2Provider(ctx, req) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Client ID", output.StrOrDash(str(resp.ClientID))}, + {"Authorization URL", output.StrOrDash(str(resp.AuthorizationURL))}, + {"Token URL", output.StrOrDash(str(resp.TokenURL))}, + {"Callback URL", output.StrOrDash(str(resp.CallbackURL))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var oauth2ListCmd = &cobra.Command{ + Use: "list", + Short: "List OAuth2 providers", + Long: `Retrieve a paginated list of OAuth2 providers.`, + RunE: func(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + size, _ := cmd.Flags().GetInt("size") + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.ListOauth2Providers(ctx, page-1, size) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + if len(resp.Content) == 0 { + fmt.Fprintln(cmd.ErrOrStderr(), "No items found.") + return nil + } + rows := make([][]string, len(resp.Content)) + for i, p := range resp.Content { + rows[i] = []string{str(p.ID), str(p.Name), str(p.Status), str(p.AuthorizationURL)} + } + output.Table([]string{"ID", "Name", "Status", "Auth URL"}, rows) + if resp.Page != nil && resp.TotalPages != nil && resp.TotalElements != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Page %d of %d (%d total items)\n", *resp.Page+1, *resp.TotalPages, *resp.TotalElements) + } + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + if len(resp.Content) > 0 { + output.PrintID(str(resp.Content[0].ID)) + } + } + return nil + }, +} + +var oauth2GetCmd = &cobra.Command{ + Use: "get ", + Short: "Get an OAuth2 provider", + Long: `Retrieve an OAuth2 provider by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.GetOauth2Provider(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Client ID", output.StrOrDash(str(resp.ClientID))}, + {"Authorization URL", output.StrOrDash(str(resp.AuthorizationURL))}, + {"Token URL", output.StrOrDash(str(resp.TokenURL))}, + {"Callback URL", output.StrOrDash(str(resp.CallbackURL))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var oauth2UpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an OAuth2 provider", + Long: `Update an existing OAuth2 provider. All of client-id, client-secret, authorization-url, and token-url are required.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + clientID, _ := cmd.Flags().GetString("client-id") + clientSecret, _ := cmd.Flags().GetString("client-secret") + authURL, _ := cmd.Flags().GetString("authorization-url") + tokenURL, _ := cmd.Flags().GetString("token-url") + + var err error + clientID, err = cliinput.RequireOrPromptString(clientID, "--client-id", "Client ID") + if err != nil { + return err + } + clientSecret, err = cliinput.RequireOrPromptSecret(clientSecret, "--client-secret", "Client Secret") + if err != nil { + return err + } + authURL, err = cliinput.RequireOrPromptString(authURL, "--authorization-url", "Authorization URL") + if err != nil { + return err + } + tokenURL, err = cliinput.RequireOrPromptString(tokenURL, "--token-url", "Token URL") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + req := &identitypkg.UpdateOauth2ProviderRequest{ + ClientID: clientID, + ClientSecret: clientSecret, + AuthorizationURL: authURL, + TokenURL: tokenURL, + } + if err := client.UpdateOauth2Provider(ctx, args[0], req); err != nil { + return err + } + // Fetch updated resource for detail output + resp, err := client.GetOauth2Provider(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Client ID", output.StrOrDash(str(resp.ClientID))}, + {"Authorization URL", output.StrOrDash(str(resp.AuthorizationURL))}, + {"Token URL", output.StrOrDash(str(resp.TokenURL))}, + {"Callback URL", output.StrOrDash(str(resp.CallbackURL))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var oauth2DeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an OAuth2 provider", + Long: `Delete an OAuth2 provider by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + if err := client.DeleteOauth2Provider(ctx, args[0]); err != nil { + return err + } + return output.PrintDeletedID(args[0]) + }, +} + +var oauth2M2MTokenCmd = &cobra.Command{ + Use: "m2m-token ", + Short: "Get an M2M OAuth2 token", + Long: `Retrieve a machine-to-machine (client credentials) OAuth2 token for an agent identity via an OAuth2 provider.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + scopes, _ := cmd.Flags().GetStringArray("scope") + var err error + scopes, err = cliinput.RequireOrPromptStringSlice(scopes, "--scope", "OAuth2 scopes") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + resp, err := client.GetM2MToken(ctx, args[0], args[1], &identitypkg.GetM2mTokenRequest{Scopes: jsonslice.Array[string](scopes)}) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + output.Table([]string{"Field", "Value"}, [][]string{ + {"Access Token", output.StrOrDash(str(resp.AccessToken))}, + {"Token Type", output.StrOrDash(str(resp.TokenType))}, + }) + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + output.PrintID(str(resp.AccessToken)) + } + return nil + }, +} + +var oauth23LOTokenCmd = &cobra.Command{ + Use: "3lo-token ", + Short: "Get a 3-legged OAuth2 token", + Long: `Retrieve a 3-legged OAuth2 token for an agent identity via an OAuth2 provider. + +Required flags: --agent-user-id, --return-url, --scope. +Optional flags: --session-id, --custom-parameters, --custom-state, --force-authentication.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + agentUserID, _ := cmd.Flags().GetString("agent-user-id") + returnURL, _ := cmd.Flags().GetString("return-url") + scopes, _ := cmd.Flags().GetStringArray("scope") + + var err error + agentUserID, err = cliinput.RequireOrPromptString(agentUserID, "--agent-user-id", "Agent user ID") + if err != nil { + return err + } + returnURL, err = cliinput.RequireOrPromptString(returnURL, "--return-url", "Return URL") + if err != nil { + return err + } + scopes, err = cliinput.RequireOrPromptStringSlice(scopes, "--scope", "OAuth2 scopes") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx) + if err != nil { + return err + } + req := &identitypkg.ThreeLoTokenRequest{ + AgentUserID: agentUserID, + ReturnURL: returnURL, + Scopes: jsonslice.Array[string](scopes), + } + if cmd.Flags().Changed("session-id") { + v, _ := cmd.Flags().GetString("session-id") + req.SessionID = &v + } + if cmd.Flags().Changed("custom-state") { + v, _ := cmd.Flags().GetString("custom-state") + req.CustomState = &v + } + if cmd.Flags().Changed("force-authentication") { + v, _ := cmd.Flags().GetBool("force-authentication") + req.ForceAuthentication = &v + } + customParams, _ := cmd.Flags().GetString("custom-parameters") + if customParams != "" { + var params map[string]string + if err := json.Unmarshal([]byte(customParams), ¶ms); err != nil { + return fmt.Errorf("invalid --custom-parameters JSON: %w", err) + } + req.CustomParameters = ¶ms + } + + resp, err := client.Get3LOToken(ctx, args[0], args[1], req) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + output.Table([]string{"Field", "Value"}, [][]string{ + {"Access Token", output.StrOrDash(str(resp.AccessToken))}, + {"Token Type", output.StrOrDash(str(resp.TokenType))}, + {"Authorization URL", output.StrOrDash(str(resp.AuthorizationURL))}, + {"Session ID", output.StrOrDash(str(resp.SessionID))}, + {"Status", output.StrOrDash(str(resp.Status))}, + }) + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + output.PrintID(str(resp.AccessToken)) + } + return nil + }, +} + +func init() { + AgentbaseCmd.AddCommand(identityCmd) + + identityLoginCmd.Flags().String("client-id", "", "OAuth2 client ID (env: GREENNODE_CLIENT_ID)") + identityLoginCmd.Flags().String("client-secret", "", "OAuth2 client secret (env: GREENNODE_CLIENT_SECRET)") + identityCmd.AddCommand(identityLoginCmd) + identityCmd.AddCommand(identityLogoutCmd) + identityCmd.AddCommand(identityWhoamiCmd) + + identityCmd.AddCommand(identityConfigCmd) + identityConfigCmd.AddCommand(identityConfigShowCmd) + + // workload + identityCmd.AddCommand(workloadCmd) + + workloadCreateCmd.Flags().StringVarP(&workloadCreateName, "name", "n", "", "Agent identity name (required without --interactive)") + workloadCreateCmd.Flags().BoolVar(&workloadCreateSetCurrent, "set-current", false, "Set as the current agent identity after creation") + workloadCreateCmd.Flags().String("description", "", "Description of the agent identity") + workloadCreateCmd.Flags().StringArray("allowed-return-url", nil, "Allowed return URL (repeatable)") + workloadCmd.AddCommand(workloadCreateCmd) + + workloadListCmd.Flags().Int("page", 1, "Page number (1-based)") + workloadListCmd.Flags().Int("size", 20, "Page size") + workloadCmd.AddCommand(workloadListCmd) + + workloadCmd.AddCommand(workloadGetCmd) + + workloadUpdateCmd.Flags().String("description", "", "Updated description") + workloadUpdateCmd.Flags().StringArray("allowed-return-url", nil, "Allowed return URL (repeatable)") + workloadCmd.AddCommand(workloadUpdateCmd) + + workloadCmd.AddCommand(workloadUseCmd) + workloadCmd.AddCommand(workloadDeleteCmd) + + // outbound-auth + identityCmd.AddCommand(outboundAuthCmd) + + // static + outboundAuthCmd.AddCommand(staticCmd) + + staticCreateCmd.Flags().StringP("name", "n", "", "Provider name (required without --interactive)") + staticCreateCmd.Flags().String("apikey", "", "API key value (required without --interactive)") + staticCmd.AddCommand(staticCreateCmd) + + staticListCmd.Flags().Int("page", 1, "Page number (1-based)") + staticListCmd.Flags().Int("size", 20, "Page size") + staticCmd.AddCommand(staticListCmd) + + staticCmd.AddCommand(staticGetCmd) + + staticUpdateCmd.Flags().String("apikey", "", "New API key value (required without --interactive)") + staticCmd.AddCommand(staticUpdateCmd) + + staticCmd.AddCommand(staticDeleteCmd) + staticCmd.AddCommand(staticGetKeyCmd) + + // delegated + outboundAuthCmd.AddCommand(delegatedCmd) + + delegatedCreateCmd.Flags().StringP("name", "n", "", "Provider name (required without --interactive)") + delegatedCmd.AddCommand(delegatedCreateCmd) + + delegatedListCmd.Flags().Int("page", 1, "Page number (1-based)") + delegatedListCmd.Flags().Int("size", 20, "Page size") + delegatedCmd.AddCommand(delegatedListCmd) + + delegatedCmd.AddCommand(delegatedGetCmd) + delegatedCmd.AddCommand(delegatedDeleteCmd) + + delegatedGetKeyCmd.Flags().String("agent-user-id", "", "Agent user ID (required without --interactive)") + delegatedGetKeyCmd.Flags().String("return-url", "", "Return URL after authorization (required without --interactive)") + delegatedGetKeyCmd.Flags().String("custom-state", "", "Custom state parameter") + delegatedGetKeyCmd.Flags().String("session-id", "", "Session ID (UUID format)") + delegatedGetKeyCmd.Flags().Bool("force-delegation", false, "Force delegation") + delegatedCmd.AddCommand(delegatedGetKeyCmd) + + // oauth2 + outboundAuthCmd.AddCommand(oauth2Cmd) + + oauth2CreateCmd.Flags().StringP("name", "n", "", "Provider name (required without --interactive)") + oauth2CreateCmd.Flags().String("client-id", "", "OAuth2 client ID (required without --interactive)") + oauth2CreateCmd.Flags().String("client-secret", "", "OAuth2 client secret (required without --interactive)") + oauth2CreateCmd.Flags().String("authorization-url", "", "Authorization endpoint URL (required without --interactive)") + oauth2CreateCmd.Flags().String("token-url", "", "Token endpoint URL (required without --interactive)") + oauth2Cmd.AddCommand(oauth2CreateCmd) + + oauth2ListCmd.Flags().Int("page", 1, "Page number (1-based)") + oauth2ListCmd.Flags().Int("size", 20, "Page size") + oauth2Cmd.AddCommand(oauth2ListCmd) + + oauth2Cmd.AddCommand(oauth2GetCmd) + + oauth2UpdateCmd.Flags().String("client-id", "", "OAuth2 client ID (required without --interactive)") + oauth2UpdateCmd.Flags().String("client-secret", "", "OAuth2 client secret (required without --interactive)") + oauth2UpdateCmd.Flags().String("authorization-url", "", "Authorization endpoint URL (required without --interactive)") + oauth2UpdateCmd.Flags().String("token-url", "", "Token endpoint URL (required without --interactive)") + oauth2Cmd.AddCommand(oauth2UpdateCmd) + + oauth2Cmd.AddCommand(oauth2DeleteCmd) + + oauth2M2MTokenCmd.Flags().StringArray("scope", nil, "OAuth2 scope (repeatable, required without --interactive)") + oauth2Cmd.AddCommand(oauth2M2MTokenCmd) + + oauth23LOTokenCmd.Flags().String("agent-user-id", "", "Agent user ID (required without --interactive)") + oauth23LOTokenCmd.Flags().String("return-url", "", "Return URL after authorization (required without --interactive)") + oauth23LOTokenCmd.Flags().StringArray("scope", nil, "OAuth2 scope (repeatable, required without --interactive)") + oauth23LOTokenCmd.Flags().String("session-id", "", "Session ID (UUID format)") + oauth23LOTokenCmd.Flags().String("custom-parameters", "", `Custom parameters as a JSON object, e.g. '{"key1":"value1"}'`) + oauth23LOTokenCmd.Flags().String("custom-state", "", "Custom state parameter") + oauth23LOTokenCmd.Flags().Bool("force-authentication", false, "Force re-authentication") + oauth2Cmd.AddCommand(oauth23LOTokenCmd) +} + +// str safely dereferences a string pointer, returning an empty string if nil. +func str(p *string) string { + if p == nil { + return "" + } + return *p +} + +// joinStrings joins a JSON slice of strings with the given separator. +func joinStrings(urls jsonslice.Array[string], sep string) string { + return strings.Join([]string(urls), sep) +} + +// formatTime formats a time pointer for display, returning "-" if nil. +func formatTime(t *time.Time) string { + if t == nil { + return "-" + } + return t.Format("2006-01-02 15:04:05") +} + +// newIdentityClient builds an authenticated identity client. +func newIdentityClient(ctx context.Context) (*identitypkg.Client, error) { + cfg := mustLoadConfigWithCreds() + provider := newAuthProvider(cfg) + if _, err := provider.AccessToken(ctx); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + return identitypkg.NewClient(cfg.Endpoints.Identity, provider), nil +} diff --git a/go/cmd/configure/get.go b/go/cmd/configure/get.go index 9c2615c..963f614 100644 --- a/go/cmd/configure/get.go +++ b/go/cmd/configure/get.go @@ -3,6 +3,7 @@ package configure import ( "fmt" "os" + "time" "github.com/spf13/cobra" "github.com/vngcloud/greennode-cli/internal/config" @@ -45,6 +46,20 @@ func runGet(cmd *cobra.Command, args []string) { value = cfg.Profile case "project_id": value = cfg.ProjectID + // Login (user) identity. refresh_token is secret-at-rest → masked (a user + // can confirm a login token is present without seeing it). auth_mode, + // iam_env and the expiry are non-secret refresh context. The client_id is + // not stored here — it is resolved from iam_env at refresh. + case "refresh_token": + value = config.MaskCredential(cfg.RefreshToken) + case "auth_mode": + value = cfg.AuthMode + case "iam_env": + value = cfg.IamEnv + case "token_expires_at": + if !cfg.TokenExpiresAt.IsZero() { + value = cfg.TokenExpiresAt.UTC().Format(time.RFC3339) + } default: fmt.Fprintf(os.Stderr, "Unknown configuration key: %s\n", key) os.Exit(1) diff --git a/go/cmd/configure/list.go b/go/cmd/configure/list.go index 2ebcfea..d08a79d 100644 --- a/go/cmd/configure/list.go +++ b/go/cmd/configure/list.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/spf13/cobra" "github.com/vngcloud/greennode-cli/internal/config" @@ -53,6 +54,14 @@ func runList(cmd *cobra.Command, args []string) { resolveConfigEntry("region", cfg.Region, configFile), resolveConfigEntry("output", cfg.Output, configFile), resolveConfigEntry("project_id", cfg.ProjectID, configFile), + // Login (user) identity — present on profiles created by `grn login`. + // refresh_token is secret-at-rest → masked; the rest is non-secret + // refresh context, shown as-is so a user can see which auth mode a + // profile is in and which IAM env it targets. + resolveCredEntry("refresh_token", cfg.RefreshToken, credsFile), + resolveCredEntryPlain("auth_mode", cfg.AuthMode, credsFile), + resolveCredEntryPlain("iam_env", cfg.IamEnv, credsFile), + resolveCredEntryPlain("token_expires_at", tokenExpiresAtStr(cfg.TokenExpiresAt), credsFile), } // Print header @@ -119,3 +128,25 @@ func resolveConfigEntry(name, value, configFile string) configEntry { loc := "~" + configFile[len(home):] return configEntry{name: name, value: value, typ: "config-file", location: loc} } + +// resolveCredEntryPlain is resolveCredEntry for non-secret credential-section +// keys (auth_mode, iam_env, token_expires_at): same location logic, but the +// value is shown as-is rather than masked (these are non-secret refresh +// context, not credentials). +func resolveCredEntryPlain(name, value, credsFile string) configEntry { + if value == "" { + return configEntry{name: name, value: "", typ: "None", location: "None"} + } + home, _ := os.UserHomeDir() + loc := "~" + credsFile[len(home):] + return configEntry{name: name, value: value, typ: "config-file", location: loc} +} + +// tokenExpiresAtStr renders the access-token expiry as RFC3339, or "" (→ +// "") when no expiry was recorded. +func tokenExpiresAtStr(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/go/cmd/configure/login_keys_test.go b/go/cmd/configure/login_keys_test.go new file mode 100644 index 0000000..5fbefb8 --- /dev/null +++ b/go/cmd/configure/login_keys_test.go @@ -0,0 +1,149 @@ +package configure + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/vngcloud/greennode-cli/internal/config" +) + +// captureOutput swaps os.Stdout for a pipe for the duration of fn and returns +// whatever was printed. Non-parallel: it mutates process-global os.Stdout. The +// configure commands print via fmt to os.Stdout (not cobra's Out), so the swap +// is what captures them. +func captureOutput(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + fn() + _ = w.Close() + return <-done +} + +// Unit: resolveCredEntry masks refresh_token (secret-at-rest), while +// resolveCredEntryPlain shows the non-secret login context as-is. +func TestResolveCredEntry_MasksRefreshToken(t *testing.T) { + e := resolveCredEntry("refresh_token", "rt-SUPERSECRET-0001", "/home/u/.greennode/credentials") + if strings.Contains(e.value, "rt-SUPERSECRET") { + t.Errorf("refresh_token not masked: %q", e.value) + } + if !strings.HasSuffix(e.value, "0001") { + t.Errorf("masked refresh_token should keep last 4, got %q", e.value) + } +} + +func TestResolveCredEntryPlain_ShowsLoginContextAsIs(t *testing.T) { + for _, tc := range []struct{ name, val string }{ + {"auth_mode", "user"}, + {"iam_env", "dev"}, + } { + e := resolveCredEntryPlain(tc.name, tc.val, "/home/u/.greennode/credentials") + if e.value != tc.val { + t.Errorf("%s: got %q, want %q as-is (non-secret)", tc.name, e.value, tc.val) + } + } + // Empty → , not a masked empty string. + if e := resolveCredEntryPlain("auth_mode", "", "/h/.greennode/credentials"); e.value != "" { + t.Errorf("empty auth_mode = %q, want ", e.value) + } +} + +// End-to-end: `configure list` on a login profile masks refresh_token and shows +// the non-secret login context in plaintext. +func TestListMasksRefreshTokenShowsLoginContext(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("GRN_PROFILE", "") + exp := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + if err := config.NewConfigFileWriter().WriteLoginToken("default", "rt-SUPERSECRET-0001", exp, "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + + root := newConfigureTestCmd() + root.SetArgs([]string{"configure", "list", "--profile", "default"}) + out := captureOutput(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("execute list: %v", err) + } + }) + + if strings.Contains(out, "rt-SUPERSECRET") { + t.Errorf("list leaked refresh_token plaintext: %q", out) + } + if !strings.Contains(out, "0001") { + t.Errorf("list should show masked refresh_token ending in 0001: %q", out) + } + // Non-secret login context is shown as-is so a user can see the auth mode. + for _, want := range []string{"user", "dev"} { + if !strings.Contains(out, want) { + t.Errorf("list missing non-secret login context %q: %q", want, out) + } + } +} + +// `configure get refresh_token` masks; `get auth_mode`/`iam_env` return the +// plaintext non-secret value. +func TestGetRefreshTokenMasked(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("GRN_PROFILE", "") + exp := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + if err := config.NewConfigFileWriter().WriteLoginToken("default", "rt-SUPERSECRET-0001", exp, "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + + root := newConfigureTestCmd() + root.SetArgs([]string{"configure", "get", "refresh_token", "--profile", "default"}) + out := strings.TrimSpace(captureOutput(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("get refresh_token: %v", err) + } + })) + if strings.Contains(out, "rt-SUPERSECRET") { + t.Errorf("get refresh_token leaked plaintext: %q", out) + } + if !strings.HasSuffix(out, "0001") { + t.Errorf("get refresh_token should be masked ending in 0001, got %q", out) + } +} + +func TestGetLoginContextPlain(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("GRN_PROFILE", "") + exp := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + if err := config.NewConfigFileWriter().WriteLoginToken("default", "rt", exp, "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + for _, tc := range []struct{ key, want string }{ + {"auth_mode", "user"}, + {"iam_env", "dev"}, + } { + root := newConfigureTestCmd() + root.SetArgs([]string{"configure", "get", tc.key, "--profile", "default"}) + out := strings.TrimSpace(captureOutput(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("get %s: %v", tc.key, err) + } + })) + if out != tc.want { + t.Errorf("get %s = %q, want %q", tc.key, out, tc.want) + } + } +} diff --git a/go/cmd/conventions_test.go b/go/cmd/conventions_test.go index e0f3506..95f88ec 100644 --- a/go/cmd/conventions_test.go +++ b/go/cmd/conventions_test.go @@ -79,9 +79,23 @@ func isConfigureSub(c *cobra.Command) bool { return false } +// agentbaseExempt: the `grn agentbase` subtree is a gated, self-contained +// migrated subsystem (own v2 OAuth2 auth + .greennode.json config, compiled in +// only with -tags agentbase). It preserves its own command UX verbatim and is +// exempt from the cross-product verb/flag conventions for now; conformity will +// be revisited when agentbase flips to default-on. +func isAgentbaseSub(c *cobra.Command) bool { + for p := c.Parent(); p != nil; p = p.Parent() { + if p.Name() == "agentbase" { + return true + } + } + return false +} + func TestNoDeniedVerbs(t *testing.T) { for _, c := range leafCommands() { - if isConfigureSub(c) { + if isConfigureSub(c) || isAgentbaseSub(c) { continue } v := verbToken(c.Use) @@ -94,6 +108,9 @@ func TestNoDeniedVerbs(t *testing.T) { func TestDestructiveCommandsHaveDryRunAndForce(t *testing.T) { for _, c := range leafCommands() { + if isAgentbaseSub(c) { + continue + } if !destructiveVerbs[verbToken(c.Use)] { continue } diff --git a/go/cmd/login/login.go b/go/cmd/login/login.go new file mode 100644 index 0000000..c7a043c --- /dev/null +++ b/go/cmd/login/login.go @@ -0,0 +1,343 @@ +// Package login wires the `grn login` / `grn logout` cobra commands around the +// internal/login PKCE library. It resolves a login.Config from flags + env plus +// a baked-in per-env public client_id (dev and prod both real; the +// client_secret is never baked in), runs the browser PKCE flow, and folds the +// refresh token (0600) + non-secret refresh context into the per-profile +// ~/.greennode/credentials INI (auth-only merge — one identity file per +// profile). The access token is held in memory only — nothing the command +// prints or writes leaks it. +package login + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/vngcloud/greennode-cli/internal/config" + loginpkg "github.com/vngcloud/greennode-cli/internal/login" +) + +// The IAM endpoint presets per environment live in loginpkg.IamEndpoints +// (internal/login/iamenv.go), from the PKCE login design spec §7 (dev signin +// host confirmed 2026-07-30). AuthorizeURL is the browser signin page; the prod +// entry 301→signin.greennode.ai (a rebrand, not an env signal). Override either +// endpoint piecewise with --authorize-url / --token-url when a portal points +// elsewhere. +// +// The per-env IAM endpoints (authorize/token/client_id) and the baked-in public +// client_id constants live in internal/login — loginpkg.IamEndpoints, +// loginpkg.DevClientID, loginpkg.ProdClientID, loginpkg.DefaultIamEnv +// — shared with the subcommand refresh-token path so a profile's iam_env +// resolves to the SAME /v2 token URL at login and at refresh. See +// internal/login/iamenv.go for the rationale (public client_ids are non-secret; +// the client_secret is never baked in). + +const ( + defaultTimeout = 5 * time.Minute +) + +var ( + flagClientID string + flagClientSecret string + flagIamEnv string + flagAuthorizeURL string + flagTokenURL string + flagScope string + flagTimeout time.Duration +) + +// LoginCmd is the `grn login` command. +var LoginCmd = &cobra.Command{ + Use: "login", + Short: "Log in to GreenNode via IAM (interactive PKCE)", + Long: `Log in to GreenNode by authenticating against VNG IAM with a browser-based +PKCE authorization-code flow. The resulting refresh token is folded into the +per-profile ~/.greennode/credentials INI (0600), alongside any machine +credentials; the access token is held in memory only. Use --profile to target a +specific profile (e.g. dev vs prod) so each holds its own login token. + +After the browser flow you are prompted for a default region (same prompt as +"grn configure"), so a login-only profile is ready for vks/vserver without a +separate configure. + +A default public client_id is baked in per --iam-env (dev's and prod's real +ids), so "grn login" needs no --client-id. Override with --client-id or +GRN_LOGIN_CLIENT_ID. Pick the env with --iam-env or GRN_IAM_ENV (default prod). +The client_secret is never baked in; omit --client-secret for a PKCE-only +public client.`, + RunE: runLogin, +} + +// LogoutCmd is the `grn logout` command. +var LogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Forget the cached GreenNode login refresh token", + Long: `Removes the login keys (refresh_token, expiry, auth_mode, +iam_env) from the current profile's credentials section, leaving any machine +client_id/client_secret intact. Idempotent: running logout when not logged in +is not an error.`, + RunE: runLogout, +} + +func init() { + LoginCmd.Flags().StringVar(&flagClientID, "client-id", "", "IAM OAuth client id (default: baked-in per --iam-env; env: GRN_LOGIN_CLIENT_ID)") + LoginCmd.Flags().StringVar(&flagClientSecret, "client-secret", "", "IAM OAuth client secret; omit for a PKCE-only public client (env: GRN_LOGIN_CLIENT_SECRET)") + LoginCmd.Flags().StringVar(&flagIamEnv, "iam-env", "", "IAM environment preset: prod|dev (default prod; env: GRN_IAM_ENV)") + LoginCmd.Flags().StringVar(&flagAuthorizeURL, "authorize-url", "", "Override the IAM signin/authorize URL") + LoginCmd.Flags().StringVar(&flagTokenURL, "token-url", "", "Override the IAM /v2 token URL") + LoginCmd.Flags().StringVar(&flagScope, "scope", "", "OAuth scopes (space-separated); default openid") + LoginCmd.Flags().DurationVar(&flagTimeout, "timeout", defaultTimeout, "Maximum time to wait for the browser login to complete") +} + +// resolveConfig builds a loginpkg.Config from the flag/env inputs plus the +// iam-env preset. Precedence: explicit flag > env var; --authorize-url/ +// --token-url > preset; client-id flag > GRN_LOGIN_CLIENT_ID > the per-env +// baked-in default (see resolveClientID). It is pure w.r.t. the cobra flag +// state (callers pass flag values in) so it table-tests cleanly. Returns an +// error only when ClientID resolves empty AND the preset has no baked default +// — unreachable with the shipped presets, kept as a defensive guard. +func resolveConfig(clientID, clientSecret, iamEnv, authorizeURL, tokenURL, scope string, timeout time.Duration) (loginpkg.Config, time.Duration, error) { + env := iamEnv + if env == "" { + env = os.Getenv("GRN_IAM_ENV") + } + if env == "" { + env = loginpkg.DefaultIamEnv + } + preset, ok := loginpkg.IamEndpoints[env] + if !ok { + return loginpkg.Config{}, 0, fmt.Errorf("invalid --iam-env %q (valid: prod, dev)", env) + } + + if authorizeURL == "" { + authorizeURL = preset.Authorize + } + if tokenURL == "" { + tokenURL = preset.Token + } + + clientID, err := resolveClientID(clientID, preset.ClientID) + if err != nil { + return loginpkg.Config{}, 0, err + } + + if clientSecret == "" { + clientSecret = os.Getenv("GRN_LOGIN_CLIENT_SECRET") + } + + var scopes []string + if s := strings.TrimSpace(scope); s != "" { + scopes = strings.Fields(s) + } else { + scopes = []string{"openid"} + } + + if timeout <= 0 { + timeout = defaultTimeout + } + + cfg := loginpkg.Config{ + AuthorizeURL: authorizeURL, + TokenURL: tokenURL, + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + } + return cfg, timeout, nil +} + +// resolveClientID applies client-id precedence: explicit --client-id flag > +// GRN_LOGIN_CLIENT_ID env > the per-iam-env baked-in public default. The baked +// default is a non-secret public client_id (dev's and prod's real ids); +// only when ALL three are empty (a preset with no default) does login refuse. +// Unreachable with the shipped presets — the error is a defensive guard so a +// future preset without a ClientID fails loudly rather than sending an empty +// client_id to IAM. +func resolveClientID(flagClientID, embeddedDefault string) (string, error) { + if flagClientID != "" { + return flagClientID, nil + } + if v := os.Getenv("GRN_LOGIN_CLIENT_ID"); v != "" { + return v, nil + } + if embeddedDefault != "" { + return embeddedDefault, nil + } + return "", fmt.Errorf("--client-id (or GRN_LOGIN_CLIENT_ID) is required") +} + +// resolveIamEnv applies iam-env precedence: a non-empty --iam-env flag > +// GRN_IAM_ENV env > prod default. The flag has no cobra default (registered as +// "") so an unset flag falls through to the env var, letting +// `GRN_IAM_ENV=dev grn login` select dev without --iam-env. Returns the +// EFFECTIVE env so it can be threaded to both resolveConfig and the persisted +// login context (iam_env must reflect where the user actually logged in, not +// the flag default — otherwise a GRN_IAM_ENV=dev login would persist prod). +func resolveIamEnv(cmd *cobra.Command) string { + if f := cmd.Flag("iam-env"); f != nil && f.Value.String() != "" { + return f.Value.String() + } + if v := os.Getenv("GRN_IAM_ENV"); v != "" { + return v + } + return loginpkg.DefaultIamEnv +} + +func runLogin(cmd *cobra.Command, _ []string) error { + iamEnv := resolveIamEnv(cmd) + cfg, timeout, err := resolveConfig(flagClientID, flagClientSecret, iamEnv, flagAuthorizeURL, flagTokenURL, flagScope, flagTimeout) + if err != nil { + return err + } + // The global --debug flag gates the library's stderr trace. Restored on + // return so a debug run never leaves the seam armed for later invocations. + if dbg, _ := cmd.Flags().GetBool("debug"); dbg { + loginpkg.SetDebug(true) + defer loginpkg.SetDebug(false) + } + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + + // The library mints and returns the Token; persistence is our job. Fold the + // refresh token + non-secret refresh context (iam_env) into the per-profile + // credentials INI so a later usage slice can refresh without re-prompting. + // The client_id is NOT persisted — it is a public id baked into source and + // resolved from iam_env at refresh (internal/login.ClientIDForEnv). The + // access token is NEVER persisted — only the refresh token is (0600). On + // partial success (no refresh_token) there is nothing to persist; the + // library already warned on stderr. + tok, err := loginpkg.Login(ctx, cfg) + if err != nil { + return err + } + + profile := resolveProfile(cmd) + if tok.RefreshToken != "" { + // Persist the RESOLVED iam-env (flag > GRN_IAM_ENV > prod) — not the raw + // flag — so refresh targets the environment the user actually logged in + // against, even when they selected it via GRN_IAM_ENV. + if err := config.NewConfigFileWriter().WriteLoginToken(profile, tok.RefreshToken, tok.ExpiresAt, "user", iamEnv); err != nil { + return fmt.Errorf("persist login token: %w", err) + } + } + + // Prompt for the default region — same UX as `grn configure` — so a + // login-only profile is immediately usable for vks/vserver without a + // separate `grn configure` (a login-only profile otherwise hits "region is + // not configured" on the first service call). The prompt defaults to the + // profile's existing region, validates against config.REGIONS, and + // preserves the existing output/project_id. A prompt/IO failure is + // non-fatal: login already succeeded. + region, regionErr := promptDefaultRegion(profile, bufio.NewReader(os.Stdin)) + + // Do not echo the access token (credential hygiene). Report only the profile + // the refresh token landed in + the access-token expiry. + fmt.Printf("Logged in. Refresh token saved to profile '%s'.", profile) + if !tok.ExpiresAt.IsZero() { + fmt.Printf(" Access token expires %s.", tok.ExpiresAt.Format(time.RFC3339)) + } + if tok.RefreshToken == "" { + fmt.Printf(" No refresh token returned — re-login will be needed after expiry.") + } + if regionErr != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", regionErr) + } else { + fmt.Printf(" Default region set to '%s'.", region) + } + fmt.Println() + return nil +} + +// resolveProfile mirrors cmd/configure's resolution: --profile flag → GRN_PROFILE +// → "default". login/logout act on the resolved profile's credentials section +// so dev/prod profiles each hold their own login token. The nil-flag guard lets +// a zero *cobra.Command (white-box tests, no registered flags) resolve to the +// default/env profile instead of panicking. +func resolveProfile(cmd *cobra.Command) string { + profile := "" + if f := cmd.Flag("profile"); f != nil { + profile = f.Value.String() + } + if profile == "" { + profile = os.Getenv("GRN_PROFILE") + } + if profile == "" { + profile = "default" + } + return profile +} + +// promptDefaultRegion asks for the profile's default region — the same prompt +// `grn configure` shows — and persists it to the profile's config file, so a +// login-only profile is immediately usable for vks/vserver. The prompt defaults +// to the profile's existing region (pressing Enter keeps it); an unknown region +// falls back to "HCM-3" with a stderr warning (mirrors configure). The existing +// output and project_id are preserved verbatim (login does not re-prompt or +// auto-detect them — that is configure's job). reader is an injected +// *bufio.Reader so the prompt is testable without hijacking os.Stdin; runLogin +// passes bufio.NewReader(os.Stdin). +// +// Lenient load: a brand-new profile (or a parse error) yields an empty config, +// so `grn login` bootstraps a profile that has not been `configure`d. On +// non-interactive stdin (EOF, piped input) the prompt returns the default, so +// headless login still writes a sane region instead of hanging. +func promptDefaultRegion(profile string, reader *bufio.Reader) (string, error) { + cfg, err := config.LoadConfig(profile) + if err != nil || cfg == nil { + cfg = &config.Config{Profile: profile} + } + output := cfg.Output + if output == "" { + output = "json" // LoadConfig's own default; re-asserted on write. + } + region := resolveDefaultRegion(reader, cfg.Region) + if err := config.NewConfigFileWriter().WriteConfig(profile, region, output, cfg.ProjectID); err != nil { + return "", fmt.Errorf("save default region: %w", err) + } + return region, nil +} + +// resolveDefaultRegion prompts for a region (defaulting to current), validates +// it against config.REGIONS, and falls back to "HCM-3" on empty/unknown — +// matching `grn configure`. Pure w.r.t. the reader so it table-tests cleanly. +func resolveDefaultRegion(reader *bufio.Reader, current string) string { + region := promptWithDefault(reader, "Default region name", current) + if _, ok := config.REGIONS[region]; !ok { + fmt.Fprintf(os.Stderr, "Warning: invalid region '%s', using default 'HCM-3'\n", region) + region = "HCM-3" + } + return region +} + +// promptWithDefault prints a "label [default]: " prompt and returns the trimmed +// input, falling back to defaultVal on empty input (including EOF on a +// non-interactive stdin). Mirrors cmd/configure's helper so the two commands +// prompt identically; kept here (not exported from a shared package) because the +// prompt is a cmd-layer concern and each cmd package stays self-contained. +func promptWithDefault(reader *bufio.Reader, prompt, defaultVal string) string { + if defaultVal != "" { + fmt.Printf("%s [%s]: ", prompt, defaultVal) + } else { + fmt.Printf("%s: ", prompt) + } + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + return input +} + +func runLogout(cmd *cobra.Command, _ []string) error { + profile := resolveProfile(cmd) + if err := config.NewConfigFileWriter().ClearLoginToken(profile); err != nil { + return fmt.Errorf("logout: %w", err) + } + fmt.Printf("Logged out (login token cleared from profile '%s').\n", profile) + return nil +} diff --git a/go/cmd/login/login_test.go b/go/cmd/login/login_test.go new file mode 100644 index 0000000..718e989 --- /dev/null +++ b/go/cmd/login/login_test.go @@ -0,0 +1,461 @@ +package login + +import ( + "bufio" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "gopkg.in/ini.v1" + + "github.com/vngcloud/greennode-cli/internal/config" + loginpkg "github.com/vngcloud/greennode-cli/internal/login" +) + +// resolveConfig is the pure cobra-slice seam; these tests cover client-id +// precedence (flag > env > baked-in per-env default), iam-env precedence +// (flag > GRN_IAM_ENV > prod default), the iam-env presets and piecewise +// overrides, scope splitting, and the public-client (empty-secret) path. +// +// They are deliberately non-parallel: resolveConfig reads process env +// (GRN_LOGIN_*, GRN_IAM_ENV) via os.Getenv, and t.Setenv is not safe across +// parallel tests in the same process. +func TestResolveConfig(t *testing.T) { + // Start from a clean env so each case controls its inputs exactly. + t.Setenv("GRN_LOGIN_CLIENT_ID", "") + t.Setenv("GRN_LOGIN_CLIENT_SECRET", "") + t.Setenv("GRN_IAM_ENV", "") + + cases := []struct { + name string + clientID string + clientSecret string + iamEnv string + authorizeURL string + tokenURL string + scope string + timeout time.Duration + envClientID string + envClientSec string + envIamEnv string + wantErr string // non-empty → expect an error containing this + wantClientID string + wantClientSec string + wantAuthorize string + wantToken string + wantScopes []string + wantTimeout time.Duration + }{ + { + name: "flag client-id wins over env", + clientID: "cid-flag", envClientID: "cid-env", iamEnv: "prod", + wantClientID: "cid-flag", wantClientSec: "", + wantAuthorize: loginpkg.IamEndpoints["prod"].Authorize, wantToken: loginpkg.IamEndpoints["prod"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "env client-id used when flag empty (env > embedded)", + envClientID: "cid-env", iamEnv: "dev", // dev has an embedded default that env must beat + wantClientID: "cid-env", + wantAuthorize: loginpkg.IamEndpoints["dev"].Authorize, wantToken: loginpkg.IamEndpoints["dev"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + // The baked-in prod client_id is used automatically when flag/env + // omit it — a real registered public client, so prod login works. + name: "embedded prod client_id used when flag/env omitted", + iamEnv: "prod", + wantClientID: loginpkg.ProdClientID, wantClientSec: "", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "embedded dev client_id used when flag/env omitted", + iamEnv: "dev", + wantClientID: loginpkg.DevClientID, wantClientSec: "", + wantAuthorize: loginpkg.IamEndpoints["dev"].Authorize, wantToken: loginpkg.IamEndpoints["dev"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "invalid iam-env errors", + clientID: "cid", iamEnv: "staging", + wantErr: "iam-env", + }, + { + name: "GRN_IAM_ENV selects dev when iam-env flag empty", + clientID: "cid", iamEnv: "", envIamEnv: "dev", + wantClientID: "cid", + wantAuthorize: loginpkg.IamEndpoints["dev"].Authorize, wantToken: loginpkg.IamEndpoints["dev"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + // Explicit --iam-env (non-empty) beats GRN_IAM_ENV. resolveConfig only + // consults GRN_IAM_ENV when iamEnv == "". + name: "explicit iam-env beats GRN_IAM_ENV", + clientID: "cid", iamEnv: "prod", envIamEnv: "dev", + wantClientID: "cid", + wantAuthorize: loginpkg.IamEndpoints["prod"].Authorize, wantToken: loginpkg.IamEndpoints["prod"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "iam-env empty + GRN_IAM_ENV empty defaults to prod", + clientID: "cid", iamEnv: "", envIamEnv: "", + wantClientID: "cid", + wantAuthorize: loginpkg.IamEndpoints["prod"].Authorize, wantToken: loginpkg.IamEndpoints["prod"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "client-secret flag wins over env", + clientID: "cid", clientSecret: "cs-flag", envClientSec: "cs-env", iamEnv: "prod", + wantClientID: "cid", wantClientSec: "cs-flag", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "client-secret env fallback", + clientID: "cid", envClientSec: "cs-env", iamEnv: "prod", + wantClientID: "cid", wantClientSec: "cs-env", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "empty secret → public client path", + clientID: "cid", iamEnv: "prod", + wantClientID: "cid", wantClientSec: "", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "dev preset swaps token URL", + clientID: "cid", iamEnv: "dev", + wantClientID: "cid", + wantAuthorize: loginpkg.IamEndpoints["dev"].Authorize, wantToken: loginpkg.IamEndpoints["dev"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "authorize-url overrides preset only", + clientID: "cid", iamEnv: "prod", authorizeURL: "https://custom/auth", + wantClientID: "cid", wantClientSec: "", + wantAuthorize: "https://custom/auth", wantToken: loginpkg.IamEndpoints["prod"].Token, + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "token-url overrides preset only", + clientID: "cid", iamEnv: "prod", tokenURL: "https://custom/token", + wantClientID: "cid", wantClientSec: "", + wantAuthorize: loginpkg.IamEndpoints["prod"].Authorize, wantToken: "https://custom/token", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "default scope is openid", + clientID: "cid", iamEnv: "prod", scope: "", + wantClientID: "cid", wantClientSec: "", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + { + name: "scope splits on spaces", + clientID: "cid", iamEnv: "prod", scope: "openid profile email", + wantClientID: "cid", wantClientSec: "", + wantScopes: []string{"openid", "profile", "email"}, wantTimeout: defaultTimeout, + }, + { + name: "timeout preserved when positive", + clientID: "cid", iamEnv: "prod", timeout: 42 * time.Second, + wantClientID: "cid", wantClientSec: "", + wantScopes: []string{"openid"}, wantTimeout: 42 * time.Second, + }, + { + name: "timeout<=0 falls back to default", + clientID: "cid", iamEnv: "prod", timeout: 0, + wantClientID: "cid", wantClientSec: "", + wantScopes: []string{"openid"}, wantTimeout: defaultTimeout, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("GRN_LOGIN_CLIENT_ID", tc.envClientID) + t.Setenv("GRN_LOGIN_CLIENT_SECRET", tc.envClientSec) + t.Setenv("GRN_IAM_ENV", tc.envIamEnv) + + cfg, gotTimeout, err := resolveConfig(tc.clientID, tc.clientSecret, tc.iamEnv, tc.authorizeURL, tc.tokenURL, tc.scope, tc.timeout) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err=%q, want it to contain %q", err.Error(), tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.ClientID != tc.wantClientID { + t.Errorf("ClientID=%q, want %q", cfg.ClientID, tc.wantClientID) + } + if cfg.ClientSecret != tc.wantClientSec { + t.Errorf("ClientSecret=%q, want %q", cfg.ClientSecret, tc.wantClientSec) + } + if tc.wantAuthorize != "" && cfg.AuthorizeURL != tc.wantAuthorize { + t.Errorf("AuthorizeURL=%q, want %q", cfg.AuthorizeURL, tc.wantAuthorize) + } + if tc.wantToken != "" && cfg.TokenURL != tc.wantToken { + t.Errorf("TokenURL=%q, want %q", cfg.TokenURL, tc.wantToken) + } + if !slices.Equal(cfg.Scopes, tc.wantScopes) { + t.Errorf("Scopes=%v, want %v", cfg.Scopes, tc.wantScopes) + } + if gotTimeout != tc.wantTimeout { + t.Errorf("timeout=%v, want %v", gotTimeout, tc.wantTimeout) + } + }) + } +} + +// resolveClientID is the pure client-id resolution helper (flag > env > +// embedded). The error path is unreachable via resolveConfig with the shipped +// presets (both have embedded defaults), so it is exercised directly here with +// an empty embedded default. +func TestResolveClientID(t *testing.T) { + t.Setenv("GRN_LOGIN_CLIENT_ID", "") + // flag wins over env and embedded. + t.Setenv("GRN_LOGIN_CLIENT_ID", "cid-env") + got, err := resolveClientID("cid-flag", "cid-embedded") + if err != nil || got != "cid-flag" { + t.Fatalf("flag should win: got=%q err=%v", got, err) + } + // env wins over embedded. + got, err = resolveClientID("", "cid-embedded") + if err != nil || got != "cid-env" { + t.Fatalf("env should beat embedded: got=%q err=%v", got, err) + } + // embedded used when flag+env empty. + t.Setenv("GRN_LOGIN_CLIENT_ID", "") + got, err = resolveClientID("", "cid-embedded") + if err != nil || got != "cid-embedded" { + t.Fatalf("embedded fallback: got=%q err=%v", got, err) + } + // all empty → error (the defensive guard). + _, err = resolveClientID("", "") + if err == nil || !strings.Contains(err.Error(), "client-id") { + t.Fatalf("expected client-id error when all empty, got %v", err) + } +} + +// resolveIamEnv applies flag > GRN_IAM_ENV > prod default. Built on a real +// cobra command whose --iam-env flag is registered with no default (mirroring +// init), so an unset flag falls through to env. Non-parallel: mutates GRN_IAM_ENV. +func TestResolveIamEnv(t *testing.T) { + t.Setenv("GRN_IAM_ENV", "") + newCmd := func() *cobra.Command { + c := &cobra.Command{} + c.Flags().String("iam-env", "", "") + return c + } + // unset flag + unset env → prod default. + c := newCmd() + if got := resolveIamEnv(c); got != loginpkg.DefaultIamEnv { + t.Errorf("unset → %q, want prod", got) + } + // unset flag + env=dev → dev. + t.Setenv("GRN_IAM_ENV", "dev") + c = newCmd() + if got := resolveIamEnv(c); got != "dev" { + t.Errorf("env dev → %q, want dev", got) + } + // explicit flag beats env. + t.Setenv("GRN_IAM_ENV", "dev") + c = newCmd() + _ = c.Flags().Set("iam-env", "prod") + if got := resolveIamEnv(c); got != "prod" { + t.Errorf("flag prod over env dev → %q, want prod", got) + } +} + +// runLogout resolves the profile from cmd (flag → GRN_PROFILE → default) and +// clears that profile's login keys via the config-layer writer. These isolate +// HOME at a temp dir so the writer targets a throwaway ~/.greennode. Non-parallel: +// they mutate process env (HOME, GRN_PROFILE). +func TestLogout_ClearsLoginKeysKeepsMachineCreds(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("GRN_PROFILE", "") + w := config.NewConfigFileWriter() + // Seed the default profile with machine creds + a login token. + if err := w.WriteCredentials("default", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials: %v", err) + } + if err := w.WriteLoginToken("default", "rt-xyz", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + + if err := runLogout(&cobra.Command{}, nil); err != nil { + t.Fatalf("runLogout: %v", err) + } + + cfg, err := config.LoadConfig("default") + if err != nil { + t.Fatalf("LoadConfig after logout: %v", err) + } + // Login keys gone. + if cfg.RefreshToken != "" { + t.Errorf("RefreshToken=%q, want empty after logout", cfg.RefreshToken) + } + if cfg.AuthMode != "" { + t.Errorf("AuthMode=%q, want empty after logout", cfg.AuthMode) + } + // Machine creds survive logout (auth-only clear, not a credentials wipe). + if cfg.ClientID != "cid" { + t.Errorf("ClientID=%q, want cid (logout must keep machine creds)", cfg.ClientID) + } + if cfg.ClientSecret != "cs" { + t.Errorf("ClientSecret=%q, want cs", cfg.ClientSecret) + } +} + +func TestLogout_IdempotentWhenNoCredsFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("GRN_PROFILE", "") + // No credentials file at all — logout must be a no-op, not an error. + if err := runLogout(&cobra.Command{}, nil); err != nil { + t.Fatalf("runLogout on missing creds file should be a no-op, got: %v", err) + } +} + +// Ensure the commands carry non-empty Short (the cmd package's +// conventions_test.go enforces this at runtime by walking rootCmd). +func TestCommands_HaveShortHelp(t *testing.T) { + for _, c := range []*cobra.Command{LoginCmd, LogoutCmd} { + if c.Short == "" { + t.Errorf("%q has no Short", c.Name()) + } + } +} + +// TestPromptWithDefault covers the three arms of the prompt: empty input keeps +// the default, EOF keeps the default (non-interactive stdin), typed input wins. +func TestPromptWithDefault(t *testing.T) { + t.Parallel() + cases := []struct { + name string + input string + def string + want string + }{ + {"empty input keeps default", "\n", "HCM-3", "HCM-3"}, + {"EOF keeps default (non-tty)", "", "HCM-3", "HCM-3"}, + {"typed input wins", "HAN\n", "HCM-3", "HAN"}, + {"typed input trimmed", " HAN \n", "HCM-3", "HAN"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := bufio.NewReader(strings.NewReader(tc.input)) + if got := promptWithDefault(r, "Default region name", tc.def); got != tc.want { + t.Errorf("promptWithDefault(%q, %q)=%q, want %q", tc.input, tc.def, got, tc.want) + } + }) + } +} + +// TestResolveDefaultRegion covers validation: a known region passes through, an +// unknown/empty region falls back to "HCM-3" (matching `grn configure`), and an +// empty input with an existing current region keeps it. +func TestResolveDefaultRegion(t *testing.T) { + t.Parallel() + cases := []struct { + name string + input string + current string + want string + }{ + {"known region kept", "HAN\n", "", "HAN"}, + {"unknown region falls back", "bogus\n", "", "HCM-3"}, + {"empty input keeps current", "\n", "HAN", "HAN"}, + {"empty input + no current falls back", "\n", "", "HCM-3"}, + {"EOF + no current falls back", "", "", "HCM-3"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := bufio.NewReader(strings.NewReader(tc.input)) + if got := resolveDefaultRegion(r, tc.current); got != tc.want { + t.Errorf("resolveDefaultRegion(%q, current=%q)=%q, want %q", tc.input, tc.current, got, tc.want) + } + }) + } +} + +// TestPromptDefaultRegion_PreservesExistingConfig asserts the region prompt +// only changes region and leaves output/project_id untouched — login must not +// clobber a prior `grn configure` (especially an auto-detected project_id). +// +// NOT parallel: mutates process HOME. +func TestPromptDefaultRegion_PreservesExistingConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // Pre-seed a configured profile: region=HCM-3, output=table, project_id=proj-1. + if err := config.NewConfigFileWriter().WriteConfig("default", "HCM-3", "table", "proj-1"); err != nil { + t.Fatalf("seed WriteConfig: %v", err) + } + // Feed "HAN" at the prompt → region becomes HAN; output/project_id preserved. + r := bufio.NewReader(strings.NewReader("HAN\n")) + region, err := promptDefaultRegion("default", r) + if err != nil { + t.Fatalf("promptDefaultRegion: %v", err) + } + if region != "HAN" { + t.Errorf("region=%q, want HAN", region) + } + cfg, err := config.LoadConfig("default") + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Region != "HAN" { + t.Errorf("persisted region=%q, want HAN", cfg.Region) + } + if cfg.Output != "table" { + t.Errorf("output=%q, want table (preserved)", cfg.Output) + } + if cfg.ProjectID != "proj-1" { + t.Errorf("project_id=%q, want proj-1 (preserved)", cfg.ProjectID) + } +} + +// TestPromptDefaultRegion_BootstrapsFreshProfile asserts a brand-new profile +// (no config file) gets a usable default: non-interactive stdin (EOF) falls +// back to "HCM-3" and output defaults to "json", so a login-only profile is +// immediately ready for vks/vserver without a separate `grn configure`. +// +// NOT parallel: mutates process HOME. +func TestPromptDefaultRegion_BootstrapsFreshProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // No config file yet; EOF stdin (headless login). + r := bufio.NewReader(strings.NewReader("")) + region, err := promptDefaultRegion("default", r) + if err != nil { + t.Fatalf("promptDefaultRegion: %v", err) + } + if region != "HCM-3" { + t.Errorf("region=%q, want HCM-3 (fallback for headless fresh profile)", region) + } + // The config file must now exist with region=HCM-3, output=json. + iniFile, err := ini.Load(filepath.Join(config.DefaultConfigDir(), "config")) + if err != nil { + t.Fatalf("config file not written: %v", err) + } + sec, err := iniFile.GetSection("default") + if err != nil { + t.Fatalf("no [default] section: %v", err) + } + if got := sec.Key("region").String(); got != "HCM-3" { + t.Errorf("persisted region=%q, want HCM-3", got) + } + if got := sec.Key("output").String(); got != "json" { + t.Errorf("output=%q, want json (default for a fresh profile)", got) + } + // promptDefaultRegion must NOT touch the credentials file (login token only). + if _, err := os.Stat(filepath.Join(config.DefaultConfigDir(), "credentials")); err == nil { + t.Error("credentials file was created by the region prompt (should only write config)") + } +} diff --git a/go/cmd/register_agentbase.go b/go/cmd/register_agentbase.go new file mode 100644 index 0000000..282ee2f --- /dev/null +++ b/go/cmd/register_agentbase.go @@ -0,0 +1,12 @@ +//go:build agentbase + +package cmd + +// GreenNode AgentBase command group. Compiled in ONLY when the build is invoked +// with `-tags agentbase`. The default grn binary and the public release build +// (which uses `-tags vks_only`) both exclude it while agentbase is still under +// development. Drop this build constraint once agentbase is feature-complete +// and ready to ship in the default binary. +import ( + _ "github.com/vngcloud/greennode-cli/cmd/agentbase" +) diff --git a/go/cmd/root.go b/go/cmd/root.go index 69c5202..702dc3e 100644 --- a/go/cmd/root.go +++ b/go/cmd/root.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/vngcloud/greennode-cli/cmd/configure" + "github.com/vngcloud/greennode-cli/cmd/login" "github.com/vngcloud/greennode-cli/internal/cli" "github.com/vngcloud/greennode-cli/internal/client" "github.com/vngcloud/greennode-cli/internal/config" @@ -80,6 +81,8 @@ func init() { client.UserAgent = "grn-vks-cli/" + cliVersion rootCmd.AddCommand(configure.ConfigureCmd) + rootCmd.AddCommand(login.LoginCmd) + rootCmd.AddCommand(login.LogoutCmd) for _, svc := range cli.Services() { rootCmd.AddCommand(svc) } diff --git a/go/go.mod b/go/go.mod index 5c472c9..97e04f0 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,15 +1,29 @@ module github.com/vngcloud/greennode-cli -go 1.22.2 +go 1.25.0 require ( + github.com/fatih/color v1.18.0 github.com/jmespath/go-jmespath v0.4.0 + github.com/olekukonko/tablewriter v1.1.4 github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.41.0 gopkg.in/ini.v1 v1.67.1 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/displaywidth v0.10.0 // indirect + github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.6 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index a4048df..f51838d 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,13 +1,35 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= +github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= +github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -25,6 +47,13 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= diff --git a/go/internal/agentbase/auth/auth.go b/go/internal/agentbase/auth/auth.go new file mode 100644 index 0000000..3130cf4 --- /dev/null +++ b/go/internal/agentbase/auth/auth.go @@ -0,0 +1,80 @@ +// Package auth provides OAuth2 client-credentials token acquisition for the +// GreenNode AgentBase platform. +package auth + +import ( + "context" + "fmt" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// Token holds a cached access token with its expiry. +type Token struct { + AccessToken string + Expiry time.Time +} + +// IsExpired reports whether the token is expired or about to expire (within 30s). +func (t *Token) IsExpired() bool { + return time.Now().After(t.Expiry.Add(-30 * time.Second)) +} + +// Provider fetches and caches an OAuth2 access token. +type Provider struct { + mu sync.Mutex + cached *Token + clientID string + clientSecret string + tokenURL string +} + +// NewProvider creates a new token Provider for the given credentials and token URL. +func NewProvider(clientID, clientSecret, tokenURL string) *Provider { + return &Provider{ + clientID: clientID, + clientSecret: clientSecret, + tokenURL: tokenURL, + } +} + +// AccessToken returns a valid access token, fetching a new one if needed. +func (p *Provider) AccessToken(ctx context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cached != nil && !p.cached.IsExpired() { + return p.cached.AccessToken, nil + } + + cfg := &clientcredentials.Config{ + ClientID: p.clientID, + ClientSecret: p.clientSecret, + TokenURL: p.tokenURL, + } + + tok, err := cfg.Token(ctx) + if err != nil { + return "", fmt.Errorf("failed to obtain access token: %w", err) + } + + p.cached = &Token{ + AccessToken: tok.AccessToken, + Expiry: tok.Expiry, + } + + return tok.AccessToken, nil +} + +// TokenSource returns an oauth2.TokenSource compatible with standard Go OAuth2 libraries. +func (p *Provider) TokenSource(ctx context.Context) oauth2.TokenSource { + cfg := &clientcredentials.Config{ + ClientID: p.clientID, + ClientSecret: p.clientSecret, + TokenURL: p.tokenURL, + } + return cfg.TokenSource(ctx) +} diff --git a/go/internal/agentbase/auth/auth_test.go b/go/internal/agentbase/auth/auth_test.go new file mode 100644 index 0000000..4459084 --- /dev/null +++ b/go/internal/agentbase/auth/auth_test.go @@ -0,0 +1,139 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func newTestTokenServer(t *testing.T, token string, expiresIn int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenResponse{ + AccessToken: token, + TokenType: "Bearer", + ExpiresIn: expiresIn, + }) + })) +} + +func TestAccessTokenFetchSuccess(t *testing.T) { + srv := newTestTokenServer(t, "test-token-123", 3600) + defer srv.Close() + + p := NewProvider("client-id", "client-secret", srv.URL) + tok, err := p.AccessToken(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tok != "test-token-123" { + t.Errorf("expected test-token-123, got %s", tok) + } +} + +func TestAccessTokenCached(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenResponse{ + AccessToken: "cached-token", + TokenType: "Bearer", + ExpiresIn: 3600, + }) + })) + defer srv.Close() + + p := NewProvider("id", "secret", srv.URL) + _, _ = p.AccessToken(context.Background()) + _, _ = p.AccessToken(context.Background()) + _, _ = p.AccessToken(context.Background()) + + if callCount != 1 { + t.Errorf("expected 1 token fetch (cached), got %d", callCount) + } +} + +func TestAccessTokenRefreshWhenExpired(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenResponse{ + AccessToken: "refreshed-token", + TokenType: "Bearer", + ExpiresIn: 1, // 1 second — will be immediately expired for the test + }) + })) + defer srv.Close() + + p := NewProvider("id", "secret", srv.URL) + _, _ = p.AccessToken(context.Background()) + + // Force expiry. + p.cached.Expiry = time.Now().Add(-1 * time.Minute) + + _, _ = p.AccessToken(context.Background()) + if callCount != 2 { + t.Errorf("expected 2 token fetches (expired cache), got %d", callCount) + } +} + +func TestAccessTokenServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + })) + defer srv.Close() + + p := NewProvider("id", "secret", srv.URL) + _, err := p.AccessToken(context.Background()) + if err == nil { + t.Error("expected error from server error response") + } +} + +func TestTokenSource(t *testing.T) { + srv := newTestTokenServer(t, "ts-token", 3600) + defer srv.Close() + + p := NewProvider("id", "secret", srv.URL) + ts := p.TokenSource(context.Background()) + if ts == nil { + t.Fatal("expected non-nil TokenSource") + } + tok, err := ts.Token() + if err != nil { + t.Fatalf("TokenSource.Token() error: %v", err) + } + if tok.AccessToken != "ts-token" { + t.Errorf("expected ts-token, got %s", tok.AccessToken) + } +} + +func TestTokenIsExpired(t *testing.T) { + expired := &Token{Expiry: time.Now().Add(-1 * time.Hour)} + if !expired.IsExpired() { + t.Error("expected expired token to be expired") + } + + fresh := &Token{Expiry: time.Now().Add(1 * time.Hour)} + if fresh.IsExpired() { + t.Error("expected fresh token to not be expired") + } + + // Within 30s buffer + almostExpired := &Token{Expiry: time.Now().Add(15 * time.Second)} + if !almostExpired.IsExpired() { + t.Error("expected token expiring within 30s to be considered expired") + } +} diff --git a/go/internal/agentbase/auth/doc.go b/go/internal/agentbase/auth/doc.go new file mode 100644 index 0000000..0156fa6 --- /dev/null +++ b/go/internal/agentbase/auth/doc.go @@ -0,0 +1,3 @@ +// Package auth provides OAuth2 client-credentials token acquisition and caching +// for authenticating against the GreenNode AgentBase platform APIs. +package auth diff --git a/go/internal/agentbase/client/client.go b/go/internal/agentbase/client/client.go new file mode 100644 index 0000000..b4017b0 --- /dev/null +++ b/go/internal/agentbase/client/client.go @@ -0,0 +1,127 @@ +// Package client provides the base HTTP client for the GreenNode AgentBase API. +// It handles authentication, JSON serialization, and error mapping. +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/vngcloud/greennode-cli/internal/agentbase/auth" +) + +// Client is the authenticated HTTP client for a single API base URL. +type Client struct { + baseURL string + httpClient *http.Client + auth *auth.Provider +} + +// New creates a new Client for the given base URL and auth provider. +func New(baseURL string, authProvider *auth.Provider) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + auth: authProvider, + } +} + +// APIError represents an error response from the API. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error (HTTP %d): %s", e.StatusCode, e.Body) +} + +// Do executes an authenticated HTTP request and decodes the response into out. +// Pass out=nil if you do not need the response body. +func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out interface{}) error { + token, err := c.auth.AccessToken(ctx) + if err != nil { + return err + } + + fullURL := c.baseURL + path + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} + } + + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// Get performs a GET request. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out interface{}) error { + return c.Do(ctx, http.MethodGet, path, query, nil, out) +} + +// Post performs a POST request. +func (c *Client) Post(ctx context.Context, path string, body, out interface{}) error { + return c.Do(ctx, http.MethodPost, path, nil, body, out) +} + +// Patch performs a PATCH request. +func (c *Client) Patch(ctx context.Context, path string, query url.Values, body, out interface{}) error { + return c.Do(ctx, http.MethodPatch, path, query, body, out) +} + +// Put performs a PUT request. +func (c *Client) Put(ctx context.Context, path string, body, out interface{}) error { + return c.Do(ctx, http.MethodPut, path, nil, body, out) +} + +// Delete performs a DELETE request. +func (c *Client) Delete(ctx context.Context, path string, out interface{}) error { + return c.Do(ctx, http.MethodDelete, path, nil, nil, out) +} diff --git a/go/internal/agentbase/client/client_test.go b/go/internal/agentbase/client/client_test.go new file mode 100644 index 0000000..09c0058 --- /dev/null +++ b/go/internal/agentbase/client/client_test.go @@ -0,0 +1,172 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/vngcloud/greennode-cli/internal/agentbase/auth" +) + +type stubResponse struct { + Message string `json:"message"` +} + +func newTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *Client) { + t.Helper() + srv := httptest.NewServer(handler) + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"test-token","token_type":"Bearer","expires_in":3600}`)) + })) + t.Cleanup(func() { + srv.Close() + tokenSrv.Close() + }) + provider := auth.NewProvider("id", "secret", tokenSrv.URL) + c := New(srv.URL, provider) + return srv, c +} + +func TestGetSuccess(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(stubResponse{Message: "ok"}) + }) + var out stubResponse + if err := c.Get(context.Background(), "/test", nil, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Message != "ok" { + t.Errorf("expected ok, got %s", out.Message) + } +} + +func TestGetWithQuery(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") != "2" { + t.Errorf("expected page=2, got %s", r.URL.Query().Get("page")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(stubResponse{Message: "paged"}) + }) + q := url.Values{} + q.Set("page", "2") + var out stubResponse + if err := c.Get(context.Background(), "/test", q, &out); err != nil { + t.Fatal(err) + } +} + +func TestPostSuccess(t *testing.T) { + type body struct{ Name string } + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + var b body + _ = json.NewDecoder(r.Body).Decode(&b) + if b.Name != "test" { + t.Errorf("expected name=test, got %s", b.Name) + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(stubResponse{Message: "created"}) + }) + var out stubResponse + if err := c.Post(context.Background(), "/test", body{Name: "test"}, &out); err != nil { + t.Fatal(err) + } + if out.Message != "created" { + t.Errorf("expected created, got %s", out.Message) + } +} + +func TestDeleteSuccess(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + }) + if err := c.Delete(context.Background(), "/test", nil); err != nil { + t.Fatal(err) + } +} + +func TestAPIErrorReturned(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + }) + err := c.Get(context.Background(), "/missing", nil, nil) + if err == nil { + t.Fatal("expected error for 404 response") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestAuthHeaderInjected(t *testing.T) { + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token" { + t.Errorf("expected Bearer test-token, got %s", auth) + } + w.WriteHeader(http.StatusOK) + }) + _ = c.Get(context.Background(), "/test", nil, nil) +} + +func TestPatchSuccess(t *testing.T) { + type body struct{ Value int } + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(stubResponse{Message: "patched"}) + }) + var out stubResponse + if err := c.Patch(context.Background(), "/test", nil, body{Value: 1}, &out); err != nil { + t.Fatal(err) + } +} + +func TestPutSuccess(t *testing.T) { + type body struct{ Name string } + _, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(stubResponse{Message: "updated"}) + }) + var out stubResponse + if err := c.Put(context.Background(), "/test", body{Name: "x"}, &out); err != nil { + t.Fatal(err) + } + if out.Message != "updated" { + t.Errorf("expected updated, got %s", out.Message) + } +} + +func TestAPIError_Error(t *testing.T) { + e := &APIError{StatusCode: 422, Body: `{"detail":"invalid"}`} + msg := e.Error() + if msg == "" { + t.Error("expected non-empty error message") + } + if msg != "API error (HTTP 422): {\"detail\":\"invalid\"}" { + t.Errorf("unexpected error message: %s", msg) + } +} diff --git a/go/internal/agentbase/client/doc.go b/go/internal/agentbase/client/doc.go new file mode 100644 index 0000000..31af580 --- /dev/null +++ b/go/internal/agentbase/client/doc.go @@ -0,0 +1,4 @@ +// Package client provides the base HTTP client used by all GreenNode AgentBase +// service clients. It handles authentication header injection, JSON +// serialization, error mapping, and context cancellation. +package client diff --git a/go/internal/agentbase/cliinput/cliinput.go b/go/internal/agentbase/cliinput/cliinput.go new file mode 100644 index 0000000..e10e8dc --- /dev/null +++ b/go/internal/agentbase/cliinput/cliinput.go @@ -0,0 +1,198 @@ +// Package cliinput provides dual-mode input helpers for CLI commands. +// In non-interactive mode (the default), missing required values produce errors. +// In interactive mode (--interactive / -i), missing values trigger terminal prompts. +package cliinput + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + "syscall" + + "golang.org/x/term" +) + +var ( + interactive bool + sc *bufio.Scanner +) + +func init() { + sc = bufio.NewScanner(os.Stdin) +} + +// SetInteractive enables or disables interactive prompt mode. +func SetInteractive(v bool) { interactive = v } + +// IsInteractive reports whether interactive mode is active. +func IsInteractive() bool { return interactive } + +// SetReader replaces the underlying scanner reader. Use in tests to inject fake stdin. +func SetReader(r io.Reader) { sc = bufio.NewScanner(r) } + +// RequireOrPromptString returns value if non-empty. When value is empty and the +// mode is non-interactive, it returns an error naming flagName. When interactive, +// it prompts with label and requires a non-empty answer. +func RequireOrPromptString(value, flagName, label string) (string, error) { + return RequireOrPromptStringWithPlaceholder(value, flagName, label, "") +} + +// RequireOrPromptStringWithPlaceholder is like RequireOrPromptString but when +// placeholder is non-empty, the interactive prompt is "Label (placeholder): ". +func RequireOrPromptStringWithPlaceholder(value, flagName, label, placeholder string) (string, error) { + if value != "" { + return value, nil + } + if !interactive { + return "", fmt.Errorf("required flag %q not set", flagName) + } + s, err := promptLineWithPlaceholder(label, placeholder) + if err != nil { + return "", err + } + if s == "" { + return "", fmt.Errorf("%s is required", label) + } + return s, nil +} + +// RequireOrPromptSecret returns value if non-empty. When value is empty and the +// mode is non-interactive, it returns an error. When interactive, it reads from +// the terminal without echoing (falling back to plain input when not a TTY). +func RequireOrPromptSecret(value, flagName, label string) (string, error) { + if value != "" { + return value, nil + } + if !interactive { + return "", fmt.Errorf("required flag %q not set", flagName) + } + fmt.Fprintf(os.Stdout, "%s: ", label) + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stdout) + if err != nil { + // Fallback to plain-text read (e.g. when stdin is not a TTY). + if !sc.Scan() { + return "", fmt.Errorf("%s is required", label) + } + s := strings.TrimSpace(sc.Text()) + if s == "" { + return "", fmt.Errorf("%s is required", label) + } + return s, nil + } + s := strings.TrimSpace(string(pw)) + if s == "" { + return "", fmt.Errorf("%s is required", label) + } + return s, nil +} + +// RequireOrPromptStringSlice returns values if non-empty. When empty and +// non-interactive, it returns an error. When interactive, it prompts for a +// comma-separated list. +func RequireOrPromptStringSlice(values []string, flagName, label string) ([]string, error) { + if len(values) > 0 { + return values, nil + } + if !interactive { + return nil, fmt.Errorf("required flag %q not set", flagName) + } + fmt.Fprintf(os.Stdout, "%s (comma-separated): ", label) + if !sc.Scan() { + return nil, fmt.Errorf("%s is required", label) + } + raw := strings.TrimSpace(sc.Text()) + if raw == "" { + return nil, fmt.Errorf("%s is required", label) + } + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + result = append(result, trimmed) + } + } + return result, nil +} + +// PromptString reads a labelled line from stdin. Callers should only call this +// in interactive mode. +func PromptString(label string) (string, error) { + return promptLine(label) +} + +// PromptIntDefault reads an integer with a displayed default. Returns def if the +// user presses Enter without input or enters an invalid value. +func PromptIntDefault(label string, def int) int { + fmt.Fprintf(os.Stdout, "%s [%d]: ", label, def) + if !sc.Scan() { + return def + } + s := strings.TrimSpace(sc.Text()) + if s == "" { + return def + } + var v int + if _, err := fmt.Sscanf(s, "%d", &v); err != nil { + return def + } + return v +} + +// PromptChoice presents a numbered list of items and returns the selected 0-based +// index. Defaults to the first item if the user presses Enter or enters an invalid +// selection. +func PromptChoice(label string, items []string) (int, error) { + if len(items) == 0 { + return 0, fmt.Errorf("no items to choose from") + } + fmt.Fprintln(os.Stdout, label+":") + for i, item := range items { + fmt.Fprintf(os.Stdout, " [%d] %s\n", i+1, item) + } + fmt.Fprint(os.Stdout, "Select [1]: ") + if !sc.Scan() { + return 0, nil + } + s := strings.TrimSpace(sc.Text()) + if s == "" { + return 0, nil + } + var idx int + if _, err := fmt.Sscanf(s, "%d", &idx); err != nil || idx < 1 || idx > len(items) { + return 0, nil + } + return idx - 1, nil +} + +// Confirm asks a yes/no question. Returns true only when the user answers "y" or "Y". +func Confirm(prompt string) bool { + fmt.Fprintf(os.Stdout, "%s [y/N] ", prompt) + if !sc.Scan() { + return false + } + return strings.ToLower(strings.TrimSpace(sc.Text())) == "y" +} + +func promptLine(label string) (string, error) { + return promptLineWithPlaceholder(label, "") +} + +func promptLineWithPlaceholder(label, placeholder string) (string, error) { + if label != "" { + if p := strings.TrimSpace(placeholder); p != "" { + fmt.Fprintf(os.Stdout, "%s (%s): ", label, p) + } else { + fmt.Fprintf(os.Stdout, "%s: ", label) + } + } + if !sc.Scan() { + if err := sc.Err(); err != nil { + return "", err + } + return "", nil + } + return strings.TrimSpace(sc.Text()), nil +} diff --git a/go/internal/agentbase/cliinput/cliinput_test.go b/go/internal/agentbase/cliinput/cliinput_test.go new file mode 100644 index 0000000..7ab74f7 --- /dev/null +++ b/go/internal/agentbase/cliinput/cliinput_test.go @@ -0,0 +1,238 @@ +package cliinput + +import ( + "strings" + "testing" +) + +func setup(t *testing.T, input string, interactive_ bool) { + t.Helper() + t.Cleanup(func() { + SetInteractive(false) + SetReader(strings.NewReader("")) + }) + SetInteractive(interactive_) + SetReader(strings.NewReader(input)) +} + +func TestRequireOrPromptString_ValueProvided(t *testing.T) { + setup(t, "", false) + got, err := RequireOrPromptString("hello", "--name", "Name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "hello" { + t.Errorf("expected 'hello', got %q", got) + } +} + +func TestRequireOrPromptString_NonInteractive_Empty(t *testing.T) { + setup(t, "", false) + _, err := RequireOrPromptString("", "--name", "Name") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestRequireOrPromptString_Interactive_UserInput(t *testing.T) { + setup(t, "my-name\n", true) + got, err := RequireOrPromptString("", "--name", "Name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "my-name" { + t.Errorf("expected 'my-name', got %q", got) + } +} + +func TestRequireOrPromptStringWithPlaceholder_Interactive_UserInput(t *testing.T) { + setup(t, "my-desc\n", true) + got, err := RequireOrPromptStringWithPlaceholder("", "--description", "Description", "hint text") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "my-desc" { + t.Errorf("expected 'my-desc', got %q", got) + } +} + +func TestRequireOrPromptString_Interactive_EmptyInput(t *testing.T) { + setup(t, "\n", true) + _, err := RequireOrPromptString("", "--name", "Name") + if err == nil { + t.Fatal("expected error for empty interactive input") + } +} + +func TestRequireOrPromptSecret_ValueProvided(t *testing.T) { + setup(t, "", false) + got, err := RequireOrPromptSecret("s3cr3t", "--secret", "Secret") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "s3cr3t" { + t.Errorf("expected 's3cr3t', got %q", got) + } +} + +func TestRequireOrPromptSecret_NonInteractive_Empty(t *testing.T) { + setup(t, "", false) + _, err := RequireOrPromptSecret("", "--secret", "Secret") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestRequireOrPromptStringSlice_ValueProvided(t *testing.T) { + setup(t, "", false) + got, err := RequireOrPromptStringSlice([]string{"a", "b"}, "--scope", "Scopes") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("unexpected result: %v", got) + } +} + +func TestRequireOrPromptStringSlice_NonInteractive_Empty(t *testing.T) { + setup(t, "", false) + _, err := RequireOrPromptStringSlice(nil, "--scope", "Scopes") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestRequireOrPromptStringSlice_Interactive_CommaSeparated(t *testing.T) { + setup(t, "read, write , admin\n", true) + got, err := RequireOrPromptStringSlice(nil, "--scope", "Scopes") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 3 || got[0] != "read" || got[1] != "write" || got[2] != "admin" { + t.Errorf("unexpected result: %v", got) + } +} + +func TestRequireOrPromptStringSlice_Interactive_Empty(t *testing.T) { + setup(t, "\n", true) + _, err := RequireOrPromptStringSlice(nil, "--scope", "Scopes") + if err == nil { + t.Fatal("expected error for empty interactive input") + } +} + +func TestPromptIntDefault_Valid(t *testing.T) { + setup(t, "3\n", true) + got := PromptIntDefault("Replicas", 1) + if got != 3 { + t.Errorf("expected 3, got %d", got) + } +} + +func TestPromptIntDefault_Empty_UsesDefault(t *testing.T) { + setup(t, "\n", true) + got := PromptIntDefault("Replicas", 5) + if got != 5 { + t.Errorf("expected 5, got %d", got) + } +} + +func TestPromptIntDefault_Invalid_UsesDefault(t *testing.T) { + setup(t, "notanumber\n", true) + got := PromptIntDefault("Replicas", 2) + if got != 2 { + t.Errorf("expected 2 (default), got %d", got) + } +} + +func TestPromptChoice_SelectSecond(t *testing.T) { + setup(t, "2\n", true) + idx, err := PromptChoice("Pick one", []string{"alpha", "beta", "gamma"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 1 { + t.Errorf("expected index 1 (beta), got %d", idx) + } +} + +func TestPromptChoice_EmptyInput_DefaultsToFirst(t *testing.T) { + setup(t, "\n", true) + idx, err := PromptChoice("Pick one", []string{"alpha", "beta"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 0 { + t.Errorf("expected index 0, got %d", idx) + } +} + +func TestPromptChoice_OutOfRange_DefaultsToFirst(t *testing.T) { + setup(t, "99\n", true) + idx, err := PromptChoice("Pick one", []string{"alpha", "beta"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 0 { + t.Errorf("expected index 0, got %d", idx) + } +} + +func TestPromptChoice_NoItems(t *testing.T) { + setup(t, "", true) + _, err := PromptChoice("Pick one", nil) + if err == nil { + t.Fatal("expected error for empty items list") + } +} + +func TestConfirm_Yes(t *testing.T) { + setup(t, "y\n", true) + if !Confirm("Proceed?") { + t.Error("expected true for 'y'") + } +} + +func TestConfirm_UpperY(t *testing.T) { + setup(t, "Y\n", true) + if !Confirm("Proceed?") { + t.Error("expected true for 'Y'") + } +} + +func TestConfirm_No(t *testing.T) { + setup(t, "n\n", true) + if Confirm("Proceed?") { + t.Error("expected false for 'n'") + } +} + +func TestConfirm_Empty(t *testing.T) { + setup(t, "\n", true) + if Confirm("Proceed?") { + t.Error("expected false for empty input") + } +} + +func TestPromptString(t *testing.T) { + setup(t, "typed-value\n", true) + got, err := PromptString("Enter value") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "typed-value" { + t.Errorf("expected 'typed-value', got %q", got) + } +} + +func TestIsInteractive(t *testing.T) { + SetInteractive(false) + if IsInteractive() { + t.Error("expected false") + } + SetInteractive(true) + if !IsInteractive() { + t.Error("expected true") + } + SetInteractive(false) +} diff --git a/go/internal/agentbase/config/config.go b/go/internal/agentbase/config/config.go new file mode 100644 index 0000000..a8fd54c --- /dev/null +++ b/go/internal/agentbase/config/config.go @@ -0,0 +1,232 @@ +// Package config handles environment context resolution and credential management +// for the GreenNode AgentBase CLI. +// +// Resolution priority (first wins): +// 1. --env flag (passed as envOverride to LoadWithEnv) +// 2. Environment variable (e.g. GREENNODE_ENV) +// 3. Field in ./.greennode.json (current working directory) +// 4. Default value (prod) +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Env represents a deployment environment. +type Env string + +const ( + EnvDev Env = "dev" + EnvProd Env = "prod" +) + +// Endpoints holds the resolved API base URLs for a given environment. +type Endpoints struct { + Identity string + Runtime string + Memory string + OAuth2Token string + Gateway string + Cr string + Policy string +} + +var endpointsByEnv = map[Env]Endpoints{ + EnvDev: { + Identity: "https://agentbase.api-dev.vngcloud.tech/identity", + Runtime: "https://pub-iamapis.api-dev.vngcloud.tech/agent-core-runtime", + Memory: "https://pub-iamapis.api-dev.vngcloud.tech/agent-core-memory", + OAuth2Token: "https://pub-iamapis.api-dev.vngcloud.tech/accounts-api/v2/auth/token", + Gateway: "https://agentbase.api-dev.vngcloud.tech/gateway", + Cr: "https://agentbase.api-dev.vngcloud.tech/cr", + Policy: "https://agentbase.api-dev.vngcloud.tech/policy", + }, + EnvProd: { + Identity: "https://agentbase.api.vngcloud.vn/identity", + Runtime: "https://agentbase.api.vngcloud.vn/runtime", + Memory: "https://agentbase.api.vngcloud.vn/memory", + OAuth2Token: "https://iam.api.vngcloud.vn/accounts-api/v2/auth/token", + Gateway: "https://agentbase.api.vngcloud.vn/gateway", + Cr: "https://agentbase.api.vngcloud.vn/cr", + Policy: "https://agentbase.api.vngcloud.vn/policy", + }, +} + +// file is the on-disk representation of ./.greennode.json. +type file struct { + Env string `json:"env,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + AgentIdentity string `json:"agent_identity,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + RegistryUsername string `json:"registry_username,omitempty"` + RegistryPassword string `json:"registry_password,omitempty"` +} + +// Config holds all resolved configuration values. +type Config struct { + Env Env + ClientID string + ClientSecret string + AgentIdentity string + RegistryURL string + RegistryUsername string + RegistryPassword string + Endpoints Endpoints +} + +// LoadWithEnv resolves the active configuration, using envOverride as the +// highest-priority source for the environment field when it is non-empty. +// envOverride must be "dev" or "prod" if provided; any other value returns an error. +func LoadWithEnv(envOverride string) (*Config, error) { + if envOverride != "" && envOverride != string(EnvDev) && envOverride != string(EnvProd) { + return nil, fmt.Errorf("invalid environment %q: must be 'dev' or 'prod'", envOverride) + } + + f, _ := readFile() + + var env Env + if envOverride != "" { + env = Env(envOverride) + } else { + env = resolveEnv(f) + } + + eps, ok := endpointsByEnv[env] + if !ok { + eps = endpointsByEnv[EnvProd] + } + + return &Config{ + Env: env, + ClientID: resolveString("GREENNODE_CLIENT_ID", f.ClientID), + ClientSecret: resolveString("GREENNODE_CLIENT_SECRET", f.ClientSecret), + AgentIdentity: resolveString("GREENNODE_AGENT_IDENTITY", f.AgentIdentity), + RegistryURL: resolveString("GREENNODE_REGISTRY_URL", f.RegistryURL), + RegistryUsername: resolveString("GREENNODE_REGISTRY_USERNAME", f.RegistryUsername), + RegistryPassword: resolveString("GREENNODE_REGISTRY_PASSWORD", f.RegistryPassword), + Endpoints: eps, + }, nil +} + +// Load resolves the active configuration using the priority chain. +// To apply a per-invocation environment override, use LoadWithEnv instead. +func Load() (*Config, error) { + return LoadWithEnv("") +} + +// MustLoad loads config and exits on error. +func MustLoad() *Config { + cfg, err := Load() + if err != nil { + fmt.Fprintln(os.Stderr, "Error loading config:", err) + os.Exit(1) + } + return cfg +} + +// RequireCredentials ensures client_id and client_secret are set. +func (c *Config) RequireCredentials() error { + if c.ClientID == "" || c.ClientSecret == "" { + return fmt.Errorf("credentials not set — run 'greennode identity login' to authenticate") + } + return nil +} + +// SetEnv writes the environment to ./.greennode.json. +func SetEnv(env Env) error { + if env != EnvDev && env != EnvProd { + return fmt.Errorf("invalid environment %q: must be 'dev' or 'prod'", env) + } + f, _ := readFile() + f.Env = string(env) + return writeFile(f) +} + +// SaveCredentials writes client_id and client_secret to ./.greennode.json. +func SaveCredentials(clientID, clientSecret string) error { + f, _ := readFile() + f.ClientID = clientID + f.ClientSecret = clientSecret + return writeFile(f) +} + +// SaveAgentIdentity writes agent_identity to ./.greennode.json. +func SaveAgentIdentity(name string) error { + f, _ := readFile() + f.AgentIdentity = name + return writeFile(f) +} + +// ClearCredentials removes credentials from ./.greennode.json. +func ClearCredentials() error { + f, _ := readFile() + f.ClientID = "" + f.ClientSecret = "" + f.AgentIdentity = "" + return writeFile(f) +} + +// GetEndpoints returns the resolved endpoints for a given environment string. +func GetEndpoints(env Env) Endpoints { + if eps, ok := endpointsByEnv[env]; ok { + return eps + } + return endpointsByEnv[EnvProd] +} + +func resolveEnv(f *file) Env { + if v := os.Getenv("GREENNODE_ENV"); v != "" { + return Env(v) + } + if f.Env != "" { + return Env(f.Env) + } + return EnvProd +} + +func resolveString(envKey, fileVal string) string { + if v := os.Getenv(envKey); v != "" { + return v + } + return fileVal +} + +func configFilePath() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(cwd, ".greennode.json"), nil +} + +func readFile() (*file, error) { + path, err := configFilePath() + if err != nil { + return &file{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return &file{}, nil + } + var f file + if err := json.Unmarshal(data, &f); err != nil { + return &file{}, err + } + return &f, nil +} + +func writeFile(f *file) error { + path, err := configFilePath() + if err != nil { + return err + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} diff --git a/go/internal/agentbase/config/config_test.go b/go/internal/agentbase/config/config_test.go new file mode 100644 index 0000000..5f1628b --- /dev/null +++ b/go/internal/agentbase/config/config_test.go @@ -0,0 +1,360 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// withTempCwd changes the working directory to a temporary directory for the +// duration of the test, then restores the original directory. +func withTempCwd(t *testing.T, f func()) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + tmp := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(orig) + }() + f() +} + +func writeConfig(t *testing.T, data file) { + t.Helper() + cwd, _ := os.Getwd() + path := filepath.Join(cwd, ".greennode.json") + b, _ := json.Marshal(data) + if err := os.WriteFile(path, b, 0600); err != nil { + t.Fatalf("failed to write test config: %v", err) + } +} + +func TestDefaultEnvIsProd(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_ENV") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Env != EnvProd { + t.Errorf("expected prod, got %s", cfg.Env) + } + }) +} + +func TestEnvVarOverridesConfig(t *testing.T) { + withTempCwd(t, func() { + writeConfig(t, file{Env: "prod"}) + t.Setenv("GREENNODE_ENV", "dev") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Env != EnvDev { + t.Errorf("expected dev, got %s", cfg.Env) + } + }) +} + +func TestConfigFileEnv(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_ENV") + writeConfig(t, file{Env: "dev"}) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Env != EnvDev { + t.Errorf("expected dev, got %s", cfg.Env) + } + }) +} + +func TestCredentialsFromEnvVar(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_ENV") + t.Setenv("GREENNODE_CLIENT_ID", "env-id") + t.Setenv("GREENNODE_CLIENT_SECRET", "env-secret") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.ClientID != "env-id" { + t.Errorf("expected env-id, got %s", cfg.ClientID) + } + if cfg.ClientSecret != "env-secret" { + t.Errorf("expected env-secret, got %s", cfg.ClientSecret) + } + }) +} + +func TestCredentialsFromFile(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_CLIENT_ID") + os.Unsetenv("GREENNODE_CLIENT_SECRET") + writeConfig(t, file{ClientID: "file-id", ClientSecret: "file-secret"}) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.ClientID != "file-id" { + t.Errorf("expected file-id, got %s", cfg.ClientID) + } + }) +} + +func TestEnvVarOverridesFileCredentials(t *testing.T) { + withTempCwd(t, func() { + writeConfig(t, file{ClientID: "file-id", ClientSecret: "file-secret"}) + t.Setenv("GREENNODE_CLIENT_ID", "env-id") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.ClientID != "env-id" { + t.Errorf("env var should override file: expected env-id, got %s", cfg.ClientID) + } + }) +} + +func TestRequireCredentials(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_CLIENT_ID") + os.Unsetenv("GREENNODE_CLIENT_SECRET") + cfg, _ := Load() + if err := cfg.RequireCredentials(); err == nil { + t.Error("expected error for missing credentials") + } + }) +} + +func TestRequireCredentialsSuccess(t *testing.T) { + withTempCwd(t, func() { + t.Setenv("GREENNODE_CLIENT_ID", "id") + t.Setenv("GREENNODE_CLIENT_SECRET", "secret") + cfg, _ := Load() + if err := cfg.RequireCredentials(); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) +} + +func TestSetEnvWritesFile(t *testing.T) { + withTempCwd(t, func() { + if err := SetEnv(EnvDev); err != nil { + t.Fatal(err) + } + f, err := readFile() + if err != nil { + t.Fatal(err) + } + if f.Env != "dev" { + t.Errorf("expected dev, got %s", f.Env) + } + }) +} + +func TestSetEnvInvalid(t *testing.T) { + withTempCwd(t, func() { + if err := SetEnv("staging"); err == nil { + t.Error("expected error for invalid env") + } + }) +} + +func TestSaveCredentials(t *testing.T) { + withTempCwd(t, func() { + if err := SaveCredentials("my-id", "my-secret"); err != nil { + t.Fatal(err) + } + f, _ := readFile() + if f.ClientID != "my-id" || f.ClientSecret != "my-secret" { + t.Errorf("credentials not saved: id=%s secret=%s", f.ClientID, f.ClientSecret) + } + }) +} + +func TestClearCredentials(t *testing.T) { + withTempCwd(t, func() { + writeConfig(t, file{ClientID: "id", ClientSecret: "secret", AgentIdentity: "identity"}) + if err := ClearCredentials(); err != nil { + t.Fatal(err) + } + f, _ := readFile() + if f.ClientID != "" || f.ClientSecret != "" || f.AgentIdentity != "" { + t.Error("credentials not cleared") + } + }) +} + +func TestDevEndpoints(t *testing.T) { + eps := GetEndpoints(EnvDev) + if eps.Identity == "" || eps.Runtime == "" || eps.Memory == "" || eps.OAuth2Token == "" { + t.Error("dev endpoints should not be empty") + } + if eps.Identity == GetEndpoints(EnvProd).Identity { + t.Error("dev and prod identity endpoints should differ") + } +} + +func TestProdEndpoints(t *testing.T) { + eps := GetEndpoints(EnvProd) + if eps.Identity == "" || eps.Runtime == "" { + t.Error("prod endpoints should not be empty") + } +} + +func TestLoadResolvesDevEndpoints(t *testing.T) { + withTempCwd(t, func() { + t.Setenv("GREENNODE_ENV", "dev") + cfg, _ := Load() + devEps := GetEndpoints(EnvDev) + if cfg.Endpoints.Identity != devEps.Identity { + t.Errorf("expected dev identity endpoint, got %s", cfg.Endpoints.Identity) + } + }) +} + +func TestMustLoad(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_ENV") + cfg := MustLoad() + if cfg == nil { + t.Fatal("expected non-nil config from MustLoad") + } + if cfg.Env != EnvProd { + t.Errorf("expected prod, got %s", cfg.Env) + } + }) +} + +func TestSaveAgentIdentity(t *testing.T) { + withTempCwd(t, func() { + if err := SaveAgentIdentity("my-agent"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.AgentIdentity != "my-agent" { + t.Errorf("expected my-agent, got %s", cfg.AgentIdentity) + } + // Overwrite to verify it replaces correctly. + if err := SaveAgentIdentity("other-agent"); err != nil { + t.Fatal(err) + } + cfg, _ = Load() + if cfg.AgentIdentity != "other-agent" { + t.Errorf("expected other-agent, got %s", cfg.AgentIdentity) + } + }) +} + +func TestLoadWithEnvOverridesDev(t *testing.T) { + withTempCwd(t, func() { + writeConfig(t, file{Env: "prod"}) + t.Setenv("GREENNODE_ENV", "prod") + cfg, err := LoadWithEnv("dev") + if err != nil { + t.Fatal(err) + } + if cfg.Env != EnvDev { + t.Errorf("expected dev, got %s", cfg.Env) + } + devEps := GetEndpoints(EnvDev) + if cfg.Endpoints.Identity != devEps.Identity { + t.Errorf("expected dev identity endpoint, got %s", cfg.Endpoints.Identity) + } + }) +} + +func TestLoadWithEnvOverridesProd(t *testing.T) { + withTempCwd(t, func() { + writeConfig(t, file{Env: "dev"}) + t.Setenv("GREENNODE_ENV", "dev") + cfg, err := LoadWithEnv("prod") + if err != nil { + t.Fatal(err) + } + if cfg.Env != EnvProd { + t.Errorf("expected prod, got %s", cfg.Env) + } + prodEps := GetEndpoints(EnvProd) + if cfg.Endpoints.Runtime != prodEps.Runtime { + t.Errorf("expected prod runtime endpoint, got %s", cfg.Endpoints.Runtime) + } + }) +} + +func TestLoadWithEnvInvalidReturnsError(t *testing.T) { + withTempCwd(t, func() { + _, err := LoadWithEnv("staging") + if err == nil { + t.Fatal("expected error for invalid env override") + } + }) +} + +func TestLoadWithEnvEmptyFallsThrough(t *testing.T) { + withTempCwd(t, func() { + os.Unsetenv("GREENNODE_ENV") + writeConfig(t, file{Env: "dev"}) + cfg, err := LoadWithEnv("") + if err != nil { + t.Fatal(err) + } + if cfg.Env != EnvDev { + t.Errorf("empty override should fall through to config file: expected dev, got %s", cfg.Env) + } + }) +} + +func TestRegistryFromFile(t *testing.T) { + withTempCwd(t, func() { + t.Setenv("GREENNODE_REGISTRY_URL", "") + t.Setenv("GREENNODE_REGISTRY_USERNAME", "") + t.Setenv("GREENNODE_REGISTRY_PASSWORD", "") + writeConfig(t, file{ + RegistryURL: "https://reg.file", + RegistryUsername: "file-user", + RegistryPassword: "file-pass", + }) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.RegistryURL != "https://reg.file" || cfg.RegistryUsername != "file-user" || cfg.RegistryPassword != "file-pass" { + t.Fatalf("unexpected registry: %+v", cfg) + } + }) +} + +func TestRegistryEnvOverridesFile(t *testing.T) { + withTempCwd(t, func() { + writeConfig(t, file{ + RegistryURL: "https://from-file", + RegistryUsername: "from-file-user", + RegistryPassword: "from-file-pass", + }) + t.Setenv("GREENNODE_REGISTRY_URL", "https://from-env") + t.Setenv("GREENNODE_REGISTRY_USERNAME", "") + t.Setenv("GREENNODE_REGISTRY_PASSWORD", "") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.RegistryURL != "https://from-env" { + t.Errorf("expected env URL, got %q", cfg.RegistryURL) + } + if cfg.RegistryUsername != "from-file-user" { + t.Errorf("expected file username when env unset, got %q", cfg.RegistryUsername) + } + }) +} diff --git a/go/internal/agentbase/config/doc.go b/go/internal/agentbase/config/doc.go new file mode 100644 index 0000000..d48b4b9 --- /dev/null +++ b/go/internal/agentbase/config/doc.go @@ -0,0 +1,8 @@ +// Package config resolves the active environment context and manages credentials +// for the GreenNode AgentBase CLI. +// +// Resolution priority (first wins for each field): +// 1. Environment variable (GREENNODE_ENV, GREENNODE_CLIENT_ID, etc.) +// 2. Corresponding field in ./.greennode.json (current working directory) +// 3. Default value (env defaults to "prod") +package config diff --git a/go/internal/agentbase/config/json.go b/go/internal/agentbase/config/json.go new file mode 100644 index 0000000..760c2f7 --- /dev/null +++ b/go/internal/agentbase/config/json.go @@ -0,0 +1,10 @@ +package config + +import ( + "encoding/json" +) + +// UnmarshalJSON unmarshals JSON data into a value. +func UnmarshalJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} diff --git a/go/internal/agentbase/identity/client.go b/go/internal/agentbase/identity/client.go new file mode 100644 index 0000000..7441af6 --- /dev/null +++ b/go/internal/agentbase/identity/client.go @@ -0,0 +1,228 @@ +package identity + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/vngcloud/greennode-cli/internal/agentbase/auth" + "github.com/vngcloud/greennode-cli/internal/agentbase/client" +) + +// Client is the API client for the Identity service. +type Client struct { + http *client.Client +} + +// NewClient creates a new identity Client. +func NewClient(baseURL string, authProvider *auth.Provider) *Client { + return &Client{http: client.New(baseURL, authProvider)} +} + +// --- Agent Identities --- + +// ListAgentIdentities returns a paginated list of agent identities. +func (c *Client) ListAgentIdentities(ctx context.Context, page, size int) (*PagedResponseAgentIdentityResponse, error) { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("size", strconv.Itoa(size)) + var out PagedResponseAgentIdentityResponse + if err := c.http.Get(ctx, "/api/v1/agent-identities", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateAgentIdentity creates a new agent identity. +func (c *Client) CreateAgentIdentity(ctx context.Context, req *CreateAgentIdentityRequest) (*AgentIdentityResponse, error) { + var out AgentIdentityResponse + if err := c.http.Post(ctx, "/api/v1/agent-identities", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetAgentIdentity retrieves an agent identity by name. +func (c *Client) GetAgentIdentity(ctx context.Context, name string) (*AgentIdentityResponse, error) { + var out AgentIdentityResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/agent-identities/%s", name), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateAgentIdentity updates an existing agent identity. +func (c *Client) UpdateAgentIdentity(ctx context.Context, name string, req *UpdateAgentIdentityRequest) (*AgentIdentityResponse, error) { + var out AgentIdentityResponse + if err := c.http.Put(ctx, fmt.Sprintf("/api/v1/agent-identities/%s", name), req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteAgentIdentity deletes an agent identity by name. +func (c *Client) DeleteAgentIdentity(ctx context.Context, name string) error { + return c.http.Delete(ctx, fmt.Sprintf("/api/v1/agent-identities/%s", name), nil) +} + +// --- OAuth2 Providers --- + +// ListOauth2Providers returns a paginated list of OAuth2 providers. +func (c *Client) ListOauth2Providers(ctx context.Context, page, size int) (*PagedResponseOauth2ProviderResponse, error) { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("size", strconv.Itoa(size)) + var out PagedResponseOauth2ProviderResponse + if err := c.http.Get(ctx, "/api/v1/outbound-auth/oauth2-providers", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOauth2Provider creates a new OAuth2 provider. +func (c *Client) CreateOauth2Provider(ctx context.Context, req *CreateOauth2ProviderRequest) (*Oauth2ProviderResponse, error) { + var out Oauth2ProviderResponse + if err := c.http.Post(ctx, "/api/v1/outbound-auth/oauth2-providers", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetOauth2Provider retrieves an OAuth2 provider by name. +func (c *Client) GetOauth2Provider(ctx context.Context, name string) (*Oauth2ProviderResponse, error) { + var out Oauth2ProviderResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/outbound-auth/oauth2-providers/%s", name), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateOauth2Provider updates an existing OAuth2 provider. +func (c *Client) UpdateOauth2Provider(ctx context.Context, name string, req *UpdateOauth2ProviderRequest) error { + return c.http.Put(ctx, fmt.Sprintf("/api/v1/outbound-auth/oauth2-providers/%s", name), req, nil) +} + +// DeleteOauth2Provider deletes an OAuth2 provider by name. +func (c *Client) DeleteOauth2Provider(ctx context.Context, name string) error { + return c.http.Delete(ctx, fmt.Sprintf("/api/v1/outbound-auth/oauth2-providers/%s", name), nil) +} + +// GetM2MToken retrieves an M2M token for an agent identity via an OAuth2 provider. +func (c *Client) GetM2MToken(ctx context.Context, providerName, agentIdentityName string, req *GetM2mTokenRequest) (*TokenResponse, error) { + var out TokenResponse + path := fmt.Sprintf("/api/v1/outbound-auth/oauth2-providers/%s/agent-identities/%s/tokens/m2m", providerName, agentIdentityName) + if err := c.http.Post(ctx, path, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Get3LOToken retrieves a 3-legged OAuth2 token for an agent identity. +func (c *Client) Get3LOToken(ctx context.Context, providerName, agentIdentityName string, req *ThreeLoTokenRequest) (*ThreeLoTokenResponse, error) { + var out ThreeLoTokenResponse + path := fmt.Sprintf("/api/v1/outbound-auth/oauth2-providers/%s/agent-identities/%s/tokens/3lo", providerName, agentIdentityName) + if err := c.http.Post(ctx, path, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// --- Static API Key Providers --- + +// ListApikeyProviders returns a paginated list of static API key providers. +func (c *Client) ListApikeyProviders(ctx context.Context, page, size int) (*PagedResponseApikeyProviderResponse, error) { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("size", strconv.Itoa(size)) + var out PagedResponseApikeyProviderResponse + if err := c.http.Get(ctx, "/api/v1/outbound-auth/api-key-providers", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateApikeyProvider creates a new static API key provider. +func (c *Client) CreateApikeyProvider(ctx context.Context, req *CreateApikeyProviderRequest) (*ApikeyProviderResponse, error) { + var out ApikeyProviderResponse + if err := c.http.Post(ctx, "/api/v1/outbound-auth/api-key-providers", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetApikeyProvider retrieves a static API key provider by name. +func (c *Client) GetApikeyProvider(ctx context.Context, name string) (*ApikeyProviderResponse, error) { + var out ApikeyProviderResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/outbound-auth/api-key-providers/%s", name), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateApikeyProvider updates a static API key provider. +func (c *Client) UpdateApikeyProvider(ctx context.Context, name string, req *UpdateApikeyProviderRequest) error { + return c.http.Put(ctx, fmt.Sprintf("/api/v1/outbound-auth/api-key-providers/%s", name), req, nil) +} + +// DeleteApikeyProvider deletes a static API key provider by name. +func (c *Client) DeleteApikeyProvider(ctx context.Context, name string) error { + return c.http.Delete(ctx, fmt.Sprintf("/api/v1/outbound-auth/api-key-providers/%s", name), nil) +} + +// GetApikeyForAgentIdentity retrieves the API key for an agent identity from a provider. +func (c *Client) GetApikeyForAgentIdentity(ctx context.Context, providerName, agentIdentityName string) (*ApikeyResponse, error) { + var out ApikeyResponse + path := fmt.Sprintf("/api/v1/outbound-auth/api-key-providers/%s/agent-identities/%s/api-key", providerName, agentIdentityName) + if err := c.http.Get(ctx, path, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// --- Delegated API Key Providers --- + +// ListDelegatedProviders returns a paginated list of delegated API key providers. +func (c *Client) ListDelegatedProviders(ctx context.Context, page, size int) (*PagedResponseDelegatedApiKeyProviderResponse, error) { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("size", strconv.Itoa(size)) + var out PagedResponseDelegatedApiKeyProviderResponse + if err := c.http.Get(ctx, "/api/v1/outbound-auth/delegated-api-key-providers", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateDelegatedProvider creates a new delegated API key provider. +func (c *Client) CreateDelegatedProvider(ctx context.Context, req *CreateDelegatedApiKeyProviderRequest) (*DelegatedApiKeyProviderResponse, error) { + var out DelegatedApiKeyProviderResponse + if err := c.http.Post(ctx, "/api/v1/outbound-auth/delegated-api-key-providers", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetDelegatedProvider retrieves a delegated API key provider by name. +func (c *Client) GetDelegatedProvider(ctx context.Context, name string) (*DelegatedApiKeyProviderResponse, error) { + var out DelegatedApiKeyProviderResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/outbound-auth/delegated-api-key-providers/%s", name), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteDelegatedProvider deletes a delegated API key provider by name. +func (c *Client) DeleteDelegatedProvider(ctx context.Context, name string) error { + return c.http.Delete(ctx, fmt.Sprintf("/api/v1/outbound-auth/delegated-api-key-providers/%s", name), nil) +} + +// GetDelegatedApiKey retrieves a delegated API key for an agent identity. +func (c *Client) GetDelegatedApiKey(ctx context.Context, providerName, agentIdentityName string, req *GetDelegatedApiKeyRequest) (*GetDelegatedApiKeyResponse, error) { + var out GetDelegatedApiKeyResponse + path := fmt.Sprintf("/api/v1/outbound-auth/delegated-api-key-providers/%s/agent-identities/%s/api-key", providerName, agentIdentityName) + if err := c.http.Post(ctx, path, req, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/go/internal/agentbase/identity/client_test.go b/go/internal/agentbase/identity/client_test.go new file mode 100644 index 0000000..6afdcaa --- /dev/null +++ b/go/internal/agentbase/identity/client_test.go @@ -0,0 +1,372 @@ +package identity + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/vngcloud/greennode-cli/internal/agentbase/auth" + "github.com/vngcloud/greennode-cli/internal/agentbase/jsonslice" +) + +func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"test","token_type":"Bearer","expires_in":3600}`)) + })) + apiSrv := httptest.NewServer(handler) + t.Cleanup(func() { + apiSrv.Close() + tokenSrv.Close() + }) + provider := auth.NewProvider("id", "secret", tokenSrv.URL) + return NewClient(apiSrv.URL, provider), apiSrv +} + +func sp(s string) *string { return &s } + +func TestListAgentIdentities(t *testing.T) { + resp := PagedResponseAgentIdentityResponse{ + Content: jsonslice.Array[AgentIdentityResponse]{{ID: sp("1"), Name: sp("agent-one")}}, + } + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + out, err := c.ListAgentIdentities(context.Background(), 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out.Content) != 1 || out.Content[0].Name == nil || *out.Content[0].Name != "agent-one" { + t.Errorf("unexpected content: %+v", out.Content) + } +} + +func TestCreateAgentIdentity(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AgentIdentityResponse{ID: sp("x"), Name: sp("new-agent")}) + }) + out, err := c.CreateAgentIdentity(context.Background(), &CreateAgentIdentityRequest{Name: "new-agent"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "new-agent" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestGetAgentIdentity(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AgentIdentityResponse{ID: sp("1"), Name: sp("my-agent")}) + }) + out, err := c.GetAgentIdentity(context.Background(), "my-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "my-agent" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestDeleteAgentIdentity(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + }) + if err := c.DeleteAgentIdentity(context.Background(), "my-agent"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateApikeyProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ApikeyProviderResponse{ID: sp("1"), Name: sp("prov")}) + }) + out, err := c.CreateApikeyProvider(context.Background(), &CreateApikeyProviderRequest{Name: "prov", Apikey: "key"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "prov" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestListApikeyProviders(t *testing.T) { + resp := PagedResponseApikeyProviderResponse{ + Content: jsonslice.Array[ApikeyProviderResponse]{{ID: sp("1"), Name: sp("static-key")}}, + } + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + out, err := c.ListApikeyProviders(context.Background(), 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out.Content) != 1 { + t.Errorf("expected 1 result, got %v", out.Content) + } +} + +func TestUpdateApikeyProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + }) + if err := c.UpdateApikeyProvider(context.Background(), "prov", &UpdateApikeyProviderRequest{Apikey: "newkey"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteApikeyProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + if err := c.DeleteApikeyProvider(context.Background(), "prov"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateOauth2Provider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Oauth2ProviderResponse{ID: sp("1"), Name: sp("google")}) + }) + req := &CreateOauth2ProviderRequest{Name: "google", ClientID: "cid", ClientSecret: "cs", + AuthorizationURL: "https://auth", TokenURL: "https://token"} + out, err := c.CreateOauth2Provider(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "google" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestListOauth2Providers(t *testing.T) { + resp := PagedResponseOauth2ProviderResponse{ + Content: jsonslice.Array[Oauth2ProviderResponse]{{ID: sp("1"), Name: sp("google")}}, + } + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + out, err := c.ListOauth2Providers(context.Background(), 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out.Content) != 1 { + t.Errorf("expected 1 result, got %v", out.Content) + } +} + +func TestGetOauth2Provider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Oauth2ProviderResponse{ID: sp("1"), Name: sp("google")}) + }) + out, err := c.GetOauth2Provider(context.Background(), "google") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "google" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestDeleteOauth2Provider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + if err := c.DeleteOauth2Provider(context.Background(), "google"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateDelegatedProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(DelegatedApiKeyProviderResponse{ID: sp("1"), Name: sp("del-prov")}) + }) + out, err := c.CreateDelegatedProvider(context.Background(), &CreateDelegatedApiKeyProviderRequest{Name: "del-prov"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "del-prov" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestListDelegatedProviders(t *testing.T) { + resp := PagedResponseDelegatedApiKeyProviderResponse{ + Content: jsonslice.Array[DelegatedApiKeyProviderResponse]{{ID: sp("1"), Name: sp("dp")}}, + } + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + out, err := c.ListDelegatedProviders(context.Background(), 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out.Content) != 1 { + t.Errorf("expected 1 result, got %v", out.Content) + } +} + +func TestGetApikeyForAgentIdentity(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ApikeyResponse{Apikey: sp("secret-key")}) + }) + out, err := c.GetApikeyForAgentIdentity(context.Background(), "prov", "agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Apikey == nil || *out.Apikey != "secret-key" { + t.Errorf("unexpected apikey: %v", out.Apikey) + } +} + +func TestGetM2MToken(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TokenResponse{AccessToken: sp("m2m-token")}) + }) + out, err := c.GetM2MToken(context.Background(), "prov", "agent", &GetM2mTokenRequest{Scopes: jsonslice.Array[string]{"read"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.AccessToken == nil || *out.AccessToken != "m2m-token" { + t.Errorf("unexpected token: %v", out.AccessToken) + } +} + +func TestClientError_Returns404(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + }) + _, err := c.GetAgentIdentity(context.Background(), "missing") + if err == nil { + t.Fatal("expected error for 404 response") + } +} + +func TestUpdateAgentIdentity(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AgentIdentityResponse{ID: sp("1"), Name: sp("updated")}) + }) + desc := "desc" + out, err := c.UpdateAgentIdentity(context.Background(), "updated", &UpdateAgentIdentityRequest{Description: &desc}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "updated" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestGetApikeyProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ApikeyProviderResponse{ID: sp("1"), Name: sp("prov")}) + }) + out, err := c.GetApikeyProvider(context.Background(), "prov") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "prov" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestGetDelegatedProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(DelegatedApiKeyProviderResponse{ID: sp("1"), Name: sp("dp")}) + }) + out, err := c.GetDelegatedProvider(context.Background(), "dp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name == nil || *out.Name != "dp" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestDeleteDelegatedProvider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + if err := c.DeleteDelegatedProvider(context.Background(), "dp"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetDelegatedApiKey(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(GetDelegatedApiKeyResponse{Apikey: sp("delegated-key")}) + }) + req := &GetDelegatedApiKeyRequest{AgentUserID: "u1", ReturnURL: "https://ret"} + out, err := c.GetDelegatedApiKey(context.Background(), "prov", "agent", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Apikey == nil || *out.Apikey != "delegated-key" { + t.Errorf("unexpected apikey: %v", out.Apikey) + } +} + +func TestUpdateOauth2Provider(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + }) + req := &UpdateOauth2ProviderRequest{ClientID: "cid", ClientSecret: "cs", + AuthorizationURL: "https://auth", TokenURL: "https://token"} + if err := c.UpdateOauth2Provider(context.Background(), "google", req); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGet3LOToken(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ThreeLoTokenResponse{AccessToken: sp("3lo-token")}) + }) + req := &ThreeLoTokenRequest{AgentUserID: "u1", ReturnURL: "https://ret", Scopes: jsonslice.Array[string]{"read"}} + out, err := c.Get3LOToken(context.Background(), "prov", "agent", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.AccessToken == nil || *out.AccessToken != "3lo-token" { + t.Errorf("unexpected token: %v", out.AccessToken) + } +} + +func TestNewClient(t *testing.T) { + c := NewClient("https://example.com", nil) + if c == nil { + t.Fatal("expected non-nil client") + } +} diff --git a/go/internal/agentbase/identity/doc.go b/go/internal/agentbase/identity/doc.go new file mode 100644 index 0000000..e0c5fc5 --- /dev/null +++ b/go/internal/agentbase/identity/doc.go @@ -0,0 +1,4 @@ +// Package identity contains the API client and data models for the GreenNode +// AgentBase Identity service. It covers agent identities and outbound +// authentication providers (static API key, delegated API key, and OAuth2). +package identity diff --git a/go/internal/agentbase/identity/models.go b/go/internal/agentbase/identity/models.go new file mode 100644 index 0000000..b5c4ce5 --- /dev/null +++ b/go/internal/agentbase/identity/models.go @@ -0,0 +1,230 @@ +// Package identity contains models and the API client for the GreenNode AgentBase Identity service. +package identity + +import ( + "time" + + "github.com/vngcloud/greennode-cli/internal/agentbase/jsonslice" +) + +// --- Agent Identity --- + +// CreateAgentIdentityRequest is the request body for creating an agent identity. +// name is required (3-50 chars, pattern: ^[a-zA-Z0-9_-]+$). +type CreateAgentIdentityRequest struct { + Name string `json:"name"` + Description *string `json:"description"` + AllowedReturnURLs jsonslice.Array[string] `json:"allowedReturnUrls"` +} + +// UpdateAgentIdentityRequest is the request body for updating an agent identity. +// All fields are optional. +type UpdateAgentIdentityRequest struct { + Description *string `json:"description"` + AllowedReturnURLs jsonslice.Array[string] `json:"allowedReturnUrls"` +} + +// AgentIdentityResponse is the response model for an agent identity. +// All fields are optional per the OpenAPI schema; pointer types are used accordingly. +type AgentIdentityResponse struct { + ID *string `json:"id"` + Name *string `json:"name"` + Description *string `json:"description"` + AllowedReturnURLs jsonslice.Array[string] `json:"allowedReturnUrls"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` +} + +// PagedResponseAgentIdentityResponse is a paginated list of agent identities. +// All fields are optional per the OpenAPI schema. +type PagedResponseAgentIdentityResponse struct { + Content jsonslice.Array[AgentIdentityResponse] `json:"content"` + Page *int `json:"page"` + Size *int `json:"size"` + TotalElements *int64 `json:"totalElements"` + TotalPages *int `json:"totalPages"` + First *bool `json:"first"` + Last *bool `json:"last"` + HasNext *bool `json:"hasNext"` + HasPrevious *bool `json:"hasPrevious"` +} + +// --- OAuth2 Provider --- + +// CreateOauth2ProviderRequest is the request body for creating an OAuth2 provider. +// name, clientId, clientSecret, authorizationUrl, and tokenUrl are required. +type CreateOauth2ProviderRequest struct { + Name string `json:"name"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + AuthorizationURL string `json:"authorizationUrl"` + TokenURL string `json:"tokenUrl"` +} + +// UpdateOauth2ProviderRequest is the request body for updating an OAuth2 provider. +// clientId, clientSecret, authorizationUrl, and tokenUrl are required. +type UpdateOauth2ProviderRequest struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + AuthorizationURL string `json:"authorizationUrl"` + TokenURL string `json:"tokenUrl"` +} + +// Oauth2ProviderResponse is the response model for an OAuth2 provider. +// All fields are optional per the OpenAPI schema. +type Oauth2ProviderResponse struct { + ID *string `json:"id"` + Name *string `json:"name"` + ClientID *string `json:"clientId"` + AuthorizationURL *string `json:"authorizationUrl"` + TokenURL *string `json:"tokenUrl"` + Status *string `json:"status"` + CallbackURL *string `json:"callbackUrl"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` +} + +// PagedResponseOauth2ProviderResponse is a paginated list of OAuth2 providers. +// All fields are optional per the OpenAPI schema. +type PagedResponseOauth2ProviderResponse struct { + Content jsonslice.Array[Oauth2ProviderResponse] `json:"content"` + Page *int `json:"page"` + Size *int `json:"size"` + TotalElements *int64 `json:"totalElements"` + TotalPages *int `json:"totalPages"` + First *bool `json:"first"` + Last *bool `json:"last"` + HasNext *bool `json:"hasNext"` + HasPrevious *bool `json:"hasPrevious"` +} + +// GetM2mTokenRequest requests an M2M (client credentials) OAuth2 token. +// scopes is required (minItems: 1). +type GetM2mTokenRequest struct { + Scopes jsonslice.Array[string] `json:"scopes"` +} + +// TokenResponse holds a plain access token. +// All fields are optional per the OpenAPI schema. +type TokenResponse struct { + AccessToken *string `json:"accessToken"` + TokenType *string `json:"tokenType"` +} + +// ThreeLoTokenRequest requests a 3-legged OAuth2 token. +// agentUserId, returnUrl, and scopes are required. +type ThreeLoTokenRequest struct { + AgentUserID string `json:"agentUserId"` + Scopes jsonslice.Array[string] `json:"scopes"` + ReturnURL string `json:"returnUrl"` + SessionID *string `json:"sessionId"` + CustomParameters *map[string]string `json:"customParameters"` + CustomState *string `json:"customState"` + ForceAuthentication *bool `json:"forceAuthentication"` +} + +// ThreeLoTokenResponse holds the 3LO token response including authorization URL. +// All fields are optional per the OpenAPI schema. +type ThreeLoTokenResponse struct { + AccessToken *string `json:"accessToken"` + TokenType *string `json:"tokenType"` + AuthorizationURL *string `json:"authorizationUrl"` + SessionID *string `json:"sessionId"` + Status *string `json:"status"` +} + +// --- API Key Provider (static) --- + +// CreateApikeyProviderRequest is the request body for creating a static API key provider. +// name and apikey are required. +type CreateApikeyProviderRequest struct { + Name string `json:"name"` + Apikey string `json:"apikey"` +} + +// UpdateApikeyProviderRequest is the request body for updating an API key provider. +// apikey is required. +type UpdateApikeyProviderRequest struct { + Apikey string `json:"apikey"` +} + +// ApikeyProviderResponse is the response model for a static API key provider. +// All fields are optional per the OpenAPI schema. +type ApikeyProviderResponse struct { + ID *string `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` +} + +// PagedResponseApikeyProviderResponse is a paginated list of API key providers. +// All fields are optional per the OpenAPI schema. +type PagedResponseApikeyProviderResponse struct { + Content jsonslice.Array[ApikeyProviderResponse] `json:"content"` + Page *int `json:"page"` + Size *int `json:"size"` + TotalElements *int64 `json:"totalElements"` + TotalPages *int `json:"totalPages"` + First *bool `json:"first"` + Last *bool `json:"last"` + HasNext *bool `json:"hasNext"` + HasPrevious *bool `json:"hasPrevious"` +} + +// ApikeyResponse holds the API key value for a specific agent identity. +// All fields are optional per the OpenAPI schema. +type ApikeyResponse struct { + Apikey *string `json:"apikey"` +} + +// --- Delegated API Key Provider --- + +// CreateDelegatedApiKeyProviderRequest is the request body for creating a delegated API key provider. +// name is required. +type CreateDelegatedApiKeyProviderRequest struct { + Name string `json:"name"` +} + +// DelegatedApiKeyProviderResponse is the response model for a delegated API key provider. +// All fields are optional per the OpenAPI schema. +type DelegatedApiKeyProviderResponse struct { + ID *string `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` +} + +// PagedResponseDelegatedApiKeyProviderResponse is a paginated list of delegated API key providers. +// All fields are optional per the OpenAPI schema. +type PagedResponseDelegatedApiKeyProviderResponse struct { + Content jsonslice.Array[DelegatedApiKeyProviderResponse] `json:"content"` + Page *int `json:"page"` + Size *int `json:"size"` + TotalElements *int64 `json:"totalElements"` + TotalPages *int `json:"totalPages"` + First *bool `json:"first"` + Last *bool `json:"last"` + HasNext *bool `json:"hasNext"` + HasPrevious *bool `json:"hasPrevious"` +} + +// GetDelegatedApiKeyRequest is the request body for obtaining a delegated API key. +// agentUserId and returnUrl are required. +type GetDelegatedApiKeyRequest struct { + AgentUserID string `json:"agentUserId"` + ReturnURL string `json:"returnUrl"` + CustomState *string `json:"customState"` + SessionID *string `json:"sessionId"` + ForceDelegation *bool `json:"forceDelegation"` +} + +// GetDelegatedApiKeyResponse holds the result of a delegated API key request. +// All fields are optional per the OpenAPI schema. +type GetDelegatedApiKeyResponse struct { + Apikey *string `json:"apikey"` + AuthorizationURL *string `json:"authorizationUrl"` + SessionID *string `json:"sessionId"` + Status *string `json:"status"` +} diff --git a/go/internal/agentbase/identity/models_test.go b/go/internal/agentbase/identity/models_test.go new file mode 100644 index 0000000..0ff5908 --- /dev/null +++ b/go/internal/agentbase/identity/models_test.go @@ -0,0 +1,150 @@ +package identity + +import ( + "encoding/json" + "testing" + + "github.com/vngcloud/greennode-cli/internal/agentbase/jsonslice" +) + +func TestCreateAgentIdentityRequestJSON(t *testing.T) { + req := CreateAgentIdentityRequest{Name: "my-agent"} + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if m["name"] != "my-agent" { + t.Error("name field missing") + } +} + +func TestUpdateAgentIdentityRequestAllOptional(t *testing.T) { + req := UpdateAgentIdentityRequest{} + b, _ := json.Marshal(req) + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["description"]; !ok { + t.Error("expected key \"description\" to be present") + } else if m["description"] != nil { + t.Errorf("expected description null, got %v", m["description"]) + } + if _, ok := m["allowedReturnUrls"]; !ok { + t.Error("expected key \"allowedReturnUrls\" to be present") + } else { + ar, ok := m["allowedReturnUrls"].([]interface{}) + if !ok || len(ar) != 0 { + t.Errorf("expected allowedReturnUrls empty array, got %v", m["allowedReturnUrls"]) + } + } +} + +func TestCreateOauth2ProviderRequestAllRequired(t *testing.T) { + req := CreateOauth2ProviderRequest{ + Name: "provider-1", + ClientID: "cid", + ClientSecret: "csecret", + AuthorizationURL: "https://auth.example.com/authorize", + TokenURL: "https://auth.example.com/token", + } + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if m["name"] != "provider-1" || m["clientId"] != "cid" { + t.Errorf("fields wrong: %v", m) + } + if m["authorizationUrl"] == nil || m["tokenUrl"] == nil { + t.Error("authorizationUrl / tokenUrl fields missing") + } +} + +func TestGetM2mTokenRequestScopes(t *testing.T) { + req := GetM2mTokenRequest{Scopes: jsonslice.Array[string]{"read", "write"}} + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + scopes, _ := m["scopes"].([]interface{}) + if len(scopes) != 2 { + t.Errorf("expected 2 scopes, got %d", len(scopes)) + } +} + +func TestThreeLoTokenRequestRequiredFields(t *testing.T) { + req := ThreeLoTokenRequest{ + AgentUserID: "user-123", + ReturnURL: "https://callback.example.com", + Scopes: jsonslice.Array[string]{"openid"}, + } + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if m["agentUserId"] != "user-123" { + t.Error("agentUserId field missing") + } + if m["returnUrl"] != "https://callback.example.com" { + t.Error("returnUrl field missing") + } +} + +func TestThreeLoTokenRequestOptionalFieldsNull(t *testing.T) { + req := ThreeLoTokenRequest{AgentUserID: "u", ReturnURL: "https://cb", Scopes: jsonslice.Array[string]{"x"}} + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, k := range []string{"sessionId", "customParameters", "customState", "forceAuthentication"} { + if _, ok := m[k]; !ok { + t.Errorf("expected key %q to be present", k) + continue + } + if m[k] != nil { + t.Errorf("expected key %q to be null, got %v", k, m[k]) + } + } +} + +func TestCreateApikeyProviderRequestJSON(t *testing.T) { + req := CreateApikeyProviderRequest{Name: "my-key", Apikey: "secret-value"} + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if m["name"] != "my-key" || m["apikey"] != "secret-value" { + t.Errorf("fields wrong: %v", m) + } +} + +func TestGetDelegatedApiKeyRequestRequiredFields(t *testing.T) { + req := GetDelegatedApiKeyRequest{ + AgentUserID: "u1", + ReturnURL: "https://return.example.com", + } + b, _ := json.Marshal(req) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if m["agentUserId"] != "u1" || m["returnUrl"] != "https://return.example.com" { + t.Errorf("required fields missing: %v", m) + } +} + +func TestPagedResponseUnmarshal(t *testing.T) { + payload := `{ + "content": [{"id":"1","name":"agent-1"}], + "page": 0, + "size": 20, + "totalElements": 1, + "totalPages": 1, + "first": true, + "last": true, + "hasNext": false, + "hasPrevious": false + }` + var resp PagedResponseAgentIdentityResponse + if err := json.Unmarshal([]byte(payload), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Content) != 1 || resp.Content[0].Name == nil || *resp.Content[0].Name != "agent-1" { + t.Errorf("unexpected response content: %+v", resp.Content) + } + if resp.TotalElements == nil || *resp.TotalElements != 1 { + t.Errorf("expected totalElements=1, got %v", resp.TotalElements) + } +} diff --git a/go/internal/agentbase/jsonslice/array.go b/go/internal/agentbase/jsonslice/array.go new file mode 100644 index 0000000..85c7c7c --- /dev/null +++ b/go/internal/agentbase/jsonslice/array.go @@ -0,0 +1,29 @@ +package jsonslice + +import "encoding/json" + +// Array is a JSON array that marshals a nil underlying slice as [] and +// unmarshals JSON null to an empty slice. +type Array[T any] []T + +// MarshalJSON implements json.Marshaler. Nil Array encodes as []. +func (a Array[T]) MarshalJSON() ([]byte, error) { + if a == nil { + return []byte("[]"), nil + } + return json.Marshal([]T(a)) +} + +// UnmarshalJSON implements json.Unmarshaler. JSON null decodes as an empty Array. +func (a *Array[T]) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + *a = Array[T]{} + return nil + } + var s []T + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *a = Array[T](s) + return nil +} diff --git a/go/internal/agentbase/jsonslice/array_test.go b/go/internal/agentbase/jsonslice/array_test.go new file mode 100644 index 0000000..11bae57 --- /dev/null +++ b/go/internal/agentbase/jsonslice/array_test.go @@ -0,0 +1,77 @@ +package jsonslice + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestArray_MarshalJSON_nilIsEmptyArray(t *testing.T) { + var a Array[string] + b, err := json.Marshal(struct { + X Array[string] `json:"x"` + }{}) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"x":[]}` { + t.Fatalf("got %s", b) + } + _ = a // zero value used in struct above +} + +func TestArray_MarshalJSON_withElements(t *testing.T) { + b, err := json.Marshal(struct { + X Array[string] `json:"x"` + }{X: Array[string]{"a", "b"}}) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + x, ok := m["x"].([]any) + if !ok || len(x) != 2 { + t.Fatalf("got %v", m["x"]) + } +} + +func TestArray_UnmarshalJSON_null(t *testing.T) { + var a Array[int] + if err := json.Unmarshal([]byte("null"), &a); err != nil { + t.Fatal(err) + } + if a != nil && len(a) != 0 { + t.Fatalf("expected empty, got %#v", a) + } +} + +func TestArray_UnmarshalJSON_emptyArray(t *testing.T) { + var a Array[int] + if err := json.Unmarshal([]byte("[]"), &a); err != nil { + t.Fatal(err) + } + if len(a) != 0 { + t.Fatalf("got %#v", a) + } +} + +func TestArray_roundTrip(t *testing.T) { + type row struct { + ID int `json:"id"` + Name string `json:"name"` + } + in := Array[row]{{ID: 1, Name: "one"}} + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + var out Array[row] + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual([]row(in), []row(out)) { + t.Fatalf("in %+v out %+v", in, out) + } +} diff --git a/go/internal/agentbase/jsonslice/doc.go b/go/internal/agentbase/jsonslice/doc.go new file mode 100644 index 0000000..9291346 --- /dev/null +++ b/go/internal/agentbase/jsonslice/doc.go @@ -0,0 +1,3 @@ +// Package jsonslice provides Array, a JSON slice type that encodes nil and +// empty states as JSON [] instead of null for API wire compatibility. +package jsonslice diff --git a/go/internal/agentbase/output/doc.go b/go/internal/agentbase/output/doc.go new file mode 100644 index 0000000..295e69c --- /dev/null +++ b/go/internal/agentbase/output/doc.go @@ -0,0 +1,4 @@ +// Package output provides table and JSON output formatting helpers for the +// greennode CLI commands. It wraps tablewriter for human-readable output and +// encoding/json for machine-readable output. +package output diff --git a/go/internal/agentbase/output/output.go b/go/internal/agentbase/output/output.go new file mode 100644 index 0000000..ffd8e50 --- /dev/null +++ b/go/internal/agentbase/output/output.go @@ -0,0 +1,143 @@ +// Package output provides table and JSON output formatting for CLI commands. +package output + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/olekukonko/tablewriter" +) + +// Format represents the output format requested by the user. +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" + FormatID Format = "id" +) + +var currentFormat Format = FormatTable + +// SetFormat sets the active output format. Called once from rootCmd.PersistentPreRun. +func SetFormat(f Format) { currentFormat = f } + +// GetFormat returns the active output format. +func GetFormat() Format { return currentFormat } + +// ParseFormat parses an output format string, defaulting to table. +func ParseFormat(s string) Format { + switch s { + case "json": + return FormatJSON + case "id": + return FormatID + case "table": + return FormatTable + default: + return FormatTable + } +} + +// PrintResource renders v as JSON, prints the ID via extractID, or calls humanFn for any other format. +// extractID must not be nil. humanFn must not be nil. +func PrintResource(v interface{}, extractID func() string, humanFn func() error) error { + switch GetFormat() { + case FormatJSON: + return JSON(v) + case FormatID: + fmt.Fprintln(os.Stdout, extractID()) + return nil + default: + return humanFn() + } +} + +// PrintDeletedID renders a deletion result. JSON emits {"id":""}, +// id emits the bare ID, table emits nothing (silence is golden). +func PrintDeletedID(id string) error { + switch GetFormat() { + case FormatJSON: + return JSON(map[string]string{"id": id}) + case FormatID: + fmt.Fprintln(os.Stdout, id) + return nil + default: + return nil + } +} + +// JSON prints any value as indented JSON to stdout. +func JSON(v interface{}) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// Table renders a table to stdout with the given headers and rows. +func Table(headers []string, rows [][]string) { + t := tablewriter.NewWriter(os.Stdout) + // Convert headers to interface{} slice for the new API. + headerIface := make([]interface{}, len(headers)) + for i, h := range headers { + headerIface[i] = h + } + t.Header(headerIface...) + for _, row := range rows { + rowIface := make([]interface{}, len(row)) + for i, cell := range row { + rowIface[i] = cell + } + _ = t.Append(rowIface...) + } + _ = t.Render() +} + +// Success prints a success message to stdout. +func Success(msg string) { + fmt.Fprintln(os.Stdout, msg) +} + +// Successf prints a formatted success message to stdout. +func Successf(format string, args ...interface{}) { + fmt.Fprintf(os.Stdout, format+"\n", args...) +} + +// Error prints an error message to stderr and exits with code 1. +func Error(msg string) { + fmt.Fprintln(os.Stderr, "Error:", msg) + os.Exit(1) +} + +// Errorf prints a formatted error message to stderr and exits with code 1. +func Errorf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) + os.Exit(1) +} + +// Warn prints a warning to stderr. +func Warn(msg string) { + fmt.Fprintln(os.Stderr, "Warning:", msg) +} + +// PrintID prints just the ID to stdout (for -o id flag). +func PrintID(id string) { + fmt.Fprintln(os.Stdout, id) +} + +// FormatTime returns a human-readable time string or "-" if nil. +func FormatTime(t interface{ String() string }) string { + if t == nil { + return "-" + } + return t.String() +} + +// StrOrDash returns the string if non-empty, otherwise "-". +func StrOrDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/go/internal/agentbase/output/output_test.go b/go/internal/agentbase/output/output_test.go new file mode 100644 index 0000000..581e24c --- /dev/null +++ b/go/internal/agentbase/output/output_test.go @@ -0,0 +1,273 @@ +package output + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" +) + +// captureStdout redirects os.Stdout to a buffer for the duration of f. +func captureStdout(t *testing.T, f func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w + + f() + + w.Close() + os.Stdout = orig + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} + +func TestParseFormat_JSON(t *testing.T) { + if ParseFormat("json") != FormatJSON { + t.Error("expected FormatJSON") + } +} + +func TestParseFormat_Default(t *testing.T) { + if ParseFormat("") != FormatTable { + t.Error("expected FormatTable for empty string") + } + if ParseFormat("something") != FormatTable { + t.Error("expected FormatTable for unknown string") + } +} + +func TestJSON_Output(t *testing.T) { + type payload struct { + ID string `json:"id"` + Name string `json:"name"` + } + out := captureStdout(t, func() { + if err := JSON(payload{ID: "1", Name: "test"}); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var decoded payload + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v (got %q)", err, out) + } + if decoded.ID != "1" || decoded.Name != "test" { + t.Errorf("unexpected decoded value: %+v", decoded) + } +} + +func TestSuccess(t *testing.T) { + out := captureStdout(t, func() { + Success("it worked") + }) + if !strings.Contains(out, "it worked") { + t.Errorf("expected 'it worked' in output, got %q", out) + } +} + +func TestSuccessf(t *testing.T) { + out := captureStdout(t, func() { + Successf("value: %d", 42) + }) + if !strings.Contains(out, "42") { + t.Errorf("expected '42' in output, got %q", out) + } +} + +func TestPrintID(t *testing.T) { + out := captureStdout(t, func() { + PrintID("abc-123") + }) + if !strings.Contains(out, "abc-123") { + t.Errorf("expected 'abc-123' in output, got %q", out) + } +} + +func TestStrOrDash_NonEmpty(t *testing.T) { + if got := StrOrDash("hello"); got != "hello" { + t.Errorf("expected 'hello', got %q", got) + } +} + +func TestStrOrDash_Empty(t *testing.T) { + if got := StrOrDash(""); got != "-" { + t.Errorf("expected '-', got %q", got) + } +} + +func TestTable_Renders(t *testing.T) { + out := captureStdout(t, func() { + Table([]string{"ID", "Name"}, [][]string{ + {"1", "alpha"}, + {"2", "beta"}, + }) + }) + if !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") { + t.Errorf("expected table rows in output, got %q", out) + } +} + +func TestWarn_WritesToStderr(t *testing.T) { + // Warn writes to stderr; we just call it to ensure it doesn't panic. + Warn("test warning") +} + +type mockStringer struct{ val string } + +func (m mockStringer) String() string { return m.val } + +func TestFormatTime_NonNil(t *testing.T) { + got := FormatTime(mockStringer{val: "2024-01-01"}) + if got != "2024-01-01" { + t.Errorf("expected '2024-01-01', got %q", got) + } +} + +func TestParseFormat_ID(t *testing.T) { + if ParseFormat("id") != FormatID { + t.Errorf("expected FormatID, got %q", ParseFormat("id")) + } +} + +func TestParseFormat_Table(t *testing.T) { + for _, s := range []string{"table", "", "unknown"} { + if ParseFormat(s) != FormatTable { + t.Errorf("ParseFormat(%q) = %q, want FormatTable", s, ParseFormat(s)) + } + } +} + +func TestSetGetFormat(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + cases := []struct { + input string + want Format + }{ + {"json", FormatJSON}, + {"id", FormatID}, + {"table", FormatTable}, + {"", FormatTable}, + } + for _, tc := range cases { + SetFormat(ParseFormat(tc.input)) + if got := GetFormat(); got != tc.want { + t.Errorf("after SetFormat(ParseFormat(%q)): GetFormat() = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestPrintResource_JSON(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + SetFormat(FormatJSON) + type res struct { + ID string `json:"id"` + } + humanCalled := false + out := captureStdout(t, func() { + err := PrintResource(res{ID: "abc"}, func() string { return "abc" }, func() error { + humanCalled = true + return nil + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if humanCalled { + t.Error("humanFn should not be called for JSON format") + } + var decoded map[string]string + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v (got %q)", err, out) + } + if decoded["id"] != "abc" { + t.Errorf("expected id=abc, got %+v", decoded) + } +} + +func TestPrintResource_ID(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + SetFormat(FormatID) + humanCalled := false + out := captureStdout(t, func() { + err := PrintResource(struct{}{}, func() string { return "xyz-123" }, func() error { + humanCalled = true + return nil + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if humanCalled { + t.Error("humanFn should not be called for ID format") + } + if strings.TrimSpace(out) != "xyz-123" { + t.Errorf("expected 'xyz-123', got %q", out) + } +} + +func TestPrintResource_Table(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + SetFormat(FormatTable) + humanCalled := false + captureStdout(t, func() { + err := PrintResource(struct{}{}, func() string { return "ignored" }, func() error { + humanCalled = true + return nil + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if !humanCalled { + t.Error("humanFn must be called for table format") + } +} + +func TestPrintDeletedID_JSON(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + SetFormat(FormatJSON) + out := captureStdout(t, func() { + if err := PrintDeletedID("del-456"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var decoded map[string]string + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v (got %q)", err, out) + } + if decoded["id"] != "del-456" { + t.Errorf("expected id=del-456, got %+v", decoded) + } +} + +func TestPrintDeletedID_ID(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + SetFormat(FormatID) + out := captureStdout(t, func() { + if err := PrintDeletedID("del-456"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if strings.TrimSpace(out) != "del-456" { + t.Errorf("expected 'del-456', got %q", out) + } +} + +func TestPrintDeletedID_Table(t *testing.T) { + t.Cleanup(func() { SetFormat(FormatTable) }) + SetFormat(FormatTable) + out := captureStdout(t, func() { + if err := PrintDeletedID("del-456"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if out != "" { + t.Errorf("expected empty stdout in table mode, got %q", out) + } +} diff --git a/go/internal/auth/login_token.go b/go/internal/auth/login_token.go new file mode 100644 index 0000000..92bd7a0 --- /dev/null +++ b/go/internal/auth/login_token.go @@ -0,0 +1,142 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/vngcloud/greennode-cli/internal/login" +) + +// refreshExpirySkew is how long before the access token's real expiry the +// LoginTokenProvider considers it stale and refreshes — mirrors the machine +// TokenManager's 60s skew (internal/auth/token.go:114). +const refreshExpirySkew = 60 * time.Second + +// noExpiryFallback is used when a refresh response carries no expires_in: pin a +// conservative 30 min so a long-lived process still re-refreshes rather than +// trusting a non-expiring token indefinitely. +const noExpiryFallback = 30 * time.Minute + +// ErrLoginTokenRefreshFailed wraps a refresh-grant failure so the caller can +// surface "run `grn login`" guidance. A refresh fails when the refresh token is +// expired or revoked, or IAM rejected the grant. This is a hard error — the +// login provider does NOT silently fall back to machine credentials (a profile +// commits to one auth type). +var ErrLoginTokenRefreshFailed = errors.New("login token expired or revoked — run `grn login`") + +// LoginTokenProvider is the user-PKCE auth source for GreennodeClient: it mints +// short-lived access tokens from the persisted refresh token via the IAM /v2 +// refresh_token grant (login.Client.Refresh). It is the login counterpart to the +// machine client_credentials TokenManager; both satisfy client.TokenProvider +// (structural — this package does not import internal/client, avoiding a cycle, +// since internal/login is stdlib-only and does not import internal/auth). +// +// The access token is held in memory only for the process lifetime (NEVER +// persisted — by design). IAM may rotate the refresh token on refresh; if so +// and persist is set, the new refresh token + expiry are written back to the +// profile (best-effort) so later invocations don't see a stale token. The +// refresh_token grant always sends Basic(client_id, "") for the public/no-secret +// client `grn login` used — the baked-in per-env id the caller resolves from +// iam_env (login.ClientIDForEnv), exactly the public-client shape the authorize +// flow's token POST uses (tokencx.go:104). +type LoginTokenProvider struct { + refreshToken string + clientID string + clientSecret string // "" for the public/no-secret dev client login persists + tokenURL string + + tc *login.Client + persist func(refreshToken string, expiresAt time.Time) error // optional; best-effort rotation write + + mu sync.Mutex + accessToken string + expiresAt time.Time +} + +// NewLoginTokenProvider builds a user-token provider. persist is an optional +// callback invoked when IAM rotates the refresh token (issues a new one); the +// caller (the client wiring layer) sets it to write the rotated token + new +// expiry back to the credentials INI via config.WriteLoginToken. A nil persist +// means rotation is not persisted (the in-memory access token is still used for +// the rest of this process). clientID is the baked-in public client resolved +// from iam_env by the caller (login.ClientIDForEnv); clientSecret is "" for +// that public client — client_secret is never persisted by `grn login`. +func NewLoginTokenProvider(refreshToken, clientID, clientSecret, tokenURL string, persist func(string, time.Time) error) *LoginTokenProvider { + return &LoginTokenProvider{ + refreshToken: refreshToken, + clientID: clientID, + clientSecret: clientSecret, + tokenURL: tokenURL, + tc: login.New(30 * time.Second), + persist: persist, + } +} + +// GetToken returns a valid access token, refreshing first if the cache is empty +// or within the expiry skew. GreennodeClient calls this once per request. +func (p *LoginTokenProvider) GetToken() (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.accessToken != "" && time.Now().Before(p.expiresAt) { + return p.accessToken, nil + } + return p.refresh() +} + +// RefreshToken force-refreshes regardless of cache state. GreennodeClient calls +// this on HTTP 401 to retry once with a fresh token. +func (p *LoginTokenProvider) RefreshToken() (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + return p.refresh() +} + +// refresh mints a new access token from the refresh_token grant. Caller holds +// p.mu. On a transport error or a non-2xx IAM response it returns +// ErrLoginTokenRefreshFailed (hard-error — no silent fallback). On success it +// caches the access token with a 60s pre-expiry skew. If IAM returned a NEW +// refresh token (rotation), it best-effort persists it via p.persist and updates +// the in-memory token so subsequent refreshes use the new one; a persist failure +// is reported on stderr, not returned (the access token is still valid for this +// invocation). +func (p *LoginTokenProvider) refresh() (string, error) { + resp, errResp, err := p.tc.Refresh(context.Background(), p.tokenURL, login.RefreshParams{ + RefreshToken: p.refreshToken, + ClientID: p.clientID, + ClientSecret: p.clientSecret, + Scope: "openid", + }) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrLoginTokenRefreshFailed, err) + } + if errResp != nil { + return "", fmt.Errorf("%w: iam status=%d body=%s", ErrLoginTokenRefreshFailed, errResp.Status, string(errResp.RawBody)) + } + tok, err := login.DecodeTokenBody(resp.Raw) + if err != nil { + return "", fmt.Errorf("login token decode: %w", err) + } + + p.accessToken = tok.AccessToken + exp := tok.ExpiresAt + if exp.IsZero() { + exp = time.Now().Add(noExpiryFallback) + } + p.expiresAt = exp.Add(-refreshExpirySkew) + + // Rotation: IAM may issue a new refresh token. Persist + adopt it so the next + // refresh (this process or a later invocation) uses the rotated token. + if tok.RefreshToken != "" && tok.RefreshToken != p.refreshToken { + if p.persist != nil { + if perr := p.persist(tok.RefreshToken, exp); perr != nil { + fmt.Fprintf(os.Stderr, "grn: warning: failed to persist rotated login refresh token: %v\n", perr) + } + } + p.refreshToken = tok.RefreshToken + } + return p.accessToken, nil +} diff --git a/go/internal/auth/login_token_test.go b/go/internal/auth/login_token_test.go new file mode 100644 index 0000000..dca3110 --- /dev/null +++ b/go/internal/auth/login_token_test.go @@ -0,0 +1,199 @@ +package auth + +import ( + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// tokenServerBuilder is a configurable /token test server. It counts hits and +// can serve a sequence of bodies (so a rotation test can return a fresh +// refresh_token on the first refresh and the same on the next). +type tokenServerBuilder struct { + hits atomic.Int32 + mu sync.Mutex + bodies []string // sequence; each refresh pops the head (or reuses the last) + status int // status to return (200 if 0) + bearer string // the Basic header the server received (for assertions) +} + +// newTokenServer starts a /token test server. bodies is a sequence consumed one +// per request (the last is reused if more requests arrive); status overrides +// the response status (200 when 0). +func newTokenServer(t *testing.T, bodies []string, status int) (*httptest.Server, *tokenServerBuilder) { + t.Helper() + tb := &tokenServerBuilder{bodies: bodies, status: status} + srv := httptest.NewServer(tb) + t.Cleanup(srv.Close) + return srv, tb +} + +func (tb *tokenServerBuilder) ServeHTTP(w http.ResponseWriter, r *http.Request) { + tb.hits.Add(1) + tb.mu.Lock() + tb.bearer = r.Header.Get("Authorization") + body := tb.bodies[0] + if len(tb.bodies) > 1 { + tb.bodies = tb.bodies[1:] + } + status := tb.status + tb.mu.Unlock() + + if status != 0 && status != http.StatusOK { + http.Error(w, `{"error":"invalid_grant"}`, status) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) +} + +func TestLoginTokenProvider_GetToken_RefreshesAndReturnsAccessToken(t *testing.T) { + t.Parallel() + body := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-orig","expires_in":3600}` + srv, _ := newTokenServer(t, []string{body}, 0) + + var persisted []struct { + rt string + exp time.Time + } + p := NewLoginTokenProvider("rt-orig", "cid", "", srv.URL, func(rt string, exp time.Time) error { + persisted = append(persisted, struct { + rt string + exp time.Time + }{rt, exp}) + return nil + }) + + got, err := p.GetToken() + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if got != "at-1" { + t.Errorf("token=%q, want at-1", got) + } + // Response carried the SAME refresh_token we sent → no rotation → no persist. + if len(persisted) != 0 { + t.Errorf("persist called %d times for a non-rotating response, want 0", len(persisted)) + } +} + +func TestLoginTokenProvider_GetToken_CacheHitSkipsHTTP(t *testing.T) { + t.Parallel() + body := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-orig","expires_in":3600}` + srv, tb := newTokenServer(t, []string{body}, 0) + + p := NewLoginTokenProvider("rt-orig", "cid", "", srv.URL, nil) + + if _, err := p.GetToken(); err != nil { + t.Fatalf("first GetToken: %v", err) + } + if _, err := p.GetToken(); err != nil { + t.Fatalf("second GetToken: %v", err) + } + if got := tb.hits.Load(); got != 1 { + t.Errorf("token endpoint hits=%d, want 1 (second GetToken served from cache)", got) + } +} + +func TestLoginTokenProvider_RefreshToken_ForcesNewHTTPCall(t *testing.T) { + t.Parallel() + body := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-orig","expires_in":3600}` + srv, tb := newTokenServer(t, []string{body, body}, 0) + + p := NewLoginTokenProvider("rt-orig", "cid", "", srv.URL, nil) + + if _, err := p.GetToken(); err != nil { + t.Fatalf("GetToken: %v", err) + } + if _, err := p.RefreshToken(); err != nil { + t.Fatalf("RefreshToken: %v", err) + } + if got := tb.hits.Load(); got != 2 { + t.Errorf("token endpoint hits=%d, want 2 (RefreshToken force-refreshes)", got) + } +} + +func TestLoginTokenProvider_Rotation_PersistsAndAdoptsNewRefreshToken(t *testing.T) { + t.Parallel() + // First refresh returns a NEW refresh_token (rotation); second returns the + // same one (steady state). + first := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-ROTATED","expires_in":3600}` + second := `{"access_token":"at-2","token_type":"Bearer","refresh_token":"rt-ROTATED","expires_in":3600}` + srv, tb := newTokenServer(t, []string{first, second}, 0) + + var persisted []string + p := NewLoginTokenProvider("rt-orig", "cid", "", srv.URL, func(rt string, _ time.Time) error { + persisted = append(persisted, rt) + return nil + }) + + if _, err := p.GetToken(); err != nil { + t.Fatalf("first GetToken: %v", err) + } + if len(persisted) != 1 || persisted[0] != "rt-ROTATED" { + t.Fatalf("persist calls=%v, want [rt-ROTATED]", persisted) + } + + // Force a second refresh: the response now carries the SAME refresh_token the + // provider already holds → persist must NOT fire again. + if _, err := p.RefreshToken(); err != nil { + t.Fatalf("second RefreshToken: %v", err) + } + if len(persisted) != 1 { + t.Errorf("persist fired again on a non-rotating response; calls=%v, want 1", persisted) + } + if tb.hits.Load() != 2 { + t.Errorf("hits=%d, want 2", tb.hits.Load()) + } +} + +func TestLoginTokenProvider_Non2xx_HardErrors(t *testing.T) { + t.Parallel() + srv, _ := newTokenServer(t, []string{`{"error":"invalid_grant"}`}, http.StatusBadRequest) + + p := NewLoginTokenProvider("rt-orig", "cid", "", srv.URL, nil) + _, err := p.GetToken() + if err == nil { + t.Fatal("GetToken succeeded, want error") + } + if !errors.Is(err, ErrLoginTokenRefreshFailed) { + t.Errorf("err=%v, want errors.Is ErrLoginTokenRefreshFailed", err) + } + if !strings.Contains(err.Error(), "grn login") { + t.Errorf("err=%v, want guidance mentioning `grn login`", err) + } +} + +func TestLoginTokenProvider_RefreshRequestShape(t *testing.T) { + t.Parallel() + body := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-orig","expires_in":3600}` + srv, tb := newTokenServer(t, []string{body}, 0) + + // Send a non-empty secret to confirm it travels in the Basic header (a public + // client sends Basic(client_id, "") — the login path). + p := NewLoginTokenProvider("rt-orig", "cid-secret", "cs", srv.URL, nil) + if _, err := p.GetToken(); err != nil { + t.Fatalf("GetToken: %v", err) + } + + if !strings.HasPrefix(tb.bearer, "Basic ") { + t.Fatalf("Authorization header=%q, want Basic ...", tb.bearer) + } + dec, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(tb.bearer, "Basic ")) + if err != nil { + t.Fatalf("decode basic: %v", err) + } + // client_id is url.QueryEscape'd in the username (tokencx.go:104). + wantUser := url.QueryEscape("cid-secret") + if !strings.HasPrefix(string(dec), wantUser+":") { + t.Errorf("basic user=%q, want prefix %q", string(dec), wantUser+":") + } +} diff --git a/go/internal/cli/client.go b/go/internal/cli/client.go index b3d33f9..e713851 100644 --- a/go/internal/cli/client.go +++ b/go/internal/cli/client.go @@ -9,7 +9,6 @@ import ( "time" "github.com/spf13/cobra" - "github.com/vngcloud/greennode-cli/internal/auth" "github.com/vngcloud/greennode-cli/internal/client" "github.com/vngcloud/greennode-cli/internal/config" ) @@ -35,8 +34,15 @@ func NewClient(cmd *cobra.Command, serviceName string) (*client.GreennodeClient, return nil, err } - if cfg.ClientID == "" || cfg.ClientSecret == "" { - return nil, fmt.Errorf("credentials not configured. Run 'grn configure' to set up credentials") + // Auth source is profile-driven (one auth type per profile): auth_mode=user + // → login refresh-token provider; else → machine client_credentials. The + // machine-cred check lives inside the provider picker's default branch, so a + // login-only profile (no machine creds) now works and a machine profile is + // unchanged. NewTokenProvider reads the RESOLVED profile off cfg (cfg.Profile, + // set by LoadConfig) — not the raw --profile flag, which may be "". + tp, err := NewTokenProvider(cfg) + if err != nil { + return nil, err } if region != "" { @@ -57,9 +63,8 @@ func NewClient(cmd *cobra.Command, serviceName string) (*client.GreennodeClient, fmt.Fprintln(os.Stderr, "Warning: SSL certificate verification is disabled. This is not recommended for production use.") } - tokenManager := auth.NewTokenManager(cfg.ClientID, cfg.ClientSecret) connect := time.Duration(connectTimeout) * time.Second read := time.Duration(readTimeout) * time.Second - return client.NewGreennodeClient(baseURL, tokenManager, connect, read, !noVerifySSL, debug), nil + return client.NewGreennodeClient(baseURL, tp, connect, read, !noVerifySSL, debug), nil } diff --git a/go/internal/cli/token_provider.go b/go/internal/cli/token_provider.go new file mode 100644 index 0000000..30c4fad --- /dev/null +++ b/go/internal/cli/token_provider.go @@ -0,0 +1,90 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/vngcloud/greennode-cli/internal/auth" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/login" +) + +// tokenURLForEnv resolves the IAM /v2 token URL for a given iam_env. It is a +// package-level var (not a direct call to login.TokenURLForEnv) so tests can +// point it at a httptest server and drive a refresh-grant rotation through +// NewTokenProvider → LoginTokenProvider, asserting the rotated refresh token +// persists to cfg.Profile's section (the "empty section name" regression would +// otherwise be silent — a warning, not a hard failure). +var tokenURLForEnv = login.TokenURLForEnv + +// clientIDForEnv resolves the baked-in public client_id for a profile's iam_env. +// A package-level var (mirroring tokenURLForEnv) so tests can drive the refresh +// path without depending on the real IAM presets. The refresh-token grant must +// present the same client_id the refresh token was issued to at login; since +// `grn login` uses the baked-in per-env public client, that id is recovered from +// iam_env rather than persisted in the credentials INI (the value is public and +// already in source). +var clientIDForEnv = login.ClientIDForEnv + +// NewTokenProvider picks the auth source for a profile from cfg.AuthMode — one +// auth type per profile, profile-driven (no per-call flag/env override). The +// profile commits to a single auth type: +// +// - "user" → LoginTokenProvider: mints short-lived access tokens from the +// persisted refresh token via the IAM /v2 refresh_token grant. Requires the +// login context `grn login` persisted (refresh_token + iam_env); missing → +// hard error pointing to `grn login` (no silent fallback to machine creds). +// The client_id is NOT persisted — it is a public id baked into source and +// resolved from iam_env (login.ClientIDForEnv). A rotation callback writes +// any rotated refresh token + new expiry back to the credentials INI +// (atomic 0600) so later invocations don't see a stale token. +// - anything else (unset / "machine") → the machine client_credentials +// TokenManager (today's behavior). Requires machine client_id/client_secret; +// missing → the existing "run `grn configure`" error. +// +// The profile is read from cfg.Profile — the RESOLVED profile from LoadConfig +// (always non-empty in production: flag → GRN_PROFILE → "default") — NOT the +// raw --profile flag, which is "" when unset. The rotation callback closes over +// this resolved name because WriteLoginToken writes a section named after the +// profile; passing the raw (empty) flag made rotation fail with "empty section +// name" (the persisted section can't be ""). +// +// Shared by cli.NewClient (vks) and vserverclient.BuildClient (vserver) so both +// services select auth the same way. +func NewTokenProvider(cfg *config.Config) (client.TokenProvider, error) { + profile := cfg.Profile + switch cfg.AuthMode { + case "user": + if cfg.RefreshToken == "" { + return nil, fmt.Errorf("profile %q is auth_mode=user but has no login token — run `grn login`", profile) + } + tokenURL, err := tokenURLForEnv(cfg.IamEnv) + if err != nil { + return nil, err + } + // The client_id is NOT persisted (a public id baked into source); resolve + // it from the profile's iam_env so the refresh-token grant presents the + // same client the refresh token was issued to at `grn login`. + clientID, err := clientIDForEnv(cfg.IamEnv) + if err != nil { + return nil, err + } + // Best-effort rotation write: if IAM issues a new refresh_token on + // refresh, persist it + the new expiry under the same profile/login + // context so the next invocation refreshes against the rotated token. + persist := func(rt string, exp time.Time) error { + return config.NewConfigFileWriter().WriteLoginToken(profile, rt, exp, "user", cfg.IamEnv) + } + // clientSecret="": `grn login` uses a public/no-secret client (the + // baked-in dev/prod id); client_secret is never persisted (spec), so the + // refresh sends Basic(client_id, "") exactly like the public-client + // authorize-flow token POST (internal/login/tokencx.go). + return auth.NewLoginTokenProvider(cfg.RefreshToken, clientID, "", tokenURL, persist), nil + default: // "" or "machine" + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil, fmt.Errorf("credentials not configured. Run 'grn configure' to set up credentials") + } + return auth.NewTokenManager(cfg.ClientID, cfg.ClientSecret), nil + } +} diff --git a/go/internal/cli/token_provider_test.go b/go/internal/cli/token_provider_test.go new file mode 100644 index 0000000..fe0aa5e --- /dev/null +++ b/go/internal/cli/token_provider_test.go @@ -0,0 +1,141 @@ +package cli + +import ( + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "gopkg.in/ini.v1" + + "github.com/vngcloud/greennode-cli/internal/auth" + "github.com/vngcloud/greennode-cli/internal/config" +) + +func TestNewTokenProvider_UserMode_BuildsLoginTokenProvider(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + Profile: "default", + AuthMode: "user", + RefreshToken: "rt-123", + IamEnv: "dev", + } + tp, err := NewTokenProvider(cfg) + if err != nil { + t.Fatalf("NewTokenProvider: %v", err) + } + if _, ok := tp.(*auth.LoginTokenProvider); !ok { + t.Errorf("tp=%T, want *auth.LoginTokenProvider", tp) + } +} + +func TestNewTokenProvider_MachineMode_BuildsTokenManager(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + Profile: "default", + // AuthMode unset → machine (today's behavior). + ClientID: "cid", + ClientSecret: "cs", + } + tp, err := NewTokenProvider(cfg) + if err != nil { + t.Fatalf("NewTokenProvider: %v", err) + } + if _, ok := tp.(*auth.TokenManager); !ok { + t.Errorf("tp=%T, want *auth.TokenManager", tp) + } +} + +func TestNewTokenProvider_UserModeMissingRefreshToken_Errors(t *testing.T) { + t.Parallel() + // Refresh token missing → the only hard-error condition in the user branch + // (the client_id is resolved from iam_env, so a missing client_id is no + // longer an error). The error must point to `grn login`. + cfg := &config.Config{Profile: "default", AuthMode: "user", RefreshToken: "", IamEnv: "dev"} + _, err := NewTokenProvider(cfg) + if err == nil { + t.Fatal("NewTokenProvider succeeded, want error") + } + if !strings.Contains(err.Error(), "grn login") { + t.Errorf("err=%q, want guidance mentioning `grn login`", err) + } +} + +func TestNewTokenProvider_UserModeBadIamEnv_Errors(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + Profile: "default", + AuthMode: "user", + RefreshToken: "rt", + IamEnv: "staging", + } + _, err := NewTokenProvider(cfg) + if err == nil { + t.Fatal("NewTokenProvider succeeded, want error for unknown iam_env") + } +} + +func TestNewTokenProvider_MachineModeMissingCreds_Errors(t *testing.T) { + t.Parallel() + cfg := &config.Config{Profile: "default"} // no AuthMode, no machine creds + _, err := NewTokenProvider(cfg) + if err == nil { + t.Fatal("NewTokenProvider succeeded, want error") + } + if !strings.Contains(err.Error(), "grn configure") { + t.Errorf("err=%q, want guidance mentioning `grn configure`", err) + } +} + +// TestNewTokenProvider_UserMode_RotationPersistsToResolvedProfile is the +// regression for the silent "failed to create section ”: empty section name" +// warning: the rotation persist callback must close over cfg.Profile (the +// RESOLVED profile from LoadConfig) — not the raw --profile flag, which is "" +// when unset. Pre-fix, rotation persisted nowhere and the next invocation +// refreshed against a STALE refresh token. This drives a refresh-grant +// rotation through NewTokenProvider against a httptest token server (via the +// tokenURLForEnv seam) and asserts the rotated token lands in [default]. +func TestNewTokenProvider_UserMode_RotationPersistsToResolvedProfile(t *testing.T) { + // NOT parallel: mutates process HOME and the package-level tokenURLForEnv. + body := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-ROTATED","expires_in":3600}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + + prev := tokenURLForEnv + tokenURLForEnv = func(string) (string, error) { return srv.URL, nil } + t.Cleanup(func() { tokenURLForEnv = prev }) + + // Isolated HOME so the rotation write lands in a temp credentials INI. + t.Setenv("HOME", t.TempDir()) + + cfg := &config.Config{ + Profile: "default", // resolved profile — the section WriteLoginToken must target + AuthMode: "user", + RefreshToken: "rt-orig", + IamEnv: "dev", + } + tp, err := NewTokenProvider(cfg) + if err != nil { + t.Fatalf("NewTokenProvider: %v", err) + } + if _, err := tp.GetToken(); err != nil { + t.Fatalf("GetToken: %v", err) + } + + creds, err := ini.Load(filepath.Join(config.DefaultConfigDir(), "credentials")) + if err != nil { + t.Fatalf("load credentials: %v (rotation persist did not write?)", err) + } + sec, err := creds.GetSection("default") + if err != nil { + t.Fatalf("no [default] section (empty-profile regression); sections=%v", creds.SectionStrings()) + } + if got := sec.Key("refresh_token").String(); got != "rt-ROTATED" { + t.Errorf("persisted refresh_token=%q, want rt-ROTATED", got) + } +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go index b9f68a8..a690771 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -12,8 +12,6 @@ import ( "os" "strings" "time" - - "github.com/vngcloud/greennode-cli/internal/auth" ) const ( @@ -45,19 +43,35 @@ var retryableStatusCodes = map[int]bool{ // keeps identification intact if that override is ever skipped (e.g. in tests). var UserAgent = "grn-vks-cli" +// TokenProvider supplies the Bearer credential GreennodeClient attaches to +// every request. It is the seam that lets a client speak to either auth source: +// the machine client_credentials flow (*auth.TokenManager) or the user PKCE +// refresh-token flow (auth.LoginTokenProvider). Declared in the consumer +// package per the repo's dependency rule; both implementations satisfy it +// structurally. GetToken returns a valid access token (fetching one if needed); +// RefreshToken force-refreshes (used on HTTP 401). Mirrors the pre-existing +// *auth.TokenManager method signatures exactly, so that type satisfies it +// without change. +type TokenProvider interface { + GetToken() (string, error) + RefreshToken() (string, error) +} + // GreennodeClient is an HTTP client for Greennode APIs with retry and auto token refresh. type GreennodeClient struct { - baseURL string - tokenManager *auth.TokenManager - httpClient *http.Client - debug bool + baseURL string + tokenProvider TokenProvider + httpClient *http.Client + debug bool } // NewGreennodeClient creates a new API client. connectTimeout bounds the TCP // connect and TLS handshake (the --cli-connect-timeout flag); readTimeout bounds // the overall request (the --cli-read-timeout flag). A zero readTimeout falls // back to the default; a zero connectTimeout means no explicit connect bound. -func NewGreennodeClient(baseURL string, tokenManager *auth.TokenManager, connectTimeout, readTimeout time.Duration, verifySSL bool, debug bool) *GreennodeClient { +// tokenProvider is the auth source (machine TokenManager or login +// LoginTokenProvider); GreennodeClient is agnostic to which. +func NewGreennodeClient(baseURL string, tokenProvider TokenProvider, connectTimeout, readTimeout time.Duration, verifySSL bool, debug bool) *GreennodeClient { if readTimeout == 0 { readTimeout = defaultTimeout } @@ -73,8 +87,8 @@ func NewGreennodeClient(baseURL string, tokenManager *auth.TokenManager, connect } return &GreennodeClient{ - baseURL: baseURL, - tokenManager: tokenManager, + baseURL: baseURL, + tokenProvider: tokenProvider, httpClient: &http.Client{ Timeout: readTimeout, Transport: transport, @@ -183,7 +197,7 @@ func (c *GreennodeClient) requestRaw(method, path string, params map[string]stri fullURL = u.String() } - token, err := c.tokenManager.GetToken() + token, err := c.tokenProvider.GetToken() if err != nil { return "", err } @@ -240,7 +254,7 @@ func (c *GreennodeClient) requestRaw(method, path string, params map[string]stri // 401 — refresh token and retry once if resp.StatusCode == http.StatusUnauthorized { - token, err = c.tokenManager.RefreshToken() + token, err = c.tokenProvider.RefreshToken() if err != nil { return "", err } diff --git a/go/internal/client/provider_seam_test.go b/go/internal/client/provider_seam_test.go new file mode 100644 index 0000000..38da368 --- /dev/null +++ b/go/internal/client/provider_seam_test.go @@ -0,0 +1,92 @@ +package client + +import ( + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +// fakeProvider is a TokenProvider that is NOT *auth.TokenManager — it proves the +// GreennodeClient seam accepts any implementor and that GetToken/RefreshToken +// drive the Bearer header (and the 401 retry) as the contract expects. +type fakeProvider struct { + token string + getCalls atomic.Int32 + refreshCnt atomic.Int32 + refreshTo string // token adopted on RefreshToken; "" → keep current +} + +func (f *fakeProvider) GetToken() (string, error) { + f.getCalls.Add(1) + return f.token, nil +} + +func (f *fakeProvider) RefreshToken() (string, error) { + f.refreshCnt.Add(1) + if f.refreshTo != "" { + f.token = f.refreshTo + } + return f.token, nil +} + +func TestGreennodeClient_UsesTokenProviderBearer(t *testing.T) { + t.Parallel() + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer srv.Close() + + fp := &fakeProvider{token: "fake-bearer"} + c := NewGreennodeClient(srv.URL, fp, 5*time.Second, 5*time.Second, false, false) + + if _, err := c.Get("/v1/thing", nil); err != nil { + t.Fatalf("Get: %v", err) + } + if gotAuth != "Bearer fake-bearer" { + t.Errorf("Authorization=%q, want Bearer fake-bearer", gotAuth) + } + if fp.getCalls.Load() != 1 { + t.Errorf("GetToken calls=%d, want 1", fp.getCalls.Load()) + } +} + +func TestGreennodeClient_401CallsRefreshTokenAndRetries(t *testing.T) { + t.Parallel() + var firstAuth, retryAuth string + var attempts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n == 1 { + firstAuth = r.Header.Get("Authorization") + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + retryAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer srv.Close() + + // RefreshToken adopts a NEW token so the retry carries a different Bearer. + fp := &fakeProvider{token: "stale", refreshTo: "refreshed"} + c := NewGreennodeClient(srv.URL, fp, 5*time.Second, 5*time.Second, false, false) + + if _, err := c.Get("/v1/thing", nil); err != nil { + t.Fatalf("Get after 401-retry: %v", err) + } + if firstAuth != "Bearer stale" { + t.Errorf("first attempt Authorization=%q, want Bearer stale", firstAuth) + } + if retryAuth != "Bearer refreshed" { + t.Errorf("retry Authorization=%q, want Bearer refreshed (after RefreshToken)", retryAuth) + } + if fp.refreshCnt.Load() != 1 { + t.Errorf("RefreshToken calls=%d, want 1", fp.refreshCnt.Load()) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 45a0ec1..3dee24e 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "time" "gopkg.in/ini.v1" ) @@ -30,6 +31,21 @@ type Config struct { Profile string ProjectID string Regions map[string]map[string]string + + // Login (user) identity — present on profiles created by `grn login`. These + // live in the per-profile `credentials` INI alongside the machine + // client_id/client_secret (auth-only merge: one identity file per profile). + // AuthMode is "user" for a PKCE login, "machine" (or empty) for an + // access-key pair. RefreshToken is secret-at-rest (0600, masked in + // configure list/get); the access token is NEVER persisted — only the + // refresh token is. IamEnv is non-secret refresh context the usage slice + // needs to resolve the /v2 token URL and the baked-in client_id at refresh + // (the client_id itself is NOT persisted — it is a public id already in + // source, resolved from iam_env via internal/login.ClientIDForEnv). + AuthMode string + RefreshToken string + TokenExpiresAt time.Time + IamEnv string } // DefaultConfigDir returns ~/.greennode. This is where config is written. @@ -116,6 +132,24 @@ func LoadConfig(profile string) (*Config, error) { if cfg.ClientSecret == "" { cfg.ClientSecret = section.Key("client_secret").String() } + // Login (user) identity keys. A profile created by `grn login` + // carries these instead of (or alongside) machine credentials, + // so finding the section already marks the profile as existing. + // refresh_token is file-only by design (no env override). + if v := section.Key("auth_mode").String(); v != "" { + cfg.AuthMode = v + } + if v := section.Key("refresh_token").String(); v != "" { + cfg.RefreshToken = v + } + if v := section.Key("token_expires_at").String(); v != "" { + if t, perr := time.Parse(time.RFC3339, v); perr == nil { + cfg.TokenExpiresAt = t + } + } + if v := section.Key("iam_env").String(); v != "" { + cfg.IamEnv = v + } } } } diff --git a/go/internal/config/writer.go b/go/internal/config/writer.go index e7549a8..38001cf 100644 --- a/go/internal/config/writer.go +++ b/go/internal/config/writer.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "gopkg.in/ini.v1" ) @@ -75,6 +76,85 @@ func (w *ConfigFileWriter) WriteConfig(profile, region, output, projectID string return w.save(cfg, filePath) } +// loginTokenKeys are the per-section keys WriteLoginToken writes and +// ClearLoginToken removes. refresh_token is secret-at-rest (0600, masked in +// configure list/get); auth_mode/iam_env are non-secret refresh context; +// token_expires_at is a non-secret RFC3339 timestamp. The OAuth client_id is +// NOT persisted here — it is a public identifier baked into source +// (internal/login's per-env presets) and resolved from iam_env at refresh, so +// storing it in the credentials INI is redundant. ClearLoginToken still deletes +// a legacy login_client_id key if one is present from an older CLI version. +var loginTokenKeys = []string{"refresh_token", "token_expires_at", "auth_mode", "iam_env"} + +// loginTokenKeysLegacy lists credential-section keys older CLI versions wrote +// that the current version no longer writes but should still clear on logout +// (so a logout fully removes a prior login). login_client_id was dropped from +// the persisted set because it is a public id already in source. +var loginTokenKeysLegacy = []string{"login_client_id"} + +// WriteLoginToken persists a PKCE login result into the per-profile credentials +// INI: it folds the refresh token + non-secret refresh context into the same +// section that may already hold machine client_id/client_secret (auth-only +// merge — one identity file per profile). Mirrors WriteCredentials: it +// loadOrCreate's the file (preserving other keys/sections) and NewSection is +// idempotent (returns the existing section without wiping its keys). An empty +// refreshToken is a no-op so a stale empty value can never erase a prior good +// token; the caller (cmd/login) also skips the call on partial success. +func (w *ConfigFileWriter) WriteLoginToken(profile, refreshToken string, expiresAt time.Time, authMode, iamEnv string) error { + if refreshToken == "" { + return nil + } + if err := w.ensureDir(); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + filePath := filepath.Join(w.configDir, "credentials") + cfg, err := w.loadOrCreate(filePath) + if err != nil { + return err + } + + section, err := cfg.NewSection(profile) + if err != nil { + return fmt.Errorf("failed to create section '%s': %w", profile, err) + } + section.Key("refresh_token").SetValue(refreshToken) + section.Key("token_expires_at").SetValue(expiresAt.UTC().Format(time.RFC3339)) + section.Key("auth_mode").SetValue(authMode) + section.Key("iam_env").SetValue(iamEnv) + + return w.save(cfg, filePath) +} + +// ClearLoginToken removes the login keys from a profile's credentials section +// (logout). It leaves machine client_id/client_secret intact and is idempotent: +// a missing file, missing section, or already-cleared section is not an error. +func (w *ConfigFileWriter) ClearLoginToken(profile string) error { + filePath := filepath.Join(w.configDir, "credentials") + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return nil // no credentials file → nothing to clear + } + return fmt.Errorf("failed to stat %s: %w", filePath, err) + } + + cfg, err := w.loadOrCreate(filePath) + if err != nil { + return err + } + section, err := cfg.GetSection(profile) + if err != nil { + return nil // no section for this profile → nothing to clear + } + for _, k := range loginTokenKeys { + section.DeleteKey(k) + } + for _, k := range loginTokenKeysLegacy { + section.DeleteKey(k) + } + return w.save(cfg, filePath) +} + func (w *ConfigFileWriter) loadOrCreate(filePath string) (*ini.File, error) { if _, err := os.Stat(filePath); err == nil { return ini.Load(filePath) @@ -82,13 +162,38 @@ func (w *ConfigFileWriter) loadOrCreate(filePath string) (*ini.File, error) { return ini.Empty(), nil } +// save writes the INI atomically: serialize to a same-directory temp file, +// chmod 0600, then rename over the target. Same-dir rename is atomic on POSIX +// and never crosses filesystems, so a crash mid-write cannot truncate the +// existing file — important now that `credentials` holds a refresh_token. The +// rename also re-asserts 0600 on an existing file whose perms may have drifted +// (the old O_TRUNC path could not tighten perms on an existing file). func (w *ConfigFileWriter) save(cfg *ini.File, filePath string) error { - f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + dir := filepath.Dir(filePath) + tmp, err := os.CreateTemp(dir, ".cfg-*") if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmp.Name() + cleanup := func() { + tmp.Close() + os.Remove(tmpPath) + } + if err := tmp.Chmod(0600); err != nil { + cleanup() + return fmt.Errorf("failed to chmod temp file: %w", err) + } + if _, err := cfg.WriteTo(tmp); err != nil { + cleanup() return fmt.Errorf("failed to write %s: %w", filePath, err) } - defer f.Close() - - _, err = cfg.WriteTo(f) - return err + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + if err := os.Rename(tmpPath, filePath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to rename temp to %s: %w", filePath, err) + } + return nil } diff --git a/go/internal/config/writer_login_test.go b/go/internal/config/writer_login_test.go new file mode 100644 index 0000000..eb6ac9b --- /dev/null +++ b/go/internal/config/writer_login_test.go @@ -0,0 +1,311 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/ini.v1" +) + +// newHomeWriter points HOME at a temp dir so ConfigFileWriter (which resolves +// DefaultConfigDir() from HOME at each NewConfigFileWriter call) writes into an +// isolated ~/.greennode. Non-parallel: it mutates process env (HOME). +func newHomeWriter(t *testing.T) *ConfigFileWriter { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return NewConfigFileWriter() +} + +func credsPath(t *testing.T) string { + t.Helper() + home := os.Getenv("HOME") + return filepath.Join(home, ".greennode", "credentials") +} + +func loadCredsFile(t *testing.T) *ini.File { + t.Helper() + f, err := ini.Load(credsPath(t)) + if err != nil { + t.Fatalf("load credentials: %v", err) + } + return f +} + +// WriteLoginToken folds the refresh token + non-secret context into the same +// [profile] section, preserving machine client_id/client_secret already there. +func TestWriteLoginToken_PreservesMachineCredentials(t *testing.T) { + w := newHomeWriter(t) + if err := w.WriteCredentials("default", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials: %v", err) + } + + exp := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + if err := w.WriteLoginToken("default", "rt-123", exp, "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + + s := loadCredsFile(t).Section("default") + // Machine creds intact. + if s.Key("client_id").String() != "cid" { + t.Errorf("client_id=%q, want cid", s.Key("client_id").String()) + } + if s.Key("client_secret").String() != "cs" { + t.Errorf("client_secret=%q, want cs", s.Key("client_secret").String()) + } + // Login keys written. + if s.Key("refresh_token").String() != "rt-123" { + t.Errorf("refresh_token=%q, want rt-123", s.Key("refresh_token").String()) + } + if s.Key("auth_mode").String() != "user" { + t.Errorf("auth_mode=%q, want user", s.Key("auth_mode").String()) + } + // login_client_id is intentionally NOT written — it is a public id resolved + // from iam_env at refresh, not persisted in the credentials INI. + if s.Key("login_client_id").String() != "" { + t.Errorf("login_client_id=%q, want empty (not persisted)", s.Key("login_client_id").String()) + } + if s.Key("iam_env").String() != "dev" { + t.Errorf("iam_env=%q, want dev", s.Key("iam_env").String()) + } + // token_expires_at is RFC3339 UTC and round-trips. + got, err := time.Parse(time.RFC3339, s.Key("token_expires_at").String()) + if err != nil { + t.Errorf("token_expires_at unparseable: %v", err) + } + if !got.Equal(exp) { + t.Errorf("token_expires_at=%v, want %v", got, exp) + } +} + +// An empty refresh token is a no-op so a stale empty value can never erase a +// prior good token. (cmd/login also skips the call on partial success.) +func TestWriteLoginToken_EmptyRefreshTokenIsNoop(t *testing.T) { + w := newHomeWriter(t) + // Seed an existing good token. + if err := w.WriteLoginToken("default", "rt-good", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("seed WriteLoginToken: %v", err) + } + // Empty refresh token must not touch the file. + if err := w.WriteLoginToken("default", "", time.Time{}, "user", "dev"); err != nil { + t.Fatalf("empty WriteLoginToken: %v", err) + } + if got := loadCredsFile(t).Section("default").Key("refresh_token").String(); got != "rt-good" { + t.Errorf("empty refresh_token overwrote prior value; got %q, want rt-good", got) + } +} + +// WriteLoginToken on one profile leaves a different profile's section untouched +// (per-profile isolation — the consolidation's reason for existing). +func TestWriteLoginToken_PerProfileIsolation(t *testing.T) { + w := newHomeWriter(t) + if err := w.WriteLoginToken("default", "rt-prod", time.Now().UTC(), "user", "prod"); err != nil { + t.Fatalf("default WriteLoginToken: %v", err) + } + if err := w.WriteLoginToken("dev", "rt-dev", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("dev WriteLoginToken: %v", err) + } + + f := loadCredsFile(t) + if got := f.Section("default").Key("refresh_token").String(); got != "rt-prod" { + t.Errorf("default refresh_token=%q, want rt-prod", got) + } + if got := f.Section("dev").Key("refresh_token").String(); got != "rt-dev" { + t.Errorf("dev refresh_token=%q, want rt-dev", got) + } +} + +// ClearLoginToken removes only the login keys, leaving machine creds intact. +func TestClearLoginToken_KeepsMachineCredentials(t *testing.T) { + w := newHomeWriter(t) + if err := w.WriteCredentials("default", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials: %v", err) + } + if err := w.WriteLoginToken("default", "rt-123", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + + if err := w.ClearLoginToken("default"); err != nil { + t.Fatalf("ClearLoginToken: %v", err) + } + s := loadCredsFile(t).Section("default") + if s.Key("client_id").String() != "cid" { + t.Errorf("client_id=%q, want cid (logout must keep machine creds)", s.Key("client_id").String()) + } + if s.Key("client_secret").String() != "cs" { + t.Errorf("client_secret=%q, want cs", s.Key("client_secret").String()) + } + for _, k := range loginTokenKeys { + if v := s.Key(k).String(); v != "" { + t.Errorf("login key %q=%q after ClearLoginToken, want empty", k, v) + } + } +} + +// ClearLoginToken is idempotent: a missing file, a missing section, and an +// already-cleared section are all non-errors. +func TestClearLoginToken_Idempotent(t *testing.T) { + w := newHomeWriter(t) + // Missing file entirely. + if err := w.ClearLoginToken("default"); err != nil { + t.Errorf("clear on missing file: %v", err) + } + // File exists but no section for the profile. + if err := w.WriteCredentials("other", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials: %v", err) + } + if err := w.ClearLoginToken("ghost"); err != nil { + t.Errorf("clear on missing section: %v", err) + } + // Section exists but already cleared. + if err := w.WriteCredentials("default", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials default: %v", err) + } + if err := w.ClearLoginToken("default"); err != nil { + t.Fatalf("clear default once: %v", err) + } + if err := w.ClearLoginToken("default"); err != nil { + t.Errorf("clear default twice (already cleared): %v", err) + } +} + +// ClearLoginToken also removes a legacy login_client_id key written by older +// CLI versions, so a logout fully clears a prior login even though +// WriteLoginToken no longer writes that key (the client_id is now resolved from +// iam_env at refresh, not persisted). +func TestClearLoginToken_RemovesLegacyLoginClientID(t *testing.T) { + w := newHomeWriter(t) + if err := os.MkdirAll(filepath.Dir(credsPath(t)), 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + legacy := "[default]\nauth_mode = user\nrefresh_token = rt\nlogin_client_id = cid-legacy\niam_env = dev\n" + if err := os.WriteFile(credsPath(t), []byte(legacy), 0600); err != nil { + t.Fatalf("write legacy creds: %v", err) + } + if err := w.ClearLoginToken("default"); err != nil { + t.Fatalf("ClearLoginToken: %v", err) + } + s := loadCredsFile(t).Section("default") + for _, k := range loginTokenKeys { + if v := s.Key(k).String(); v != "" { + t.Errorf("login key %q=%q after ClearLoginToken, want empty", k, v) + } + } + for _, k := range loginTokenKeysLegacy { + if v := s.Key(k).String(); v != "" { + t.Errorf("legacy login key %q=%q after ClearLoginToken, want empty", k, v) + } + } +} + +// LoadConfig reads the login keys back into Config from the credentials INI. +func TestLoadConfig_ReadsLoginKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + for _, k := range []string{"GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { + t.Setenv(k, "") + } + dir := filepath.Join(home, ".greennode") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + exp := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + writeFile(t, filepath.Join(dir, "credentials"), + "[default]\n"+ + "auth_mode = user\n"+ + "refresh_token = rt-xyz\n"+ + "token_expires_at = "+exp.Format(time.RFC3339)+"\n"+ + "login_client_id = cid-login\n"+ + "iam_env = dev\n") + + cfg, err := LoadConfig("default") + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.AuthMode != "user" { + t.Errorf("AuthMode=%q, want user", cfg.AuthMode) + } + if cfg.RefreshToken != "rt-xyz" { + t.Errorf("RefreshToken=%q, want rt-xyz", cfg.RefreshToken) + } + if !cfg.TokenExpiresAt.Equal(exp) { + t.Errorf("TokenExpiresAt=%v, want %v", cfg.TokenExpiresAt, exp) + } + // login_client_id in the seed file is legacy and now ignored (the field is + // gone; the client_id is resolved from iam_env at refresh) — LoadConfig must + // not choke on a legacy key, it just doesn't surface it. + if cfg.IamEnv != "dev" { + t.Errorf("IamEnv=%q, want dev", cfg.IamEnv) + } +} + +// A login-only profile (no machine client_id/client_secret) "exists": LoadConfig +// must not reject it as "profile does not exist". +func TestLoadConfig_LoginOnlyProfileExists(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + for _, k := range []string{"GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { + t.Setenv(k, "") + } + dir := filepath.Join(home, ".greennode") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, filepath.Join(dir, "credentials"), + "[default]\nauth_mode = user\nrefresh_token = rt\nlogin_client_id = cid\niam_env = dev\n") + + cfg, err := LoadConfig("default") + if err != nil { + t.Fatalf("login-only profile should exist, got error: %v", err) + } + if cfg.RefreshToken != "rt" { + t.Errorf("RefreshToken=%q, want rt", cfg.RefreshToken) + } +} + +// The atomic save writes the credentials file at 0600 (refresh_token is +// secret-at-rest). Regression: the old O_TRUNC path admitted looser perms. +func TestSave_CredentialsFileIs0600(t *testing.T) { + w := newHomeWriter(t) + if err := w.WriteLoginToken("default", "rt", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + fi, err := os.Stat(credsPath(t)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("credentials perm=%o, want 0600", fi.Mode().Perm()) + } + // Re-writing an existing file must re-assert 0600 even if perms drifted. + if err := os.Chmod(credsPath(t), 0644); err != nil { + t.Fatalf("chmod loosen: %v", err) + } + if err := w.WriteLoginToken("default", "rt2", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("rewrite WriteLoginToken: %v", err) + } + fi, _ = os.Stat(credsPath(t)) + if fi.Mode().Perm() != 0600 { + t.Errorf("after rewrite, credentials perm=%o, want 0600 (save must tighten)", fi.Mode().Perm()) + } +} + +// The atomic save leaves no leftover temp files in the config dir. +func TestSave_NoLeftoverTempFiles(t *testing.T) { + w := newHomeWriter(t) + if err := w.WriteLoginToken("default", "rt", time.Now().UTC(), "user", "dev"); err != nil { + t.Fatalf("WriteLoginToken: %v", err) + } + entries, err := os.ReadDir(filepath.Dir(credsPath(t))) + if err != nil { + t.Fatalf("readdir: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".cfg-") { + t.Errorf("leftover temp file %q after save", e.Name()) + } + } +} diff --git a/go/internal/login/doc.go b/go/internal/login/doc.go new file mode 100644 index 0000000..212b764 --- /dev/null +++ b/go/internal/login/doc.go @@ -0,0 +1,15 @@ +// Package login implements a native PKCE authorization-code login against VNG +// IAM for the greennode CLI. The CLI is the OAuth client: it generates the PKCE +// verifier/challenge, binds a loopback redirect listener, opens the browser to +// IAM's signin page, exchanges the authorization code at IAM's /v2 token +// endpoint (sending client_secret via Basic only when configured), and persists +// the refresh token (0600) for reuse across subsequent CLI invocations. +// +// The access token is never persisted; only the refresh token and its expiry. +// +// The IAM-leg code (camelCase authorize URL, client_secret_basic token +// exchange, fail-loud access_token extraction) is lifted from +// agent-core-gateway's internal/oauth/iamidp and internal/clients/idpoauth; the +// authorization-server machinery (sessions, cookies, signed state, discovery) +// is intentionally not lifted — the CLI needs only the client leg. +package login diff --git a/go/internal/login/iamenv.go b/go/internal/login/iamenv.go new file mode 100644 index 0000000..8669987 --- /dev/null +++ b/go/internal/login/iamenv.go @@ -0,0 +1,99 @@ +package login + +import "fmt" + +// IamEndpoint is one IAM environment's OAuth endpoints plus the baked-in +// public client_id for that env. These are PUBLIC OAuth client identifiers +// (RFC 6749) — not secrets — so baking the client_id is safe; the client_secret +// is NEVER baked in (the dev client is public/no-secret). This map is the +// single source of truth shared by the `grn login` authorize flow and the +// subcommand refresh-token path (which needs the /v2 token URL given an env). +// Mirrors the endpoints previously inlined (unexported) in cmd/login/login.go. +type IamEndpoint struct { + Authorize string + Token string + ClientID string +} + +// IamEndpoints holds the prod/dev IAM presets. The Token URL is the /v2 PKCE +// endpoint (distinct from the machine client_credentials v1 endpoint in +// internal/auth/token.go). Authorize is the IAM signin URL the browser opens. +var IamEndpoints = map[string]IamEndpoint{ + "prod": { + Authorize: "https://signin.vngcloud.vn/ap/auth", + Token: "https://iam.api.vngcloud.vn/accounts-api/v2/auth/token", + ClientID: ProdClientID, + }, + "dev": { + Authorize: "https://dev-signin.vngcloud.tech/ap/auth", + Token: "https://pub-iamapis.api-dev.vngcloud.tech/accounts-api/v2/auth/token", + ClientID: DevClientID, + }, +} + +// DevClientID is the public (no-secret, redirect-*) OAuth client registered on +// the IAM dev portal. A non-secret identifier; safe to bake in. Used as the +// default client_id for `grn login --iam-env dev` and for the refresh path on a +// dev login profile. +const DevClientID = "70a17ade-b887-4354-9ecb-cfcfd06150b0" + +// ProdClientID is the public (no-secret, redirect-*) OAuth client registered on +// the IAM prod portal for `grn login`. A non-secret identifier; safe to bake in +// (only the client_secret is secret, and it is never baked). Used as the default +// client_id for `grn login --iam-env prod` (and the prod default) and for the +// refresh path on a prod login profile. +const ProdClientID = "09b427e9-3b45-437b-a3a6-bf9f7fd24185" + +// DefaultIamEnv is the iam-env assumed when neither a flag nor GRN_IAM_ENV +// selects one. Prod is the safe default (matches the console). +const DefaultIamEnv = "prod" + +// TokenURLForEnv returns the /v2 PKCE token endpoint for the given env. A +// refresh path uses this to mint fresh access tokens from the persisted refresh +// token. Empty or unknown env → error (a profile without a valid iam_env cannot +// be refreshed safely). +func TokenURLForEnv(env string) (string, error) { + if env == "" { + return "", fmt.Errorf("iam_env is not set for this profile — run `grn login` again") + } + ep, ok := IamEndpoints[env] + if !ok { + return "", fmt.Errorf("unknown iam_env %q (valid: %s)", env, validIamEnvs()) + } + return ep.Token, nil +} + +// ClientIDForEnv returns the baked-in public client_id for the given env — the +// no-secret OAuth client `grn login` uses for that env, and the client the +// refresh-token grant must present (the refresh token is bound to it at login). +// The subcommand refresh path resolves the client_id from a profile's iam_env +// via this helper rather than reading a persisted login_client_id — the value +// is a public identifier already baked into source (iamenv.go), so storing it +// again in the credentials INI is redundant. Empty/unknown env → error. +func ClientIDForEnv(env string) (string, error) { + if env == "" { + return "", fmt.Errorf("iam_env is not set for this profile — run `grn login` again") + } + ep, ok := IamEndpoints[env] + if !ok { + return "", fmt.Errorf("unknown iam_env %q (valid: %s)", env, validIamEnvs()) + } + return ep.ClientID, nil +} + +// EndpointForEnv returns the full preset for an env (authorize/token/client_id), +// used by the `grn login` authorize flow. Unknown env → error. +func EndpointForEnv(env string) (IamEndpoint, error) { + if env == "" { + return IamEndpoint{}, fmt.Errorf("iam_env is empty (valid: %s)", validIamEnvs()) + } + ep, ok := IamEndpoints[env] + if !ok { + return IamEndpoint{}, fmt.Errorf("invalid iam_env %q (valid: %s)", env, validIamEnvs()) + } + return ep, nil +} + +func validIamEnvs() string { + return "prod, dev" +} diff --git a/go/internal/login/iamenv_test.go b/go/internal/login/iamenv_test.go new file mode 100644 index 0000000..6a6c786 --- /dev/null +++ b/go/internal/login/iamenv_test.go @@ -0,0 +1,108 @@ +package login + +import ( + "strings" + "testing" +) + +func TestTokenURLForEnv_ProdAndDev(t *testing.T) { + t.Parallel() + cases := []struct { + env string + want string + }{ + {"prod", "https://iam.api.vngcloud.vn/accounts-api/v2/auth/token"}, + {"dev", "https://pub-iamapis.api-dev.vngcloud.tech/accounts-api/v2/auth/token"}, + } + for _, tc := range cases { + t.Run(tc.env, func(t *testing.T) { + t.Parallel() + got, err := TokenURLForEnv(tc.env) + if err != nil { + t.Fatalf("TokenURLForEnv(%q): %v", tc.env, err) + } + if got != tc.want { + t.Errorf("TokenURLForEnv(%q)=%q, want %q", tc.env, got, tc.want) + } + }) + } +} + +func TestTokenURLForEnv_EmptyAndUnknownError(t *testing.T) { + t.Parallel() + for _, env := range []string{"", "staging", "prod "} { + _, err := TokenURLForEnv(env) + if err == nil { + t.Errorf("TokenURLForEnv(%q) succeeded, want error", env) + continue + } + // Empty env gets a "run `grn login`" hint; unknown gets the valid list. + if env == "" && !strings.Contains(err.Error(), "grn login") { + t.Errorf("empty-env err=%q, want guidance mentioning `grn login`", err) + } + } +} + +func TestIamEndpoints_BakedClientIDs(t *testing.T) { + t.Parallel() + if got := IamEndpoints["dev"].ClientID; got != DevClientID { + t.Errorf("dev ClientID=%q, want DevClientID %q", got, DevClientID) + } + if got := IamEndpoints["prod"].ClientID; got != ProdClientID { + t.Errorf("prod ClientID=%q, want ProdClientID %q", got, ProdClientID) + } + // The dev token URL must differ from the prod v2 URL (and from the v1 machine + // endpoint) — a mix-up would send refreshes to the wrong host. + if IamEndpoints["dev"].Token == IamEndpoints["prod"].Token { + t.Error("dev and prod share a token URL; expected distinct per-env endpoints") + } +} + +func TestEndpointForEnv(t *testing.T) { + t.Parallel() + ep, err := EndpointForEnv("dev") + if err != nil { + t.Fatalf("EndpointForEnv(dev): %v", err) + } + if ep.Authorize == "" || ep.Token == "" || ep.ClientID == "" { + t.Errorf("dev preset incomplete: %+v", ep) + } + if _, err := EndpointForEnv("nope"); err == nil { + t.Error("EndpointForEnv(nope) succeeded, want error") + } + if _, err := EndpointForEnv(""); err == nil { + t.Error("EndpointForEnv() succeeded, want error") + } +} + +// TestClientIDForEnv covers the refresh-path helper that resolves the client_id +// from iam_env (so login_client_id need not be persisted). dev/prod return their +// baked-in public ids; empty/unknown env error (a profile without a valid +// iam_env cannot be refreshed safely). +func TestClientIDForEnv(t *testing.T) { + t.Parallel() + cases := []struct { + env string + want string + }{ + {"prod", ProdClientID}, + {"dev", DevClientID}, + } + for _, tc := range cases { + t.Run(tc.env, func(t *testing.T) { + t.Parallel() + got, err := ClientIDForEnv(tc.env) + if err != nil { + t.Fatalf("ClientIDForEnv(%q): %v", tc.env, err) + } + if got != tc.want { + t.Errorf("ClientIDForEnv(%q)=%q, want %q", tc.env, got, tc.want) + } + }) + } + for _, env := range []string{"", "staging"} { + if _, err := ClientIDForEnv(env); err == nil { + t.Errorf("ClientIDForEnv(%q) succeeded, want error", env) + } + } +} diff --git a/go/internal/login/iamidp.go b/go/internal/login/iamidp.go new file mode 100644 index 0000000..16c94c1 --- /dev/null +++ b/go/internal/login/iamidp.go @@ -0,0 +1,73 @@ +package login + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// Config is the resolved IAM login configuration, env-specific. All fields are +// sourced by the caller (the future root grn login command) from greennode-cli's +// per-env config; this package does no config loading itself. +type Config struct { + // AuthorizeURL is IAM's signin page, e.g. + // https://signin.vngcloud.vn/ap/auth (301 → signin.greennode.ai). + AuthorizeURL string + // TokenURL is IAM's OAuth /v2 token endpoint. + TokenURL string + // ClientID is the CLI's IAM OAuth client id. + ClientID string + // ClientSecret is "" for a public client (PKCE-only) or non-empty for a + // confidential client (sent via Basic at the token endpoint). + ClientSecret string + // Scopes are optional OAuth scopes, e.g. ["openid"]. + Scopes []string +} + +// BuildAuthorizeURL renders IAM's camelCase signin URL: clientId, +// responseType=code, codeChallenge, codeChallengeMethod=S256, appState, +// redirectUri (+ scope when Scopes is non-empty). signedState is the opaque +// appState carrier (the CLI's nonce). Mirrors agent-core-gateway +// internal/oauth/iamidp/adapter.go:51-69. +func (c Config) BuildAuthorizeURL(redirectURI, codeChallenge, signedState string) string { + u, err := url.Parse(c.AuthorizeURL) + if err != nil || u == nil { + // AuthorizeURL is assumed validated upstream; fall back to raw so an + // error is visible rather than silently dropped. + return c.AuthorizeURL + } + q := u.Query() + q.Set("clientId", c.ClientID) + q.Set("responseType", "code") + q.Set("codeChallenge", codeChallenge) + q.Set("codeChallengeMethod", "S256") + q.Set("appState", signedState) + q.Set("redirectUri", redirectURI) + if scope := c.ScopeString(); scope != "" { + q.Set("scope", scope) + } + u.RawQuery = q.Encode() + return u.String() +} + +// ScopeString joins Scopes with a space (OAuth scope syntax), or "" when none. +func (c Config) ScopeString() string { return strings.Join(c.Scopes, " ") } + +// AccessTokenFrom extracts access_token from a /v2 token response body, +// returning an error when the body is not JSON or carries no non-empty +// access_token. This is the fail-loud guard against an unexpected response +// shape — a silent decode-to-empty must be impossible. Mirrors +// agent-core-gateway internal/oauth/iamidp/adapter.go:84-95. +func (c Config) AccessTokenFrom(raw []byte) (string, error) { + var body struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(raw, &body); err != nil { + return "", fmt.Errorf("iamidp: token response is not valid JSON: %w", err) + } + if strings.TrimSpace(body.AccessToken) == "" { + return "", fmt.Errorf("iamidp: token response missing access_token (unexpected /v2 shape)") + } + return body.AccessToken, nil +} diff --git a/go/internal/login/iamidp_test.go b/go/internal/login/iamidp_test.go new file mode 100644 index 0000000..13b623d --- /dev/null +++ b/go/internal/login/iamidp_test.go @@ -0,0 +1,95 @@ +package login + +import ( + "net/url" + "strings" + "testing" +) + +func cfgForTest() Config { + return Config{ + AuthorizeURL: "https://signin.vngcloud.vn/ap/auth", + TokenURL: "https://iam.api.vngcloud.vn/accounts-api/v2/auth/token", + ClientID: "cli-client-id", + Scopes: []string{"openid"}, + } +} + +func TestBuildAuthorizeURL_BaselineCamelCase(t *testing.T) { + t.Parallel() + c := cfgForTest() + got := c.BuildAuthorizeURL("http://127.0.0.1:8080/callback", "Z0iBB67yj6V", "state-nonce") + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v\nurl=%s", err, got) + } + q := u.Query() + cases := map[string]string{ + "clientId": "cli-client-id", + "responseType": "code", + "codeChallenge": "Z0iBB67yj6V", + "codeChallengeMethod": "S256", + "appState": "state-nonce", + "redirectUri": "http://127.0.0.1:8080/callback", + "scope": "openid", + } + for k, want := range cases { + if got := q.Get(k); got != want { + t.Errorf("query[%s]=%q, want %q", k, got, want) + } + } + if u.Scheme != "https" || u.Host != "signin.vngcloud.vn" || u.Path != "/ap/auth" { + t.Errorf("base URL changed: %s", got) + } +} + +func TestBuildAuthorizeURL_ScopeOmittedWhenEmpty(t *testing.T) { + t.Parallel() + c := cfgForTest() + c.Scopes = nil + got := c.BuildAuthorizeURL("http://127.0.0.1:8080/callback", "ch", "st") + u, _ := url.Parse(got) + if _, ok := u.Query()["scope"]; ok { + t.Errorf("scope must be omitted when Scopes is empty; url=%s", got) + } +} + +func TestBuildAuthorizeURL_RejectsSnakeCaseSynonyms(t *testing.T) { + t.Parallel() + c := cfgForTest() + got := c.BuildAuthorizeURL("http://127.0.0.1:8080/callback", "ch", "st") + // IAM uses camelCase; snake_case params must NOT appear. + for _, bad := range []string{"client_id", "response_type", "code_challenge", "code_challenge_method", "state", "redirect_uri"} { + if strings.Contains(got, bad+"=") || strings.Contains(got, bad+"&") { + t.Errorf("snake_case param %q must not appear; url=%s", bad, got) + } + } +} + +func TestAccessTokenFrom_ReturnsToken(t *testing.T) { + t.Parallel() + c := cfgForTest() + body := []byte(`{"access_token":"abc.def.ghi","token_type":"Bearer","expires_in":3600}`) + got, err := c.AccessTokenFrom(body) + if err != nil { + t.Fatalf("AccessTokenFrom: %v", err) + } + if got != "abc.def.ghi" { + t.Errorf("got %q, want abc.def.ghi", got) + } +} + +func TestAccessTokenFrom_FailLoudOnMissing(t *testing.T) { + t.Parallel() + c := cfgForTest() + for _, body := range [][]byte{ + []byte(`{"token_type":"Bearer"}`), // no access_token + []byte(`{"access_token":""}`), // empty access_token + []byte(`{}`), // empty object + []byte(`not json at all`), // not JSON + } { + if _, err := c.AccessTokenFrom(body); err == nil { + t.Errorf("expected error for body %q, got nil", string(body)) + } + } +} diff --git a/go/internal/login/listen.go b/go/internal/login/listen.go new file mode 100644 index 0000000..f3887b8 --- /dev/null +++ b/go/internal/login/listen.go @@ -0,0 +1,339 @@ +package login + +import ( + "context" + "fmt" + "html/template" + "net" + "net/http" + "strings" + "sync" +) + +// ErrAuthzDenied is returned by Serve when the callback carries RFC 6749 +// §4.1.2.1 error params (?error=…): the user denied consent or IAM rejected the +// authorize request. +type ErrAuthzDenied struct { + Code string + Description string +} + +func (e *ErrAuthzDenied) Error() string { + return fmt.Sprintf("login: authorization denied (code=%s): %s", e.Code, e.Description) +} + +const callbackPath = "/callback" + +// Listener is a one-shot loopback HTTP server that captures the IAM callback. +// It binds 127.0.0.1:/callback, serves exactly one request, then +// shuts down (RFC 8252 loopback redirect URI). +type Listener struct { + uri string + srv *http.Server + bound net.Listener + mu sync.Mutex + code string + state string + cbErr error + wantState string // when non-empty, handle rejects callbacks whose appState differs + received chan struct{} // closed on first callback +} + +// setExpectedState arms the state-mismatch check. When set to a non-empty nonce, +// handle serves a failure page and Serve returns ErrStateMismatch if the +// callback's appState differs from it. The zero value (empty) disables the +// check, leaving the listener nonce-agnostic (how the unit tests exercise it). +func (l *Listener) setExpectedState(s string) { l.wantState = s } + +// NewListener binds 127.0.0.1:0 (random ephemeral port) and returns a Listener +// whose RedirectURI() is http://127.0.0.1:/callback. Serve starts +// accepting. +func NewListener() (*Listener, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("login: bind loopback: %w", err) + } + port := ln.Addr().(*net.TCPAddr).Port + l := &Listener{ + uri: fmt.Sprintf("http://127.0.0.1:%d/callback", port), + bound: ln, + received: make(chan struct{}), + } + mux := http.NewServeMux() + mux.HandleFunc(callbackPath, l.handle) + l.srv = &http.Server{Handler: mux} + return l, nil +} + +// RedirectURI returns the URI to put on the IAM authorize URL. +func (l *Listener) RedirectURI() string { return l.uri } + +// Close releases the bound listener. Safe to call multiple times (idempotent). +func (l *Listener) Close() error { + if l.bound != nil { + err := l.bound.Close() + l.bound = nil + return err + } + return nil +} + +// Serve runs the server until the first callback arrives or ctx is cancelled. +// Returns the parsed code and appState; RFC 6749 error params → *ErrAuthzDenied. +func (l *Listener) Serve(ctx context.Context) (string, string, error) { + serveErr := make(chan error, 1) + go func() { + serveErr <- l.srv.Serve(l.bound) // ErrServerClosed on Shutdown + }() + + ctxCancel := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = l.srv.Shutdown(context.Background()) + close(ctxCancel) + case <-l.received: + // callback arrived; Serve's <-received branch drains srv.Serve. + } + }() + + select { + case <-l.received: + // handle() captured code/state/cbErr and triggered Shutdown. + <-serveErr // wait for the server goroutine to finish + return l.code, l.state, l.cbErr + case <-ctxCancel: + <-serveErr + return "", "", ctx.Err() + case <-serveErr: + // Server exited on its own (e.g. accept error) without a callback. + // On ctx cancel, Shutdown (from the watcher) closes the listener and + // srv.Serve returns here before ctxCancel is observed — surface the + // honest ctx error so errors.Is(., context.Canceled) holds. + if err := ctx.Err(); err != nil { + return "", "", err + } + return "", "", fmt.Errorf("login: listener exited without callback: %w", l.cbErr) + } +} + +// handle is the /callback handler. Extracts code+appState (or error params), +// serves a static result page, and signals Serve to return. One-shot: after the +// first request it shuts the server down. +func (l *Listener) handle(w http.ResponseWriter, r *http.Request) { + l.mu.Lock() + defer l.mu.Unlock() + // Shutdown in a goroutine: calling (*http.Server).Shutdown synchronously + // from within the handler self-deadlocks (Shutdown waits for this in-flight + // connection to drain, which can't happen until handle returns). Running it + // async lets handle return, the response flush, and the connection go idle so + // the Shutdown goroutine then completes. Triggered by handle (one-shot). + defer func() { go l.srv.Shutdown(context.Background()) }() + q := r.URL.Query() + if e := q.Get("error"); e != "" { + l.cbErr = &ErrAuthzDenied{Code: e, Description: q.Get("error_description")} + writeResult(w, r, false, resultDenied) + l.signal() + return + } + l.code = q.Get("code") + l.state = q.Get("appState") + // When an expected nonce is armed (by Login), a mismatch is a stale or forged + // callback: serve a failure page and report ErrStateMismatch. Empty wantState + // disables the check (unit tests exercise the listener without one). + if l.wantState != "" && l.state != l.wantState { + l.cbErr = ErrStateMismatch + writeResult(w, r, false, resultMismatch) + l.signal() + return + } + writeResult(w, r, true, resultSuccess) + l.signal() +} + +// signal closes l.received once (guard against duplicate close if a second +// request slips in before Shutdown takes effect). +func (l *Listener) signal() { + select { + case <-l.received: + default: + close(l.received) + } +} + +// resultKind identifies which callback outcome the page reports. +type resultKind int + +const ( + resultSuccess resultKind = iota + resultDenied + resultMismatch +) + +// titleDesc is the title for one language's result page. +type titleDesc struct{ Title string } + +// resultCopy holds the bilingual (en/vi) title per outcome. The English titles +// mirror the GreenNode console's MCP login-callback page +// (aiplatform.console.greennode.ai/mcp/login-callback, i18n key +// mcpConnector.publicCallback.*) so the local `grn login` UX matches the +// console verbatim. Under the title, closeCaptions adds a one-line "you can +// close this tab" instruction (the tab is OS-launched, so a script +// window.close() button cannot close it — see resultPageTmpl). Vietnamese is +// taken from the console's vi.json. +var resultCopy = map[resultKind]struct{ En, Vi titleDesc }{ + resultSuccess: { + En: titleDesc{Title: "Authorization successful"}, + Vi: titleDesc{Title: "Xác thực thành công"}, + }, + resultDenied: { + En: titleDesc{Title: "Authorization failed"}, + Vi: titleDesc{Title: "Xác thực thất bại"}, + }, + resultMismatch: { + En: titleDesc{Title: "Authorization failed"}, + Vi: titleDesc{Title: "Xác thực thất bại"}, + }, +} + +// closeCaptions is the bilingual one-line instruction shown under the title on +// every result page: the IAM authorize URL is opened by the OS browser launcher +// (not by window.open), so the callback tab is OS-launched and browsers only +// allow scripts to close script-opened windows — a "Close tab" button would +// silently do nothing. The page instead tells the user to close the tab +// manually. Identical across outcomes (success/denied/mismatch); the grn +// process in the terminal reports the result either way. +var closeCaptions = struct{ En, Vi string }{ + En: "You can close this tab.", + Vi: "Bạn có thể đóng tab này.", +} + +// brandMarkImg is the GreenNode brand mark, emitted as a trusted template.HTML +// so the data:image/png URI is not rewritten by html/template's safe-URL filter +// (which only allows http(s)/mailto in a `src` attribute and would otherwise +// replace it with the #ZgotmplZ sentinel). It is a package CONSTANT — the PNG is +// the official GreenNode brand mark (provided at /Users/lap15626/Downloads/ +// greennode.png, 447×447 palette PNG; downscaled to 256×256 for a crisp inline +// data-URI at the rendered 72px height) — and NO query param or user input +// reaches it, so marking it template.HTML is not an XSS sink. It is the page's +// sole brand element (no wordmark text alongside). The page stays fully +// self-contained (renders offline, no CDN/asset fetch). +const brandMarkImg = template.HTML(`GreenNode`) + +// resultPageTmpl renders the local PKCE callback result page. It mirrors the +// GreenNode console's MCP login-callback page so local `grn login` shares the +// same UX: GreenNode brand mark, the console's animated result-icon (a green +// check on success, a red X on failure), a title, and a one-line "you can +// close this tab" instruction. The console's button is "Go to home"; the local +// page has nowhere to go. A "Close tab" (window.close()) button was tried but +// cannot work: `grn login` opens the IAM authorize URL via the OS browser +// launcher, so the callback tab is OS-launched (not window.open'd), and +// browsers only permit scripts to close script-opened windows. So the page +// instead tells the user to close the tab manually (closeCaptions) — no button. +// +// The result-icon SVG geometry (ring/halo circle r=24, check path +// M14 27l8 8 16-16, X bars 16,16↔32,32) is copied VERBATIM from the console's +// compiled mcp-connector-callback chunk. Colors are the console's: primary +// #309036, text-success #40c646, text-danger #ff2633. +// +// The ANIMATION intentionally diverges from the console (which runs the +// per-stroke `stroke-dashoffset` draw-in as an infinite loop ending at +// opacity:0). That design hides each stroke behind a dashoffset and only +// reveals it mid-cycle, so the icon spends most of its time partially or +// fully invisible — on a one-shot callback page that read as "the X is +// missing, just a red circle" and "the V is covered in a box." Here the +// strokes are ALWAYS fully drawn (no dasharray/dashoffset): the only motion +// is a single whole-SVG entrance (`result-icon-cycle`: scale .5→1.06→1 with +// a fade, `forwards`, ending at opacity:1) plus a faint halo fade-in. The +// icon is therefore visible from the first frame and never depends on +// dashoffset timing to become legible. The carries `overflow:visible` +// and a padded viewBox ("-2 -2 52 52") so the ring's 3px stroke (reaching +// r=25.5) is not clipped to a square at the four cardinal points. +// +// The page is fully self-contained (no CDN/fonts/JS deps) so it renders offline; +// Inter is preferred but falls back to the system font. Bilingual en/vi; the +// language is chosen from the request's Accept-Language (vi preferred → vi), +// defaulting to English. +// +// Security: the template is static — only Title/Close/Lang from resultCopy +// (package constants) reach it, NEVER query params, so there is no XSS sink. +var resultPageTmpl = template.Must(template.New("result").Parse(` + + + + +grn login + + + +
+
{{.BrandImg}}
+
+ +
+

{{.Title}}

+

{{.Close}}

+
+ +`)) + +// preferVietnamese returns true when the request's Accept-Language prefers +// Vietnamese (the first comma-separated tag starts with "vi"). Defaults to +// English otherwise. This is a deliberately simple heuristic — the browser +// signals the user's language and we honor it; no CLI flag needed. +func preferVietnamese(r *http.Request) bool { + al := r.Header.Get("Accept-Language") + if al == "" { + return false + } + first := al + if i := strings.IndexByte(al, ','); i >= 0 { + first = al[:i] + } + if i := strings.IndexByte(first, ';'); i >= 0 { + first = first[:i] + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(first)), "vi") +} + +func writeResult(w http.ResponseWriter, r *http.Request, ok bool, kind resultKind) { + status := http.StatusOK + if !ok { + status = http.StatusBadRequest + } + cp := resultCopy[kind] + lang := "en" + title, closeLine := cp.En.Title, closeCaptions.En + if preferVietnamese(r) { + lang = "vi" + title, closeLine = cp.Vi.Title, closeCaptions.Vi + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _ = resultPageTmpl.Execute(w, struct { + Lang string + Ok bool + Title string + Close string + BrandImg template.HTML + }{Lang: lang, Ok: ok, Title: title, Close: closeLine, BrandImg: brandMarkImg}) +} diff --git a/go/internal/login/listen_test.go b/go/internal/login/listen_test.go new file mode 100644 index 0000000..bc87c27 --- /dev/null +++ b/go/internal/login/listen_test.go @@ -0,0 +1,358 @@ +package login + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestListener_BindsLoopback_RandomPort(t *testing.T) { + t.Parallel() + l1, err := NewListener() + if err != nil { + t.Fatalf("NewListener: %v", err) + } + defer l1.Close() + uri := l1.RedirectURI() + if !strings.HasPrefix(uri, "http://127.0.0.1:") { + t.Errorf("RedirectURI=%q, want http://127.0.0.1:/callback", uri) + } + l2, err := NewListener() + if err != nil { + t.Fatalf("NewListener 2: %v", err) + } + defer l2.Close() + if l1.RedirectURI() == l2.RedirectURI() { + t.Error("two listeners should bind different random ports") + } +} + +func TestServe_ReturnsCodeAndState(t *testing.T) { + t.Parallel() + l, err := NewListener() + if err != nil { + t.Fatalf("NewListener: %v", err) + } + defer l.Close() + + done := make(chan struct{}) + var code, state string + var sErr error + go func() { + code, state, sErr = l.Serve(context.Background()) + close(done) + }() + + // Give the goroutine a moment to call Accept, then GET the callback. + uri := l.RedirectURI() + "?code=the-code&appState=the-nonce" + resp, err := http.Get(uri) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + resp.Body.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Serve did not return within 2s") + } + if sErr != nil { + t.Fatalf("Serve err: %v", sErr) + } + if code != "the-code" { + t.Errorf("code=%q, want the-code", code) + } + if state != "the-nonce" { + t.Errorf("state=%q, want the-nonce", state) + } +} + +func TestServe_ErrorParamsReturnErrAuthzDenied(t *testing.T) { + t.Parallel() + l, err := NewListener() + if err != nil { + t.Fatalf("NewListener: %v", err) + } + defer l.Close() + + done := make(chan struct{}) + var sErr error + go func() { + _, _, sErr = l.Serve(context.Background()) + close(done) + }() + + uri := l.RedirectURI() + "?error=access_denied&error_description=user+cancelled" + resp, err := http.Get(uri) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + resp.Body.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Serve did not return within 2s") + } + denied, ok := sErr.(*ErrAuthzDenied) + if !ok { + t.Fatalf("sErr=%v, want *ErrAuthzDenied", sErr) + } + if denied.Code != "access_denied" { + t.Errorf("Code=%q, want access_denied", denied.Code) + } +} + +func TestServe_ContextCancelShutsDown(t *testing.T) { + t.Parallel() + l, err := NewListener() + if err != nil { + t.Fatalf("NewListener: %v", err) + } + defer l.Close() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + var sErr error + go func() { + _, _, sErr = l.Serve(ctx) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Serve did not shut down within 2s of ctx cancel") + } + if !errors.Is(sErr, context.Canceled) { + t.Errorf("Serve cancel err=%v, want context.Canceled", sErr) + } +} + +func TestServe_StateMismatchServesFailurePage(t *testing.T) { + t.Parallel() + l, err := NewListener() + if err != nil { + t.Fatalf("NewListener: %v", err) + } + defer l.Close() + l.setExpectedState("the-nonce") // arm the nonce check (as Login does) + + done := make(chan struct{}) + var sErr error + go func() { + _, _, sErr = l.Serve(context.Background()) + close(done) + }() + + // Callback carries a DIFFERENT appState than the armed nonce. + uri := l.RedirectURI() + "?code=c&appState=WRONG" + resp, err := http.Get(uri) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + defer resp.Body.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Serve did not return within 2s") + } + if !errors.Is(sErr, ErrStateMismatch) { + t.Errorf("Serve err=%v, want ErrStateMismatch", sErr) + } + // The honest UX: a mismatch shows a FAILURE page, not "Login complete". + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status=%d, want %d (failure page on state mismatch)", resp.StatusCode, http.StatusBadRequest) + } +} + +// TestServe_SuccessPageRendersUnifiedUI proves the local callback page mirrors +// the console's MCP login-callback UX: GreenNode brand, a success title, and a +// one-line "you can close this tab" instruction (no button — the tab is +// OS-launched, so window.close() cannot close it; see listen.go closeCaptions). +// Security-critical: no query-param value is reflected into the page (XSS guard +// — the page is static; Title/Close come from code constants only). +func TestServe_SuccessPageRendersUnifiedUI(t *testing.T) { + t.Parallel() + l, err := NewListener() + if err != nil { + t.Fatalf("NewListener: %v", err) + } + defer l.Close() + + done := make(chan struct{}) + go func() { l.Serve(context.Background()); close(done) }() + + // A distinctive code value we then assert is NOT reflected back. + uri := l.RedirectURI() + "?code=SECRET-CODE-XYZ&appState=n" + resp, err := http.Get(uri) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("status=%d, want 200 (success page)", resp.StatusCode) + } + bs := string(body) + // Console-faithful copy: the success TITLE is the console's i18n string, + // not a local placeholder; the close instruction is "You can close this + // tab."; the brand row is the GreenNode logo image alone (no wordmark text + // — the wordmark lives in the logo). The success result-icon is the check + // geometry (not the X). + for _, want := range []string{"Authorization successful", "You can close this tab.", `result-icon__check`, `GreenNode<", `alert(1)" + resp, err := http.Get(uri) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status=%d, want 400 (denied page)", resp.StatusCode) + } + bs := string(body) + for _, want := range []string{"Authorization failed", "You can close this tab.", `result-icon__bar--1`} { + if !strings.Contains(bs, want) { + t.Errorf("denied page missing %q; body: %s", want, bs) + } + } + // The success check must NOT appear on the denied page (it uses the X bars). + if strings.Contains(bs, "result-icon__check") { + t.Errorf("denied page shows success check; want X bars; body: %s", bs) + } + // The raw error code and the injected script payload must NOT appear. + for _, bad := range []string{"access_denied", "