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/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 { 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/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) 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],