Add SSO password grant login flow
diff --git a/auth/login.go b/auth/login.go index 91267ea..26d59bb 100644 --- a/auth/login.go +++ b/auth/login.go
@@ -84,19 +84,25 @@ } const usage = `Usage: -ops -login <apihost> [<user>] +ops -login [options] <apihost> [<user>] Login to an OpenServerless instance. If no user is specified, the default user "nuvolaris" is used. You can set the environment variables OPS_APIHOST and OPS_USER to avoid specifying them on the command line. 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. Options: + --sso-flow FLOW SSO login flow: device or password. Default: device + --sso-username USER Identity-provider username for SSO password flow -h, --help Show usage` const whiskLoginPath = "/api/v1/web/whisk-system/nuv/login" const oidcLoginPath = "/system/api/v1/auth/oidc" const oidcDeviceStartPath = "/system/api/v1/auth/oidc/device/start" const oidcDevicePollPath = "/system/api/v1/auth/oidc/device/poll" +const oidcPasswordPath = "/system/api/v1/auth/oidc/password" const defaultUser = "nuvolaris" const opsSecretServiceName = "nuvolaris" @@ -113,8 +119,12 @@ } var helpFlag bool + var ssoFlowFlag string + var ssoUsernameFlag string flag.BoolVar(&helpFlag, "h", false, "Show usage") flag.BoolVar(&helpFlag, "help", false, "Show usage") + flag.StringVar(&ssoFlowFlag, "sso-flow", "", "SSO login flow: device or password") + flag.StringVar(&ssoUsernameFlag, "sso-username", "", "Identity-provider username for SSO password flow") err := flag.Parse(os.Args[1:]) if err != nil { return nil, err @@ -156,12 +166,22 @@ } } } + if user == "" { + user = os.Getenv("OPSDEV_USERNAME") + } var creds map[string]string ssoEnabled := isTruthy(os.Getenv("SSO_ENABLED")) var oidcToken string if ssoEnabled { - if useBackendManagedOIDCDeviceFlow() { + if useBackendManagedOIDCPasswordFlow(ssoFlowFlag) { + fmt.Println("Logging in", apihost, "with backend-managed OIDC password grant") + creds, err = backendManagedOIDCPasswordLogin(apihost, user, firstNonEmpty(ssoUsernameFlag, os.Getenv("OPS_SSO_USERNAME"))) + if err != nil { + return nil, err + } + user = loginFromCredentials(creds, user) + } else if useBackendManagedOIDCDeviceFlow() { fmt.Println("Logging in", apihost, "with backend-managed OIDC") creds, err = backendManagedOIDCDeviceLogin(apihost, user) if err != nil { @@ -315,6 +335,10 @@ isTruthy(firstNonEmpty(os.Getenv("SSO_OIDC_CLIENT_SECRET_CONFIGURED"), os.Getenv("OIDC_CLIENT_SECRET_CONFIGURED"))) } +func useBackendManagedOIDCPasswordFlow(flagValue string) bool { + return strings.EqualFold(firstNonEmpty(flagValue, os.Getenv("OPS_SSO_LOGIN_FLOW")), "password") +} + func backendManagedOIDCDeviceLogin(apihost, requestedNamespace string) (map[string]string, error) { startURL := strings.TrimRight(apihost, "/") + oidcDeviceStartPath pollURL := strings.TrimRight(apihost, "/") + oidcDevicePollPath @@ -353,6 +377,65 @@ return pollBackendManagedOIDCDeviceFlow(pollURL, start, requestedNamespace) } +func backendManagedOIDCPasswordLogin(apihost, requestedNamespace, idpUsername string) (map[string]string, error) { + username := firstNonEmpty(idpUsername, requestedNamespace) + if username == "" { + return nil, errors.New("SSO password login requires a username") + } + + password := os.Getenv("OPS_PASSWORD") + if password == "" { + fmt.Print("Enter SSO Password: ") + pwd, err := AskPassword() + if err != nil { + return nil, err + } + password = pwd + fmt.Println() + } + + return doOIDCPasswordLogin( + strings.TrimRight(apihost, "/")+oidcPasswordPath, + username, + password, + requestedNamespace, + ) +} + +func doOIDCPasswordLogin(url, username, password, requestedNamespace string) (map[string]string, error) { + payload := map[string]string{ + "username": username, + "password": password, + } + if strings.TrimSpace(requestedNamespace) != "" { + payload["namespace"] = strings.TrimSpace(requestedNamespace) + } + loginJSON, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + resp, err := http.Post(url, "application/json", bytes.NewBuffer(loginJSON)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("OIDC password login failed with status code %d", resp.StatusCode) + } + return nil, fmt.Errorf("OIDC password login failed (%d): %s", resp.StatusCode, string(body)) + } + + var creds map[string]string + if err := json.NewDecoder(resp.Body).Decode(&creds); err != nil { + return nil, errors.New("failed to decode response from OIDC password login request") + } + return creds, nil +} + func startBackendManagedOIDCDeviceFlow(url, requestedNamespace string) (*backendDeviceStartResponse, error) { payload := map[string]string{} if strings.TrimSpace(requestedNamespace) != "" {
diff --git a/auth/login_test.go b/auth/login_test.go index d53d4cc..acebdcc 100644 --- a/auth/login_test.go +++ b/auth/login_test.go
@@ -289,6 +289,102 @@ require.Equal(t, 1, pollCount) }) + t.Run("SSO password flow uses backend managed password grant", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("SSO_ENABLED", "true") + t.Setenv("OPS_PASSWORD", "sso-password") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/system/api/v1/auth/oidc/password": + 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["username"]) + require.Equal(t, "sso-password", payload["password"]) + require.Equal(t, "michelem", payload["namespace"]) + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) + default: + http.NotFound(w, r) + } + })) + defer mockServer.Close() + + os.Args = []string{"login", "--sso-flow", "password", mockServer.URL, "michelem"} + 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 supports IdP username override", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("SSO_ENABLED", "true") + t.Setenv("OPS_SSO_LOGIN_FLOW", "password") + t.Setenv("OPS_SSO_USERNAME", "michele@example.test") + t.Setenv("OPS_PASSWORD", "sso-password") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/system/api/v1/auth/oidc/password": + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Equal(t, "michele@example.test", payload["username"]) + require.Equal(t, "michelem", payload["namespace"]) + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) + default: + http.NotFound(w, r) + } + })) + defer mockServer.Close() + + os.Args = []string{"login", mockServer.URL, "michelem"} + 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 OPSDEV username from ops ide login", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("SSO_ENABLED", "true") + t.Setenv("OPS_SSO_LOGIN_FLOW", "password") + t.Setenv("OPS_PASSWORD", "sso-password") + t.Setenv("OPSDEV_USERNAME", "michelem") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/system/api/v1/auth/oidc/password": + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Equal(t, "michelem", payload["username"]) + require.Equal(t, "michelem", 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 enabled fails without issuer", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("OPS_HOME", tmpDir)