Skip to content
Merged
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
37 changes: 22 additions & 15 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, [<user>] 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
Expand Down Expand Up @@ -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")
Expand All @@ -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
}
Expand Down
55 changes: 50 additions & 5 deletions auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "previous-ops-workspace")
t.Setenv("OPS_APIHOST", "")
t.Setenv("OPSDEV_USERNAME", "previous-ide-workspace")

pollCount := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -259,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",
Expand All @@ -272,23 +273,67 @@ 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, "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)
})

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", "previous-ops-workspace")
t.Setenv("OPS_APIHOST", "")
t.Setenv("OPSDEV_USERNAME", "previous-ide-workspace")

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":"authenticated-workspace"}`))
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, "authenticated-workspace", 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)
Expand Down
2 changes: 1 addition & 1 deletion config/config_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
8 changes: 4 additions & 4 deletions config/config_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
},
}

Expand Down
15 changes: 14 additions & 1 deletion tests/config.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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=previous-workspace
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
Expand Down Expand Up @@ -192,4 +205,4 @@ setup() {
assert_success
assert_line 'VALUE'
assert_line 'new_value'
}
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.1-2607111538.dev
0.9.1-2607121109.dev
Loading