From fc5d5b0dbe5668e2ce68a5e19042c59bff197213 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 11:06:47 +0200 Subject: [PATCH 1/3] Fix SSO device workspace selection --- auth/login.go | 37 ++++++++++++++++++------------ auth/login_test.go | 47 +++++++++++++++++++++++++++++++++++++- config/config_tool.go | 2 +- config/config_tool_test.go | 8 +++---- tests/config.bats | 15 +++++++++++- 5 files changed, 87 insertions(+), 22 deletions(-) diff --git a/auth/login.go b/auth/login.go index 26d59bb..3c61a09 100644 --- a/auth/login.go +++ b/auth/login.go @@ -92,6 +92,8 @@ You can set OPS_PASSWORD to avoid entering the password interactively. When SSO is enabled, set OPS_SSO_LOGIN_FLOW=password or pass --sso-flow password to use OIDC password grant instead of the browser/device flow. OPS_SSO_USERNAME can override the identity-provider username when it differs from the OpenServerless namespace. +For backend-managed device flow, [] explicitly requests workspace binding; omit it +to use the namespace resolved from the authenticated identity. Options: --sso-flow FLOW SSO login flow: device or password. Default: device @@ -150,21 +152,26 @@ func LoginCmd() (*LoginResult, error) { passwordLoginURL := apihost + whiskLoginPath oidcLoginURL := apihost + oidcLoginPath - // try to get the user from the environment - user := os.Getenv("OPS_USER") - if user == "" { - // if env var not set, try to get it from the command line - if os.Getenv("OPS_APIHOST") != "" { - // if apihost env var was set, treat the first arg as the user - if len(args) > 0 { - user = args[0] - } - } else { - // if apihost env var was not set, treat the second arg as the user - if len(args) > 1 { - user = args[1] - } + // Command-line input is explicit and must take precedence over values that + // may have been reloaded into the environment from config.json. + user := "" + if os.Getenv("OPS_APIHOST") != "" { + // if apihost env var was set, treat the first arg as the user + if len(args) > 0 { + user = args[0] } + } else { + // if apihost env var was not set, treat the second arg as the user + if len(args) > 1 { + user = args[1] + } + } + // Only a positional user is an explicit workspace binding for the device + // flow. Environment values may have been reloaded from config.json by the + // parent CLI process and therefore cannot prove user intent for this login. + requestedNamespace := user + if user == "" { + user = os.Getenv("OPS_USER") } if user == "" { user = os.Getenv("OPSDEV_USERNAME") @@ -183,7 +190,7 @@ func LoginCmd() (*LoginResult, error) { user = loginFromCredentials(creds, user) } else if useBackendManagedOIDCDeviceFlow() { fmt.Println("Logging in", apihost, "with backend-managed OIDC") - creds, err = backendManagedOIDCDeviceLogin(apihost, user) + creds, err = backendManagedOIDCDeviceLogin(apihost, requestedNamespace) if err != nil { return nil, err } diff --git a/auth/login_test.go b/auth/login_test.go index acebdcc..76d5db5 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -249,8 +249,9 @@ func TestLoginCmd(t *testing.T) { t.Setenv("SSO_CLIENT_MODE", "confidential") t.Setenv("OPS_SSO_DISABLE_BROWSER", "true") t.Setenv("OPS_PASSWORD", "") - t.Setenv("OPS_USER", "") + t.Setenv("OPS_USER", "stale-workspace") t.Setenv("OPS_APIHOST", "") + t.Setenv("OPSDEV_USERNAME", "nuvolaris") pollCount := 0 mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -272,6 +273,7 @@ func TestLoginCmd(t *testing.T) { var payload map[string]string require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) require.Equal(t, "flow-1", payload["flow_id"]) + require.Equal(t, "michelem", payload["namespace"]) pollCount++ _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) default: @@ -289,6 +291,49 @@ func TestLoginCmd(t *testing.T) { require.Equal(t, 1, pollCount) }) + t.Run("SSO confidential client ignores persisted OPSDEV workspace", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("SSO_ENABLED", "true") + t.Setenv("SSO_CLIENT_MODE", "confidential") + t.Setenv("OPS_SSO_DISABLE_BROWSER", "true") + t.Setenv("OPS_PASSWORD", "") + t.Setenv("OPS_USER", "stale-ops-user") + t.Setenv("OPS_APIHOST", "") + t.Setenv("OPSDEV_USERNAME", "nuvolaris") + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/system/api/v1/auth/oidc/device/start": + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.NotContains(t, payload, "namespace") + _, _ = w.Write([]byte(`{ + "flow_id": "flow-1", + "verification_uri_complete": "http://localhost/device", + "expires_in": 10, + "interval": 1 + }`)) + case "/system/api/v1/auth/oidc/device/poll": + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Equal(t, "flow-1", payload["flow_id"]) + require.NotContains(t, payload, "namespace") + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) + default: + http.NotFound(w, r) + } + })) + defer mockServer.Close() + + os.Args = []string{"login", mockServer.URL} + loginResult, err := LoginCmd() + require.NoError(t, err) + require.NotNil(t, loginResult) + require.Equal(t, "michelem", loginResult.Login) + require.Equal(t, "oidc-auth", loginResult.Auth) + }) + t.Run("SSO password flow uses backend managed password grant", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("OPS_HOME", tmpDir) diff --git a/config/config_tool.go b/config/config_tool.go index 1b4758a..36a3eeb 100644 --- a/config/config_tool.go +++ b/config/config_tool.go @@ -162,7 +162,7 @@ func (kv *keyValues) Set(value string) error { key := parts[0] val := parts[1] - if key == "" || val == "" { + if key == "" { return fmt.Errorf("invalid key-value pair: %q", value) } diff --git a/config/config_tool_test.go b/config/config_tool_test.go index 56af022..1ff1dc1 100644 --- a/config/config_tool_test.go +++ b/config/config_tool_test.go @@ -179,7 +179,7 @@ func TestConfigTool(t *testing.T) { err := ConfigTool(cm) require.NoError(t, err) - os.Args = []string{"config", "FOO_BAR=\"\""} + os.Args = []string{"config", "FOO_BAR="} err = ConfigTool(cm) require.NoError(t, err) @@ -304,10 +304,10 @@ func Test_buildKeyValueMap(t *testing.T) { err: fmt.Errorf("invalid key-value pair: %q", "foo"), }, { - name: "Invalid key-value pair", + name: "Empty value", input: []string{"foo="}, - want: nil, - err: fmt.Errorf("invalid key-value pair: %q", "foo="), + want: keyValues{"foo": ""}, + err: nil, }, } diff --git a/tests/config.bats b/tests/config.bats index 5d0556d..bd735a1 100644 --- a/tests/config.bats +++ b/tests/config.bats @@ -136,6 +136,19 @@ setup() { assert_line 'ANOTHER=123' } +@test "set an empty config value" { + run rm -f ~/.ops/config.json + run ops -config OPSDEV_USERNAME=nuvolaris + assert_success + + run ops -config OPSDEV_USERNAME= + assert_success + + run ops -config OPSDEV_USERNAME + assert_success + assert_output "" +} + @test "remove config values" { run rm -f ~/.ops/config.json run ops -config KEY=VALUE ANOTHER=123 @@ -192,4 +205,4 @@ setup() { assert_success assert_line 'VALUE' assert_line 'new_value' -} \ No newline at end of file +} From c6a6c6c1821df35f529a9c2a3d0a32e568a0c395 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 11:09:44 +0200 Subject: [PATCH 2/3] Prepare 0.9.1-2607121109.dev --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index acf11a2..3ec7f02 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.9.1-2607111538.dev +0.9.1-2607121109.dev From c386a3ca4ddfdbf66f4646c550ffe3a84c9bcd57 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 11:16:18 +0200 Subject: [PATCH 3/3] Use neutral SSO test identities --- auth/login_test.go | 22 +++++++++++----------- tests/config.bats | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/auth/login_test.go b/auth/login_test.go index 76d5db5..11bb2cd 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -249,9 +249,9 @@ func TestLoginCmd(t *testing.T) { t.Setenv("SSO_CLIENT_MODE", "confidential") t.Setenv("OPS_SSO_DISABLE_BROWSER", "true") t.Setenv("OPS_PASSWORD", "") - t.Setenv("OPS_USER", "stale-workspace") + t.Setenv("OPS_USER", "previous-ops-workspace") t.Setenv("OPS_APIHOST", "") - t.Setenv("OPSDEV_USERNAME", "nuvolaris") + t.Setenv("OPSDEV_USERNAME", "previous-ide-workspace") pollCount := 0 mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -260,7 +260,7 @@ func TestLoginCmd(t *testing.T) { require.Equal(t, http.MethodPost, r.Method) var payload map[string]string require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) - require.Equal(t, "michelem", payload["namespace"]) + require.Equal(t, "requested-workspace", payload["namespace"]) _, _ = w.Write([]byte(`{ "flow_id": "flow-1", "user_code": "ABCD-EFGH", @@ -273,20 +273,20 @@ func TestLoginCmd(t *testing.T) { var payload map[string]string require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) require.Equal(t, "flow-1", payload["flow_id"]) - require.Equal(t, "michelem", payload["namespace"]) + require.Equal(t, "requested-workspace", payload["namespace"]) pollCount++ - _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"requested-workspace"}`)) default: http.NotFound(w, r) } })) defer mockServer.Close() - os.Args = []string{"login", mockServer.URL, "michelem"} + os.Args = []string{"login", mockServer.URL, "requested-workspace"} loginResult, err := LoginCmd() require.NoError(t, err) require.NotNil(t, loginResult) - require.Equal(t, "michelem", loginResult.Login) + require.Equal(t, "requested-workspace", loginResult.Login) require.Equal(t, "oidc-auth", loginResult.Auth) require.Equal(t, 1, pollCount) }) @@ -298,9 +298,9 @@ func TestLoginCmd(t *testing.T) { t.Setenv("SSO_CLIENT_MODE", "confidential") t.Setenv("OPS_SSO_DISABLE_BROWSER", "true") t.Setenv("OPS_PASSWORD", "") - t.Setenv("OPS_USER", "stale-ops-user") + t.Setenv("OPS_USER", "previous-ops-workspace") t.Setenv("OPS_APIHOST", "") - t.Setenv("OPSDEV_USERNAME", "nuvolaris") + t.Setenv("OPSDEV_USERNAME", "previous-ide-workspace") mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -319,7 +319,7 @@ func TestLoginCmd(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) require.Equal(t, "flow-1", payload["flow_id"]) require.NotContains(t, payload, "namespace") - _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"authenticated-workspace"}`)) default: http.NotFound(w, r) } @@ -330,7 +330,7 @@ func TestLoginCmd(t *testing.T) { loginResult, err := LoginCmd() require.NoError(t, err) require.NotNil(t, loginResult) - require.Equal(t, "michelem", loginResult.Login) + require.Equal(t, "authenticated-workspace", loginResult.Login) require.Equal(t, "oidc-auth", loginResult.Auth) }) diff --git a/tests/config.bats b/tests/config.bats index bd735a1..400b305 100644 --- a/tests/config.bats +++ b/tests/config.bats @@ -138,7 +138,7 @@ setup() { @test "set an empty config value" { run rm -f ~/.ops/config.json - run ops -config OPSDEV_USERNAME=nuvolaris + run ops -config OPSDEV_USERNAME=previous-workspace assert_success run ops -config OPSDEV_USERNAME=