diff --git a/auth/login.go b/auth/login.go index 484d30c..26d59bb 100644 --- a/auth/login.go +++ b/auth/login.go @@ -68,18 +68,41 @@ type oidcTokenResponse struct { ErrorDescription string `json:"error_description"` } +type backendDeviceStartResponse struct { + FlowID string `json:"flow_id"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type backendDevicePollResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Interval int `json:"interval"` +} + const usage = `Usage: -ops -login [] +ops -login [options] [] 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" @@ -96,8 +119,12 @@ func LoginCmd() (*LoginResult, error) { } 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 @@ -139,24 +166,43 @@ func LoginCmd() (*LoginResult, error) { } } } + if user == "" { + user = os.Getenv("OPSDEV_USERNAME") + } var creds map[string]string ssoEnabled := isTruthy(os.Getenv("SSO_ENABLED")) var oidcToken string if ssoEnabled { - oidcToken, err = oidcDeviceAccessToken() - if err != nil { - return nil, err + 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 { + return nil, err + } + user = loginFromCredentials(creds, user) + } else { + oidcToken, err = oidcDeviceAccessToken() + if err != nil { + return nil, err + } } } - if oidcToken != "" { + if creds == nil && oidcToken != "" { fmt.Println("Logging in", apihost, "with OIDC") creds, err = doOIDCLogin(oidcLoginURL, oidcToken) if err != nil { return nil, err } user = loginFromCredentials(creds, user) - } else { + } else if creds == nil { if ssoEnabled { return nil, errors.New("SSO is enabled but OIDC login did not return an access token") } @@ -284,6 +330,192 @@ func oidcDeviceAccessToken() (string, error) { return pollOIDCDeviceToken(discovery.TokenEndpoint, clientID, device, codeVerifier) } +func useBackendManagedOIDCDeviceFlow() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("SSO_CLIENT_MODE")), "confidential") || + 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 + + start, err := startBackendManagedOIDCDeviceFlow(startURL, requestedNamespace) + if err != nil { + return nil, err + } + if start.FlowID == "" { + return nil, errors.New("SSO device login response missing flow_id") + } + if start.Interval <= 0 { + start.Interval = 5 + } + if start.ExpiresIn <= 0 { + start.ExpiresIn = 600 + } + + verificationURL := start.VerificationURIComplete + if verificationURL == "" { + verificationURL = start.VerificationURI + } + fmt.Println() + fmt.Println("SSO is enabled for this cluster.") + fmt.Println("Open this URL in your browser to login:") + fmt.Println(verificationURL) + if start.UserCode != "" { + fmt.Println("Code:", start.UserCode) + } + fmt.Println("Waiting for authentication...") + + if verificationURL != "" && !isTruthy(os.Getenv("OPS_SSO_DISABLE_BROWSER")) { + _ = browser.OpenURL(verificationURL) + } + + 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) != "" { + payload["namespace"] = strings.TrimSpace(requestedNamespace) + } + startJSON, err := json.Marshal(payload) + if err != nil { + return nil, err + } + resp, err := http.Post(url, "application/json", bytes.NewBuffer(startJSON)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC device authorization failed (%d): %s", resp.StatusCode, string(body)) + } + + var start backendDeviceStartResponse + if err := json.Unmarshal(body, &start); err != nil { + return nil, errors.New("failed to decode OIDC device authorization response") + } + return &start, nil +} + +func pollBackendManagedOIDCDeviceFlow(url string, start *backendDeviceStartResponse, requestedNamespace string) (map[string]string, error) { + deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) + interval := time.Duration(start.Interval) * time.Second + + for { + if time.Now().After(deadline) { + return nil, errors.New("OIDC device login expired") + } + time.Sleep(interval) + + payload := map[string]string{"flow_id": start.FlowID} + 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 + } + + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, readErr + } + + if resp.StatusCode == http.StatusAccepted { + var pending backendDevicePollResponse + if err := json.Unmarshal(body, &pending); err == nil && pending.Interval > 0 { + interval = time.Duration(pending.Interval) * time.Second + } + continue + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC token polling failed (%d): %s", resp.StatusCode, string(body)) + } + + var creds map[string]string + if err := json.Unmarshal(body, &creds); err != nil { + return nil, errors.New("failed to decode response from OIDC device login request") + } + return creds, nil + } +} + func fetchOIDCDiscovery(issuer string) (*oidcDiscovery, error) { resp, err := http.Get(issuer + "/.well-known/openid-configuration") if err != nil { diff --git a/auth/login_test.go b/auth/login_test.go index 2da23ec..acebdcc 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -242,6 +242,149 @@ func TestLoginCmd(t *testing.T) { require.Equal(t, "oidc-auth", loginResult.Auth) }) + t.Run("SSO confidential client uses backend managed device flow", 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", "") + t.Setenv("OPS_APIHOST", "") + + pollCount := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/system/api/v1/auth/oidc/device/start": + 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"]) + _, _ = w.Write([]byte(`{ + "flow_id": "flow-1", + "user_code": "ABCD-EFGH", + "verification_uri_complete": "http://localhost/device?user_code=ABCD-EFGH", + "expires_in": 10, + "interval": 1 + }`)) + case "/system/api/v1/auth/oidc/device/poll": + require.Equal(t, http.MethodPost, r.Method) + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Equal(t, "flow-1", payload["flow_id"]) + pollCount++ + _, _ = 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) + 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) diff --git a/config/sso_tool.go b/config/sso_tool.go index 3a60324..573b038 100644 --- a/config/sso_tool.go +++ b/config/sso_tool.go @@ -31,6 +31,7 @@ import ( const ( defaultSSONamespace = "nuvolaris" defaultSSOConfigMap = "openserverless-sso-config" + defaultSSOSecret = "openserverless-sso-secret" defaultSSOWorkload = "nuvolaris-system-api" defaultSSOContainer = "nuvolaris-system-api" ) @@ -43,6 +44,8 @@ type ssoOptions struct { IssuerURL string JWKSURL string Audience string + ClientID string + ClientSecret string RequiredGroup string UsernameClaim string GroupsClaim string @@ -55,6 +58,7 @@ type ssoOptions struct { NamespaceMaxLength string Namespace string ConfigMapName string + SecretName string WorkloadName string ContainerName string NoRollout bool @@ -62,12 +66,12 @@ type ssoOptions struct { func printSSOUsage() { fmt.Print(`Usage: -ops config sso keycloak --enable --issuer-url URL --jwks-url URL --audience AUDIENCE --required-group GROUP [options] +ops config sso keycloak --enable --issuer-url URL --jwks-url URL (--audience AUDIENCE|--client-id CLIENT_ID) --required-group GROUP [options] ops config sso show ops config sso disable [options] Legacy embedded form: -ops -config sso keycloak --enable --issuer-url URL --jwks-url URL --audience AUDIENCE --required-group GROUP [options] +ops -config sso keycloak --enable --issuer-url URL --jwks-url URL (--audience AUDIENCE|--client-id CLIENT_ID) --required-group GROUP [options] ops -config sso show ops -config sso disable [options] @@ -76,8 +80,11 @@ Configure OpenServerless SSO/OIDC integration for admin-api. Options: --username-claim CLAIM OIDC username claim. Default: preferred_username --groups-claim CLAIM OIDC groups claim. Default: groups + --client-id CLIENT_ID OIDC client id. Defaults to --audience when omitted + --client-secret SECRET OIDC confidential client secret stored only in Kubernetes Secret --namespace NS Kubernetes namespace. Default: nuvolaris --configmap NAME Kubernetes ConfigMap name. Default: openserverless-sso-config + --secret NAME Kubernetes Secret name. Default: openserverless-sso-secret --statefulset NAME admin-api StatefulSet name. Default: nuvolaris-system-api --container NAME admin-api container name. Default: nuvolaris-system-api --no-rollout Do not restart or wait for admin-api rollout @@ -120,6 +127,12 @@ func configureKeycloakSSO(configMap ConfigMap, args []string) error { return err } + if opts.ClientSecret != "" { + if err := applySSOSecret(opts); err != nil { + return err + } + } + if err := patchSSOEnvFrom(opts); err != nil { return err } @@ -132,6 +145,9 @@ func configureKeycloakSSO(configMap ConfigMap, args []string) error { fmt.Println("SSO configuration applied to admin-api.") fmt.Printf("ConfigMap: %s/%s\n", opts.Namespace, opts.ConfigMapName) + if opts.ClientSecret != "" { + fmt.Printf("Secret: %s/%s\n", opts.Namespace, opts.SecretName) + } return nil } @@ -148,6 +164,7 @@ func parseKeycloakSSOArgs(args []string) (ssoOptions, error) { NamespaceMaxLength: "61", Namespace: defaultSSONamespace, ConfigMapName: defaultSSOConfigMap, + SecretName: defaultSSOSecret, WorkloadName: defaultSSOWorkload, ContainerName: defaultSSOContainer, } @@ -158,11 +175,14 @@ func parseKeycloakSSOArgs(args []string) (ssoOptions, error) { flags.StringVar(&opts.IssuerURL, "issuer-url", "", "OIDC issuer URL") flags.StringVar(&opts.JWKSURL, "jwks-url", "", "OIDC JWKS URL") flags.StringVar(&opts.Audience, "audience", "", "OIDC audience") + flags.StringVar(&opts.ClientID, "client-id", "", "OIDC client id") + flags.StringVar(&opts.ClientSecret, "client-secret", "", "OIDC confidential client secret") flags.StringVar(&opts.RequiredGroup, "required-group", "", "required OIDC group") flags.StringVar(&opts.UsernameClaim, "username-claim", opts.UsernameClaim, "OIDC username claim") flags.StringVar(&opts.GroupsClaim, "groups-claim", opts.GroupsClaim, "OIDC groups claim") flags.StringVar(&opts.Namespace, "namespace", opts.Namespace, "Kubernetes namespace") flags.StringVar(&opts.ConfigMapName, "configmap", opts.ConfigMapName, "Kubernetes ConfigMap name") + flags.StringVar(&opts.SecretName, "secret", opts.SecretName, "Kubernetes Secret name") flags.StringVar(&opts.WorkloadName, "statefulset", opts.WorkloadName, "admin-api StatefulSet name") flags.StringVar(&opts.ContainerName, "container", opts.ContainerName, "admin-api container name") flags.BoolVar(&opts.NoRollout, "no-rollout", false, "skip rollout restart/status") @@ -182,8 +202,14 @@ func parseKeycloakSSOArgs(args []string) (ssoOptions, error) { if opts.JWKSURL == "" { return opts, fmt.Errorf("missing --jwks-url") } + if opts.ClientID == "" { + opts.ClientID = opts.Audience + } if opts.Audience == "" { - return opts, fmt.Errorf("missing --audience") + opts.Audience = opts.ClientID + } + if opts.Audience == "" { + return opts, fmt.Errorf("missing --audience or --client-id") } if opts.RequiredGroup == "" { return opts, fmt.Errorf("missing --required-group") @@ -194,6 +220,9 @@ func parseKeycloakSSOArgs(args []string) (ssoOptions, error) { if opts.GroupsClaim == "" { return opts, fmt.Errorf("missing --groups-claim") } + if opts.SecretName == "" { + return opts, fmt.Errorf("missing --secret") + } return opts, nil } @@ -201,6 +230,7 @@ func parseDisableSSOArgs(args []string) (ssoOptions, error) { opts := ssoOptions{ Namespace: defaultSSONamespace, ConfigMapName: defaultSSOConfigMap, + SecretName: defaultSSOSecret, WorkloadName: defaultSSOWorkload, ContainerName: defaultSSOContainer, } @@ -209,6 +239,7 @@ func parseDisableSSOArgs(args []string) (ssoOptions, error) { flags.SetOutput(os.Stderr) flags.StringVar(&opts.Namespace, "namespace", opts.Namespace, "Kubernetes namespace") flags.StringVar(&opts.ConfigMapName, "configmap", opts.ConfigMapName, "Kubernetes ConfigMap name") + flags.StringVar(&opts.SecretName, "secret", opts.SecretName, "Kubernetes Secret name") flags.StringVar(&opts.WorkloadName, "statefulset", opts.WorkloadName, "admin-api StatefulSet name") flags.StringVar(&opts.ContainerName, "container", opts.ContainerName, "admin-api container name") flags.BoolVar(&opts.NoRollout, "no-rollout", false, "skip rollout restart/status") @@ -229,9 +260,12 @@ func saveSSOConfig(configMap ConfigMap, opts ssoOptions) error { "SSO_OIDC_ISSUER_URL": opts.IssuerURL, "SSO_OIDC_JWKS_URL": opts.JWKSURL, "SSO_OIDC_AUDIENCE": opts.Audience, + "SSO_OIDC_CLIENT_ID": opts.ClientID, "SSO_OIDC_REQUIRED_GROUP": opts.RequiredGroup, "SSO_OIDC_USERNAME_CLAIM": opts.UsernameClaim, "SSO_OIDC_GROUPS_CLAIM": opts.GroupsClaim, + "SSO_OIDC_CLIENT_SECRET_CONFIGURED": fmt.Sprintf("%t", opts.ClientSecret != ""), + "SSO_CLIENT_MODE": ssoClientMode(opts), "SSO_AUTOPROVISION_ON_LOGIN": fmt.Sprintf("%t", opts.AutoProvision), "SSO_AUTOPROVISION_TIMEOUT_SECONDS": opts.AutoProvisionTimeout, "SSO_AUTOPROVISION_POLL_SECONDS": opts.AutoProvisionPoll, @@ -241,6 +275,7 @@ func saveSSOConfig(configMap ConfigMap, opts ssoOptions) error { "SSO_NAMESPACE_MAX_LENGTH": opts.NamespaceMaxLength, "SSO_KUBE_NAMESPACE": opts.Namespace, "SSO_KUBE_CONFIGMAP": opts.ConfigMapName, + "SSO_KUBE_SECRET": opts.SecretName, "SSO_KUBE_STATEFULSET": opts.WorkloadName, "SSO_KUBE_CONTAINER": opts.ContainerName, } @@ -262,7 +297,7 @@ func printSSOConfig(configMap ConfigMap) { } sort.Strings(keys) for _, key := range keys { - fmt.Printf("%s=%s\n", key, values[key]) + fmt.Printf("%s=%s\n", key, printableSSOValue(key, values[key])) } } @@ -281,6 +316,9 @@ func disableSSO(configMap ConfigMap, args []string) error { if err := deleteSSOConfigMap(opts); err != nil { return err } + if err := deleteSSOSecret(opts); err != nil { + return err + } if !opts.NoRollout { if err := rolloutSSOWorkload(opts); err != nil { return err @@ -308,6 +346,7 @@ func applySSOConfigMap(opts ssoOptions) error { "OIDC_ISSUER_URL": opts.IssuerURL, "OIDC_JWKS_URL": opts.JWKSURL, "OIDC_AUDIENCE": opts.Audience, + "OIDC_CLIENT_ID": opts.ClientID, "OIDC_REQUIRED_GROUP": opts.RequiredGroup, "OIDC_USERNAME_CLAIM": opts.UsernameClaim, "OIDC_GROUPS_CLAIM": opts.GroupsClaim, @@ -336,21 +375,50 @@ func applySSOConfigMap(opts ssoOptions) error { return err } +func applySSOSecret(opts ssoOptions) error { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]string{ + "name": opts.SecretName, + "namespace": opts.Namespace, + }, + "type": "Opaque", + "stringData": map[string]string{ + "OIDC_CLIENT_SECRET": opts.ClientSecret, + }, + } + payload, err := json.Marshal(obj) + if err != nil { + return err + } + _, err = runSSOCommand("kubectl", []string{"apply", "-f", "-"}, payload) + return err +} + func patchSSOEnvFrom(opts ssoOptions) error { + envFrom := []map[string]interface{}{ + { + "configMapRef": map[string]string{ + "name": opts.ConfigMapName, + }, + }, + } + if opts.ClientSecret != "" { + envFrom = append(envFrom, map[string]interface{}{ + "secretRef": map[string]string{ + "name": opts.SecretName, + }, + }) + } patch := map[string]interface{}{ "spec": map[string]interface{}{ "template": map[string]interface{}{ "spec": map[string]interface{}{ "containers": []map[string]interface{}{ { - "name": opts.ContainerName, - "envFrom": []map[string]interface{}{ - { - "configMapRef": map[string]string{ - "name": opts.ConfigMapName, - }, - }, - }, + "name": opts.ContainerName, + "envFrom": envFrom, }, }, }, @@ -393,6 +461,11 @@ func deleteSSOConfigMap(opts ssoOptions) error { return err } +func deleteSSOSecret(opts ssoOptions) error { + _, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "delete", "secret", opts.SecretName, "--ignore-not-found"}, nil) + return err +} + func rolloutSSOWorkload(opts ssoOptions) error { if _, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "rollout", "restart", "statefulset/" + opts.WorkloadName}, nil); err != nil { return err @@ -401,6 +474,30 @@ func rolloutSSOWorkload(opts ssoOptions) error { return err } +func ssoClientMode(opts ssoOptions) string { + if opts.ClientSecret != "" { + return "confidential" + } + return "public" +} + +func printableSSOValue(key string, value interface{}) interface{} { + if isSecretLikeKey(key) { + return "" + } + return value +} + +func isSecretLikeKey(key string) bool { + lower := strings.ToLower(key) + for _, marker := range []string{"secret", "token", "credential", "password"} { + if strings.Contains(lower, marker) && !strings.HasSuffix(lower, "_configured") { + return true + } + } + return false +} + func realCommandRunner(name string, args []string, stdin []byte) ([]byte, error) { cmdName := name if _, err := exec.LookPath(cmdName); err != nil && name == "kubectl" { diff --git a/config/sso_tool_test.go b/config/sso_tool_test.go index 86dc38b..910ef48 100644 --- a/config/sso_tool_test.go +++ b/config/sso_tool_test.go @@ -74,6 +74,7 @@ func TestConfigSSOToolKeycloak(t *testing.T) { require.Equal(t, "ConfigMap", cmObj["kind"]) data := cmObj["data"].(map[string]interface{}) require.Equal(t, "openserverless-admin-api", data["OIDC_AUDIENCE"]) + require.Equal(t, "openserverless-admin-api", data["OIDC_CLIENT_ID"]) require.Equal(t, "preferred_username", data["OIDC_USERNAME_CLAIM"]) require.Equal(t, "groups", data["OIDC_GROUPS_CLAIM"]) require.Equal(t, "true", data["SSO_AUTOPROVISION_ON_LOGIN"]) @@ -85,6 +86,96 @@ func TestConfigSSOToolKeycloak(t *testing.T) { require.Equal(t, "61", data["SSO_NAMESPACE_MAX_LENGTH"]) } +func TestConfigSSOToolKeycloakRollsOutAdminAPIByDefault(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + cm, err := NewConfigMapBuilder().WithConfigJson(configPath).Build() + require.NoError(t, err) + + var commands []recordedCommand + oldRunner := runSSOCommand + runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { + commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + return []byte("ok"), nil + } + defer func() { runSSOCommand = oldRunner }() + + err = ConfigSSOTool(cm, []string{ + "keycloak", + "--enable", + "--issuer-url", "http://localhost:8080/realms/openserverless-lab", + "--jwks-url", "http://localhost:8080/realms/openserverless-lab/protocol/openid-connect/certs", + "--client-id", "openserverless-admin-api", + "--required-group", "openserverless-users", + }) + require.NoError(t, err) + + require.Len(t, commands, 4) + require.Equal(t, []string{"apply", "-f", "-"}, commands[0].args) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[1].args[7]}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "restart", "statefulset/nuvolaris-system-api"}, commands[2].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "status", "statefulset/nuvolaris-system-api", "--timeout=180s"}, commands[3].args) +} + +func TestConfigSSOToolKeycloakWithClientSecret(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + cm, err := NewConfigMapBuilder().WithConfigJson(configPath).Build() + require.NoError(t, err) + + var commands []recordedCommand + oldRunner := runSSOCommand + runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { + commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + return []byte("ok"), nil + } + defer func() { runSSOCommand = oldRunner }() + + err = ConfigSSOTool(cm, []string{ + "keycloak", + "--enable", + "--issuer-url", "http://localhost:8080/realms/openserverless-lab", + "--jwks-url", "http://172.18.0.1:8080/realms/openserverless-lab/protocol/openid-connect/certs", + "--client-id", "openserverless-admin-api", + "--client-secret", "super-secret", + "--secret", "custom-sso-secret", + "--required-group", "openserverless-users", + "--no-rollout", + }) + require.NoError(t, err) + + configBytes, err := os.ReadFile(configPath) + require.NoError(t, err) + require.NotContains(t, string(configBytes), "super-secret") + + flat := cm.Flatten() + require.Equal(t, "openserverless-admin-api", flat["SSO_OIDC_AUDIENCE"]) + require.Equal(t, "openserverless-admin-api", flat["SSO_OIDC_CLIENT_ID"]) + require.Equal(t, "confidential", flat["SSO_CLIENT_MODE"]) + require.Equal(t, "true", flat["SSO_OIDC_CLIENT_SECRET_CONFIGURED"]) + require.Equal(t, "custom-sso-secret", flat["SSO_KUBE_SECRET"]) + + require.Len(t, commands, 3) + + var cmObj map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(commands[0].stdin), &cmObj)) + require.Equal(t, "ConfigMap", cmObj["kind"]) + cmData := cmObj["data"].(map[string]interface{}) + require.Equal(t, "openserverless-admin-api", cmData["OIDC_AUDIENCE"]) + require.Equal(t, "openserverless-admin-api", cmData["OIDC_CLIENT_ID"]) + require.NotContains(t, commands[0].stdin, "super-secret") + + var secretObj map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(commands[1].stdin), &secretObj)) + require.Equal(t, "Secret", secretObj["kind"]) + require.Equal(t, "custom-sso-secret", secretObj["metadata"].(map[string]interface{})["name"]) + secretData := secretObj["stringData"].(map[string]interface{}) + require.Equal(t, "super-secret", secretData["OIDC_CLIENT_SECRET"]) + + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[2].args[7]}, commands[2].args) + require.Contains(t, commands[2].args[7], "custom-sso-secret") +} + func TestConfigSSOToolKeycloakRequiresValues(t *testing.T) { cm, err := NewConfigMapBuilder().Build() require.NoError(t, err) @@ -126,6 +217,32 @@ func TestConfigSSOToolDisable(t *testing.T) { gotConfig, err := readConfig(configPath, fromConfigJson) require.NoError(t, err) require.Empty(t, gotConfig) - require.Len(t, commands, 2) + require.Len(t, commands, 3) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "secret", "openserverless-sso-secret", "--ignore-not-found"}, commands[2].args) +} + +func TestConfigSSOToolDisableRollsOutAdminAPIByDefault(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + cm, err := NewConfigMapBuilder().WithConfigJson(configPath).Build() + require.NoError(t, err) + + var commands []recordedCommand + oldRunner := runSSOCommand + runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { + commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + return []byte("ok"), nil + } + defer func() { runSSOCommand = oldRunner }() + + err = ConfigSSOTool(cm, []string{"disable"}) + require.NoError(t, err) + + require.Len(t, commands, 5) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[0].args[7]}, commands[0].args) require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "secret", "openserverless-sso-secret", "--ignore-not-found"}, commands[2].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "restart", "statefulset/nuvolaris-system-api"}, commands[3].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "status", "statefulset/nuvolaris-system-api", "--timeout=180s"}, commands[4].args) }