From 86c6d1c7c5f1b839a6c79ecf49df652c3c2531bc Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 5 Oct 2026 10:02:54 +0200 Subject: [PATCH 1/3] feat(auth): print an access token with meshstack auth token It prints the token the resolved credential sends, renewed where it is about to expire, and nothing else on stdout. Someone who wants to try the API docs against their meshStack pastes it as the Bearer token there. Co-Authored-By: Claude Opus 5.5 --- cmd/auth/auth.go | 1 + cmd/auth/token.go | 36 ++++++++++++++++++ cmd/internal/testacc/login_test.go | 57 +++++++++++++++------------- cmd/internal/testacc/testacc_test.go | 6 ++- 4 files changed, 71 insertions(+), 29 deletions(-) create mode 100644 cmd/auth/token.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 10a5e01..df23375 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -17,6 +17,7 @@ func New() *cobra.Command { cmd.AddCommand(newLogin()) cmd.AddCommand(newLogout()) cmd.AddCommand(newStatus()) + cmd.AddCommand(newToken()) return cmd } diff --git a/cmd/auth/token.go b/cmd/auth/token.go new file mode 100644 index 0000000..4407c79 --- /dev/null +++ b/cmd/auth/token.go @@ -0,0 +1,36 @@ +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/meshcloud/meshstack-cli/cmd/internal" + "github.com/meshcloud/meshstack-cli/internal/auth" +) + +func newToken() *cobra.Command { + return &cobra.Command{ + Use: "token", + Short: "Print an access token for the meshStack API", + Long: `Print the access token a command run with the same flags and environment would send, renewed +where it is about to expire. Nothing else goes to stdout, so $(meshstack auth token) works. + +Paste it as the Bearer token in the API docs to try a request against your meshStack. The token is +short-lived: run this again once the API answers 401. For scripts, meshstack api renews the token by +itself. A browser login prints a token for the workspace this run resolves.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions()) + if err != nil { + return err + } + token, err := session.GetBearerToken(cmd.Context()) + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), token) + return err + }, + } +} diff --git a/cmd/internal/testacc/login_test.go b/cmd/internal/testacc/login_test.go index d9939cf..3ab27a9 100644 --- a/cmd/internal/testacc/login_test.go +++ b/cmd/internal/testacc/login_test.go @@ -119,6 +119,7 @@ func TestAccBrowserLogin(t *testing.T) { require.NoErrorf(t, run.wait(), "the browser login did not finish:\n%s", run.output.String()) }) t.Run("every GET operation answers the organization admin", everyGetOperationAnswers(c, false)) + t.Run("--apitoken logs in with the token auth token prints", apiTokenLogsIn(c)) t.Run("tfstate of a browser login says that it takes an API key", tfstateTakesAnApiKey(c, apiKey)) } @@ -172,19 +173,7 @@ func TestAccApiKeyLogin(t *testing.T) { requireAuthStatus(t, c, "API key") }) - // Reading the token the API key login cached back off disk is the only way to reach --apitoken - // without minting a token, and a configuration directory is writable in CI too. - t.Run("--apitoken logs in with the token the API key login cached", func(t *testing.T) { - withToken := newCLI(t, endpoint) - withToken.setEnv(setting.ApiToken.EnvKey(), cachedApiKeyToken(t, c)) - - output, err := withToken.run("", "login", "--apitoken") - require.NoErrorf(t, err, "the API token login did not finish:\n%s", output) - assert.Contains(t, output, "| meshStack | ", "the login shows the status, with the meshStack it reached") - require.FileExists(t, withToken.credentialsJson()) - requireAuthStatus(t, withToken, "API token") - }) - + t.Run("--apitoken logs in with the token auth token prints", apiTokenLogsIn(c)) t.Run("a listing keeps to the profile's default workspace", listingKeepsToTheDefaultWorkspace(c)) if workspace := c.workspaceHoldingABuildingBlock(t); workspace != "" { c.setEnv(envWorkspace, workspace) @@ -207,6 +196,34 @@ func (c *cli) withApiKey() *cli { return c } +// apiTokenLogsIn sends the token of c's login as an API token, as someone does who pastes it into +// the API docs. +func apiTokenLogsIn(c *cli) func(*testing.T) { + return func(t *testing.T) { + token := c.authToken(t) + withToken := newCLI(t, c.endpoint) + withToken.setEnv(setting.ApiToken.EnvKey(), token) + + output, err := withToken.run("", "login", "--apitoken") + require.NoErrorf(t, err, "the API token login did not finish:\n%s", output) + assert.Contains(t, output, "| meshStack | ", "the login shows the status, with the meshStack it reached") + require.FileExists(t, withToken.credentialsJson()) + requireAuthStatus(t, withToken, "API token") + output, err = withToken.run("", "workspace", "list", "-o", "ndjson") + require.NoErrorf(t, err, "the API refused the token:\n%s", output) + assert.Equal(t, token, withToken.authToken(t), "an API token is printed as it was given") + } +} + +func (c *cli) authToken(t *testing.T) string { + t.Helper() + run := c.start("", "auth", "token") + require.NoErrorf(t, run.wait(), "meshstack auth token failed:\n%s", run.output.String()) + token, oneLine := strings.CutSuffix(run.stdout.String(), "\n") + require.Truef(t, oneLine && !strings.Contains(token, "\n"), "a script reads stdout as the token, but it holds:\n%s", run.stdout.String()) + return token +} + // requireAuthStatus does not check whether this meshStack lets an API key read itself through // /self yet, so it accepts the warnings about the key's details. func requireAuthStatus(t *testing.T, c *cli, wantCredential string) { @@ -231,20 +248,6 @@ func requireStoredLogin(t *testing.T, c *cli, output string) { } } -func cachedApiKeyToken(t *testing.T, c *cli) string { - t.Helper() - content, err := os.ReadFile(c.credentialsCacheJson("apiKey")) - require.NoError(t, err) - var cacheFile struct { - Cache struct { - Token string `json:"token"` - } `json:"cache"` - } - require.NoError(t, json.Unmarshal(content, &cacheFile)) - require.NotEmpty(t, cacheFile.Cache.Token, "the API key login cached no token to log in with") - return cacheFile.Cache.Token -} - func startLogin(t *testing.T, c *cli, workspaceAnswer string) *cliRun { t.Helper() return c.start(workspaceAnswer+"\n", "login") diff --git a/cmd/internal/testacc/testacc_test.go b/cmd/internal/testacc/testacc_test.go index 6a58f8e..1e53b76 100644 --- a/cmd/internal/testacc/testacc_test.go +++ b/cmd/internal/testacc/testacc_test.go @@ -3,6 +3,7 @@ package testacc import ( "context" "encoding/json/v2" + goio "io" "log/slog" gohttp "net/http" "os" @@ -139,6 +140,7 @@ func newRootCommand() *cobra.Command { // binary would write to stdout and stderr, and the log, in the order it was written. type cliRun struct { output *syncBuffer + stdout *syncBuffer cancel context.CancelFunc finished chan struct{} err error @@ -156,7 +158,7 @@ func (c *cli) start(stdin string, args ...string) *cliRun { c.t.Helper() c.applyEnv() ctx, cancel := context.WithCancel(c.t.Context()) - run := &cliRun{output: &syncBuffer{}, cancel: cancel, finished: make(chan struct{})} + run := &cliRun{output: &syncBuffer{}, stdout: &syncBuffer{}, cancel: cancel, finished: make(chan struct{})} previous := slog.Default() c.t.Cleanup(func() { slog.SetDefault(previous) }) slog.SetDefault(slog.New(slog.NewTextHandler(run.output, nil))) @@ -164,7 +166,7 @@ func (c *cli) start(stdin string, args ...string) *cliRun { cmd := newRootCommand() cmd.SetArgs(args) cmd.SetIn(strings.NewReader(stdin)) - cmd.SetOut(run.output) + cmd.SetOut(goio.MultiWriter(run.output, run.stdout)) cmd.SetErr(run.output) go func() { defer close(run.finished) From 4627ee64cbcfee2e7905f19bbccbee77ac4e6815 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 5 Oct 2026 10:02:54 +0200 Subject: [PATCH 2/3] docs: write meshstack login rather than meshstack auth login Both commands work, and the docs of the CLI, the Terraform provider and meshStack now name the shorter one throughout. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- client/workspace.go | 2 +- cmd/profile/profile.go | 2 +- internal/profile/name.go | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6eb167d..ea880ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ looks stale. ## Naming -- **`meshstack`** — the binary, so every invocation reads `meshstack auth login`. +- **`meshstack`** — the binary, so every invocation reads `meshstack login`. - **meshStack CLI** — the product name, used in prose and docs. - `github.com/meshcloud/meshstack-cli` — the repository and Go module. diff --git a/client/workspace.go b/client/workspace.go index dbbb507..8583042 100644 --- a/client/workspace.go +++ b/client/workspace.go @@ -38,7 +38,7 @@ type MeshWorkspaceListFilter struct { type MeshWorkspaceClient interface { // List returns every workspace the credential can see. An unscoped user token reaches this and - // almost nothing else, which is why `meshstack auth login` prompts for a workspace from it. + // almost nothing else, which is why `meshstack login` prompts for a workspace from it. List(ctx context.Context) ([]MeshWorkspace, error) Read(ctx context.Context, name string) (*MeshWorkspace, error) Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error) diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go index 0398968..674faea 100644 --- a/cmd/profile/profile.go +++ b/cmd/profile/profile.go @@ -14,7 +14,7 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "profile", Short: "Manage the stored profiles", - Long: `Manage the profiles that meshstack auth login stores, each an endpoint with its credential and + Long: `Manage the profiles that meshstack login stores, each an endpoint with its credential and default workspace. On a terminal, this lists the profiles to add, edit, delete, or make the current one. Elsewhere it diff --git a/internal/profile/name.go b/internal/profile/name.go index 5a4fecc..51edc5f 100644 --- a/internal/profile/name.go +++ b/internal/profile/name.go @@ -18,10 +18,10 @@ var NameSetting = setting.Setting[Name]{ Long: func(envKey string) string { return fmt.Sprintf("The profile whose credentials and defaults this run uses, also read from `%s`.\n\n"+ "A profile is a named bundle of endpoint and credential, written by "+ - "`meshstack auth login` into the meshStack CLI's configuration directory. It supplies each of those "+ + "`meshstack login` into the meshStack CLI's configuration directory. It supplies each of those "+ "only where nothing above it did, so it is never an override.\n\n"+ "With no name given, the profile is the one whose endpoint matches the endpoint in use, else the one "+ - "the last `meshstack auth login` selected, else `default`.", envKey) + "the last `meshstack login` selected, else `default`.", envKey) }, Default: setting.StaticDefault("default"), Parse: setting.ParseTextUnmarshaler[Name], From 64c94ed4af09341b501985fbbeb884c531bfb6ae Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 5 Oct 2026 12:26:42 +0200 Subject: [PATCH 3/3] refactor(client): show the authentication of the OpenAPI document as meshStack publishes it The CLI left out the security schemes, the security requirements, the "Authentication" paragraphs and /api/login, because those paragraphs were wrong. meshStack's API docs now describe authentication correctly, so the CLI shows them as published. Co-Authored-By: Claude Opus 5.5 --- client/openapi/authentication.go | 52 ------------------------------ client/openapi/object.go | 55 -------------------------------- client/openapi/spec.go | 7 +--- client/openapi/spec_test.go | 24 -------------- 4 files changed, 1 insertion(+), 137 deletions(-) delete mode 100644 client/openapi/authentication.go diff --git a/client/openapi/authentication.go b/client/openapi/authentication.go deleted file mode 100644 index 9e082a5..0000000 --- a/client/openapi/authentication.go +++ /dev/null @@ -1,52 +0,0 @@ -package openapi - -import ( - "encoding/json/jsontext" - "encoding/json/v2" - "slices" - "strings" -) - -// apiKeyLoginPath is the token exchange of an API key, which internal/auth/credential sends for -// every command that needs a token. -const apiKeyLoginPath = "/api/login" - -// withoutAuthentication leaves out what the document says about authentication, because the CLI -// authenticates every request itself, and much of it is outdated: the security requirements of the -// document and of each operation, the security schemes they name, the paragraphs of a description -// about them, and the operation of the API key login. -func withoutAuthentication(document jsontext.Value) (jsontext.Value, error) { - var members object - if err := json.Unmarshal(document, &members); err != nil { - return nil, err - } - members = members.without("security") - err := members.update("components", func(components object) (object, error) { - return components.without("securitySchemes"), nil - }) - if err == nil { - err = members.update("paths", func(paths object) (object, error) { - paths = paths.without(apiKeyLoginPath) - return paths, paths.updateAll(func(path object) (object, error) { - return path, path.updateAll(func(operation object) (object, error) { - operation = operation.without("security") - return operation, operation.updateString("description", withoutAuthenticationParagraphs) - }) - }) - }) - } - if err != nil { - return nil, err - } - return json.Marshal(members) -} - -// withoutAuthenticationParagraphs leaves out a paragraph such as "**Authentication:** This endpoint -// supports API Key authentication.", and one that asks for "Basic Authentication with an API User". -func withoutAuthenticationParagraphs(description string) string { - paragraphs := slices.DeleteFunc(strings.Split(description, "\n\n"), func(paragraph string) bool { - return strings.HasPrefix(strings.TrimSpace(paragraph), "**Authentication:**") || - strings.Contains(paragraph, "Basic Authentication") - }) - return strings.TrimSpace(strings.Join(paragraphs, "\n\n")) -} diff --git a/client/openapi/object.go b/client/openapi/object.go index 92f3dbb..0593d6b 100644 --- a/client/openapi/object.go +++ b/client/openapi/object.go @@ -2,9 +2,7 @@ package openapi import ( "encoding/json/jsontext" - "encoding/json/v2" "fmt" - "slices" ) // object keeps the order of its members, which a map would lose, so that a part of the document @@ -65,56 +63,3 @@ func (o object) get(name string) (jsontext.Value, bool) { } return nil, false } - -func (o object) without(name string) object { - return slices.DeleteFunc(o, func(m member) bool { return m.name == name }) -} - -// update replaces the object at name, where there is one. -func (o object) update(name string, f func(object) (object, error)) error { - for i, m := range o { - if m.name != name { - continue - } - var value object - if err := json.Unmarshal(m.value, &value); err != nil { - return fmt.Errorf("%s: %w", name, err) - } - updated, err := f(value) - if err != nil { - return fmt.Errorf("%s: %w", name, err) - } - if o[i].value, err = json.Marshal(updated); err != nil { - return err - } - } - return nil -} - -// updateAll replaces each member that is an object, and leaves any other value as it is. -func (o object) updateAll(f func(object) (object, error)) error { - for _, m := range o { - if m.value.Kind() != '{' { - continue - } - if err := o.update(m.name, f); err != nil { - return err - } - } - return nil -} - -// updateString replaces the string at name, where there is one. -func (o object) updateString(name string, f func(string) string) error { - for i, m := range o { - var value string - if m.name != name || json.Unmarshal(m.value, &value) != nil { - continue - } - var err error - if o[i].value, err = json.Marshal(f(value)); err != nil { - return err - } - } - return nil -} diff --git a/client/openapi/spec.go b/client/openapi/spec.go index 74fc74f..67d2fe6 100644 --- a/client/openapi/spec.go +++ b/client/openapi/spec.go @@ -47,16 +47,11 @@ func (c component) ref() string { return "#/components/" + c.kind + "/" + c.name } -// Parse leaves out what the document says about authentication, see withoutAuthentication. func Parse(r io.Reader) (Spec, error) { - read, err := io.ReadAll(r) + document, err := io.ReadAll(r) if err != nil { return Spec{}, err } - document, err := withoutAuthentication(read) - if err != nil { - return Spec{}, fmt.Errorf("cannot parse the OpenAPI document: %w", err) - } var parsed struct { Paths object `json:"paths"` Components map[string]object `json:"components"` diff --git a/client/openapi/spec_test.go b/client/openapi/spec_test.go index 214514a..9203a66 100644 --- a/client/openapi/spec_test.go +++ b/client/openapi/spec_test.go @@ -5,7 +5,6 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "os" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -190,29 +189,6 @@ func TestSpec(t *testing.T) { }) } -func TestParseLeavesOutAuthentication(t *testing.T) { - spec, err := openapi.Parse(strings.NewReader(`{ - "security": [{"oauth2": []}], - "paths": { - "/api/login": {"post": {"operationId": "apiKeyLoginResponse"}}, - "/api/meshobjects": {"put": { - "operationId": "importInJson", - "description": "Imports meshObjects.\n\nIt therefore requires\nBasic Authentication with an API User.\n\n**Authentication:** This endpoint supports API User authentication.", - "security": [{"basic": []}] - }} - }, - "components": {"schemas": {}, "securitySchemes": {"basic": {"type": "http", "scheme": "basic"}}} - }`)) - require.NoError(t, err) - - out, err := json.Marshal(spec) - require.NoError(t, err) - assert.JSONEq(t, `{ - "paths": {"/api/meshobjects": {"put": {"operationId": "importInJson", "description": "Imports meshObjects."}}}, - "components": {"schemas": {}} - }`, string(out)) -} - func TestApiVersion(t *testing.T) { versions := []string{"v1", "v2-preview", "v2", "v10"} for i := range len(versions) - 1 {