Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Use `lstk setup <emulator>` 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 <duration>` (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`).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down
9 changes: 4 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
38 changes: 38 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
113 changes: 113 additions & 0 deletions test/integration/auth_token_precedence_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading