From 33318d2fe43f25a51b69552a258540275558aea0 Mon Sep 17 00:00:00 2001 From: George Tsiolis <120486+gtsiolis@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:48:06 +0000 Subject: [PATCH] Make LOCALSTACK_AUTH_TOKEN take precedence over stored credentials Co-Authored-By: Claude --- CLAUDE.md | 2 +- README.md | 2 +- cmd/root.go | 9 +- internal/auth/auth.go | 23 +++- internal/auth/auth_test.go | 38 ++++++ .../integration/auth_token_precedence_test.go | 113 ++++++++++++++++++ 6 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 test/integration/auth_token_precedence_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dba1270a..648eb164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,7 @@ Use `lstk setup ` to set up CLI integration for an emulator type: This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations. Environment variables: -- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set) +- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set). It takes precedence over credentials stored in the keyring, so a per-invocation token overrides a previous `lstk login` without a `lstk logout` first; resolution order is env var → keyring → browser login (`auth.GetToken`, mirrored in `cmd/root.go`'s telemetry token resolution). - `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start. `lstk start --timeout ` (also on the bare root) overrides this for a single run; the flag wins over the env var when explicitly set, and `--timeout 0` falls back to the per-mode default (`addTimeoutFlag`/`applyTimeoutFlag` in `cmd/root.go`). `restart` and the snapshot auto-start path do not expose the flag. - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). - `LSTK_MERGE_STRATEGY` - Default merge strategy for `snapshot load` / `load` (`account-region-merge`, `overwrite`, or `service-merge`) when `--merge` is not passed; an explicit `--merge` always wins. Resolved in `resolveMergeStrategy` (`cmd/snapshot.go`). diff --git a/README.md b/README.md index 4352149e..3f762ff0 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Running `lstk` will automatically handle authentication, configuration, and cont - **Start / stop / status / logs** — manage the full LocalStack emulator lifecycle with a single command - **Interactive TUI** — a Bubble Tea-powered terminal UI in interactive terminals, plain output for CI/CD and scripting -- **Browser-based login** — authenticate via browser and store credentials securely in the system keyring, or use `LOCALSTACK_AUTH_TOKEN` for CI +- **Browser-based login** — authenticate via browser and store credentials securely in the system keyring, or use `LOCALSTACK_AUTH_TOKEN` for CI (it takes precedence over stored credentials) - **Snapshots** — save, load, and manage emulator state as local files, cloud snapshots, or in your own S3 bucket - **Cloud CLI proxies** — run `aws`, `az`, `terraform`, `cdk`, and `sam` commands against LocalStack with the endpoint, credentials, and region pre-configured - **Target an external emulator** — pass `--endpoint-url ` (or set `LSTK_ENDPOINT_URL`) to point most commands at an already-running LocalStack instance — docker compose, host-network mode, CI, a different machine, or a cloud-hosted ephemeral instance (`https://` is supported) — instead of one lstk manages locally diff --git a/cmd/root.go b/cmd/root.go index 54430688..ae475e40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -265,12 +265,11 @@ func Execute(ctx context.Context) error { logger.Info("lstk %s starting", version.Version()) - // Resolve auth token for telemetry: keyring first, then env var. - resolvedToken := cfg.AuthToken + // Resolve the auth token: LOCALSTACK_AUTH_TOKEN first, then the keyring, so an + // explicitly provided token overrides stored credentials (see auth.GetToken). + resolvedToken := strings.TrimSpace(cfg.AuthToken) if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil { - if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" { - resolvedToken = token - } + resolvedToken = auth.ResolveToken(resolvedToken, tokenStorage) } // Trim surrounding whitespace: env-injected tokens (e.g. CI secrets) commonly // carry a trailing newline. Then reject clearly malformed tokens before they diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a5081d39..dcc50104 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -47,13 +47,26 @@ func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, a } } -// GetToken tries in order: 1) keyring 2) LOCALSTACK_AUTH_TOKEN env var 3) device flow login -func (a *Auth) GetToken(ctx context.Context) (string, error) { - if token, err := a.tokenStorage.GetAuthToken(); err == nil && token != "" { - return token, nil +// ResolveToken returns a caller-provided token before consulting stored +// credentials. Storage errors are treated like a missing stored token. +func ResolveToken(authToken string, storage AuthTokenStorage) string { + if authToken != "" { + return authToken } - if token := a.authToken; token != "" { + token, err := storage.GetAuthToken() + if err != nil { + return "" + } + return token +} + +// GetToken tries in order: 1) LOCALSTACK_AUTH_TOKEN env var 2) keyring 3) device flow login. +// The environment variable wins over the stored token so a per-invocation +// override (CI secret, a second account, `LOCALSTACK_AUTH_TOKEN=... lstk start`) +// takes effect without logging out first — matching how other CLIs behave. +func (a *Auth) GetToken(ctx context.Context) (string, error) { + if token := ResolveToken(a.authToken, a.tokenStorage); token != "" { return token, nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 7489c5cf..03c279c9 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -59,6 +59,44 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) { }) } +// The environment token must win over stored credentials (DEVX-1023), so a +// per-invocation override takes effect without a `lstk logout` first. +func TestGetToken_EnvTokenOverridesStoredToken(t *testing.T) { + ctrl := gomock.NewController(t) + mockStorage := NewMockAuthTokenStorage(ctrl) + + auth := &Auth{ + tokenStorage: mockStorage, + login: NewMockLoginProvider(ctrl), + sink: output.SinkFunc(func(output.Event) {}), + authToken: "env-token", + allowLogin: true, + } + + token, err := auth.GetToken(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "env-token", token) +} + +func TestGetToken_FallsBackToStoredTokenWithoutEnvToken(t *testing.T) { + ctrl := gomock.NewController(t) + mockStorage := NewMockAuthTokenStorage(ctrl) + mockStorage.EXPECT().GetAuthToken().Return("stored-token", nil) + + auth := &Auth{ + tokenStorage: mockStorage, + login: NewMockLoginProvider(ctrl), + sink: output.SinkFunc(func(output.Event) {}), + allowLogin: true, + } + + token, err := auth.GetToken(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "stored-token", token) +} + func TestRelogin_DiscardsTokenAndLicenseThenLogsIn(t *testing.T) { ctrl := gomock.NewController(t) mockStorage := NewMockAuthTokenStorage(ctrl) diff --git a/test/integration/auth_token_precedence_test.go b/test/integration/auth_token_precedence_test.go new file mode 100644 index 00000000..b56f9a3c --- /dev/null +++ b/test/integration/auth_token_precedence_test.go @@ -0,0 +1,113 @@ +package integration_test + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedStoredAuthToken writes a stored credential into the file-based keyring of +// an isolated HOME (testEnvWithHome forces LSTK_KEYRING=file). +func seedStoredAuthToken(t *testing.T, home, token string) { + t.Helper() + configDir := expectedOSConfigDir(home, "") + require.NoError(t, os.MkdirAll(configDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "auth-token"), []byte(token), 0600)) +} + +func basicAuthHeader(token string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+token)) +} + +// LOCALSTACK_AUTH_TOKEN must win over stored credentials (DEVX-1023) so a +// per-invocation override takes effect without a `lstk logout` first. +func TestEnvAuthTokenOverridesStoredToken(t *testing.T) { + t.Parallel() + + home := t.TempDir() + seedStoredAuthToken(t, home, "stored-token") + + var cap listCapture + srv := mockCloudPodsServer(t, []map[string]any{}, &cap) + + environ := env.Environ(testEnvWithHome(home, "")). + With(env.APIEndpoint, srv.URL). + With(env.AuthToken, "env-token") + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), environ, + "--non-interactive", "snapshot", "list", + ) + require.NoError(t, err, "lstk snapshot list failed: %s", stderr) + + called, _, auth := cap.get() + require.True(t, called, "the platform list endpoint should have been called") + assert.Equal(t, basicAuthHeader("env-token"), auth, "LOCALSTACK_AUTH_TOKEN should override the stored token") +} + +func TestStoredTokenUsedWithoutEnvAuthToken(t *testing.T) { + t.Parallel() + + home := t.TempDir() + seedStoredAuthToken(t, home, "stored-token") + + var cap listCapture + srv := mockCloudPodsServer(t, []map[string]any{}, &cap) + + environ := env.Environ(testEnvWithHome(home, "")). + With(env.APIEndpoint, srv.URL). + Without(env.AuthToken) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), environ, + "--non-interactive", "snapshot", "list", + ) + require.NoError(t, err, "lstk snapshot list failed: %s", stderr) + + called, _, auth := cap.get() + require.True(t, called, "the platform list endpoint should have been called") + assert.Equal(t, basicAuthHeader("stored-token"), auth, "the stored token should be used when no env token is set") +} + +func TestEnvAuthTokenOverridesStoredTokenForExternalEmulator(t *testing.T) { + t.Parallel() + + home := t.TempDir() + seedStoredAuthToken(t, home, "stored-token") + + authHeader := make(chan string, 1) + health := awsHealthHandler() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && r.URL.Path == "/_localstack/pods/my-baseline" { + authHeader <- r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"event":"completion","status":"ok"}` + "\n")) + return + } + health.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + + environ := env.Environ(testEnvWithHome(home, "")). + With(env.AuthToken, "env-token"). + With(env.DisableEvents, "1") + environ = append(environ, unreachableDockerHost) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), environ, + "--non-interactive", "--endpoint-url", srv.URL, "snapshot", "load", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot load failed: %s", stderr) + + select { + case auth := <-authHeader: + assert.Equal(t, basicAuthHeader("env-token"), auth, "LOCALSTACK_AUTH_TOKEN should override the stored token") + default: + t.Fatal("the external emulator pod endpoint should have been called") + } +}