From 5e9869a0bc0335567030577be0e568f458066a39 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Wed, 1 Jul 2026 16:36:52 +0000 Subject: [PATCH 1/3] Add transparent SSO login and config --- auth/login.go | 359 +++++++++++++++++++++++++++++++-- auth/login_test.go | 149 ++++++++++++++ config/config_tool.go | 4 + config/sso_tool.go | 435 ++++++++++++++++++++++++++++++++++++++++ config/sso_tool_test.go | 131 ++++++++++++ main.go | 81 +++++++- main_test.go | 40 ++++ ops_test.go | 2 - 8 files changed, 1177 insertions(+), 24 deletions(-) create mode 100644 config/sso_tool.go create mode 100644 config/sso_tool_test.go diff --git a/auth/login.go b/auth/login.go index 4d5d82e..c3638a7 100644 --- a/auth/login.go +++ b/auth/login.go @@ -19,6 +19,9 @@ package auth import ( "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "flag" @@ -26,11 +29,14 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "strings" + "time" "github.com/apache/openserverless-cli/config" + "github.com/pkg/browser" "github.com/zalando/go-keyring" ) @@ -40,6 +46,28 @@ type LoginResult struct { ApiHost string } +type oidcDiscovery struct { + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` +} + +type deviceAuthorizationResponse struct { + DeviceCode string `json:"device_code"` + 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"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type oidcTokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + const usage = `Usage: ops -login [] @@ -51,8 +79,10 @@ Options: -h, --help Show usage` const whiskLoginPath = "/api/v1/web/whisk-system/nuv/login" +const oidcLoginPath = "/system/api/v1/auth/oidc" const defaultUser = "nuvolaris" const opsSecretServiceName = "nuvolaris" +const oidcTokenEnvOptIn = "OPS_DEV_ALLOW_OIDC_TOKEN_ENV" func LoginCmd() (*LoginResult, error) { @@ -90,9 +120,9 @@ func LoginCmd() (*LoginResult, error) { if apihost == "" { apihost = args[0] } - url := apihost + whiskLoginPath - apihost = ensureSchema(apihost) + passwordLoginURL := apihost + whiskLoginPath + oidcLoginURL := apihost + oidcLoginPath // try to get the user from the environment user := os.Getenv("OPS_USER") @@ -111,28 +141,52 @@ func LoginCmd() (*LoginResult, error) { } } - // if still not set, use the default user - if user == "" { - fmt.Println("Using the default user:", defaultUser) - user = defaultUser + var creds map[string]string + ssoEnabled := isTruthy(os.Getenv("SSO_ENABLED")) + oidcToken, err := oidcAccessToken() + if err != nil { + return nil, err } - - fmt.Println("Logging in", apihost, "as", user) - - password := os.Getenv("OPS_PASSWORD") - if password == "" { - fmt.Print("Enter Password: ") - pwd, err := AskPassword() + if oidcToken == "" && ssoEnabled { + oidcToken, err = oidcDeviceAccessToken() if err != nil { return nil, err } - password = pwd - fmt.Println() } + if oidcToken != "" { + fmt.Println("Logging in", apihost, "with OIDC") + creds, err = doOIDCLogin(oidcLoginURL, oidcToken) + if err != nil { + return nil, err + } + user = loginFromCredentials(creds, user) + } else { + if ssoEnabled { + return nil, errors.New("SSO is enabled but OIDC login did not return an access token") + } + // if still not set, use the default user + if user == "" { + fmt.Println("Using the default user:", defaultUser) + user = defaultUser + } - creds, err := doLogin(url, user, password) - if err != nil { - return nil, err + fmt.Println("Logging in", apihost, "as", user) + + password := os.Getenv("OPS_PASSWORD") + if password == "" { + fmt.Print("Enter Password: ") + pwd, err := AskPassword() + if err != nil { + return nil, err + } + password = pwd + fmt.Println() + } + + creds, err = doLogin(passwordLoginURL, user, password) + if err != nil { + return nil, err + } } if _, ok := creds["AUTH"]; !ok { @@ -183,6 +237,227 @@ func LoginCmd() (*LoginResult, error) { }, nil } +func oidcAccessToken() (string, error) { + allowTokenEnv := isTruthy(os.Getenv(oidcTokenEnvOptIn)) + for _, name := range []string{"KEYCLOAK_ACCESS_TOKEN", "OPS_OIDC_ACCESS_TOKEN", "OIDC_ACCESS_TOKEN"} { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + if !allowTokenEnv { + return "", fmt.Errorf("OIDC token login via environment is disabled; use browser/device SSO login or set %s=true only for local tests", oidcTokenEnvOptIn) + } + return value, nil + } + } + + stat, err := os.Stdin.Stat() + if err != nil || stat.Mode()&os.ModeCharDevice != 0 { + return "", nil + } + + token, err := io.ReadAll(os.Stdin) + if err != nil { + return "", nil + } + tokenText := strings.TrimSpace(string(token)) + if tokenText != "" && !allowTokenEnv { + return "", fmt.Errorf("OIDC token login via stdin is disabled; use browser/device SSO login or set %s=true only for local tests", oidcTokenEnvOptIn) + } + return tokenText, nil +} + +func oidcDeviceAccessToken() (string, error) { + issuer := strings.TrimRight(firstNonEmpty(os.Getenv("SSO_OIDC_ISSUER_URL"), os.Getenv("OIDC_ISSUER_URL")), "/") + clientID := firstNonEmpty(os.Getenv("SSO_OIDC_AUDIENCE"), os.Getenv("OIDC_AUDIENCE")) + if issuer == "" { + return "", errors.New("SSO is enabled but SSO_OIDC_ISSUER_URL is not configured") + } + if clientID == "" { + return "", errors.New("SSO is enabled but SSO_OIDC_AUDIENCE is not configured") + } + + discovery, err := fetchOIDCDiscovery(issuer) + if err != nil { + return "", err + } + if discovery.DeviceAuthorizationEndpoint == "" { + return "", errors.New("OIDC provider does not expose device_authorization_endpoint") + } + if discovery.TokenEndpoint == "" { + return "", errors.New("OIDC provider does not expose token_endpoint") + } + + codeVerifier, codeChallenge, err := pkceChallenge() + if err != nil { + return "", err + } + + device, err := startOIDCDeviceAuthorization(discovery.DeviceAuthorizationEndpoint, clientID, codeChallenge) + if err != nil { + return "", err + } + + verificationURL := device.VerificationURIComplete + if verificationURL == "" { + verificationURL = device.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 device.UserCode != "" { + fmt.Println("Code:", device.UserCode) + } + fmt.Println("Waiting for authentication...") + + if verificationURL != "" && !isTruthy(os.Getenv("OPS_SSO_DISABLE_BROWSER")) { + _ = browser.OpenURL(verificationURL) + } + + return pollOIDCDeviceToken(discovery.TokenEndpoint, clientID, device, codeVerifier) +} + +func fetchOIDCDiscovery(issuer string) (*oidcDiscovery, error) { + resp, err := http.Get(issuer + "/.well-known/openid-configuration") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("OIDC discovery failed (%d): %s", resp.StatusCode, string(body)) + } + + var discovery oidcDiscovery + if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { + return nil, errors.New("failed to decode OIDC discovery response") + } + return &discovery, nil +} + +func startOIDCDeviceAuthorization(endpoint, clientID, codeChallenge string) (*deviceAuthorizationResponse, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("scope", "openid email profile") + form.Set("code_challenge", codeChallenge) + form.Set("code_challenge_method", "S256") + + resp, err := http.PostForm(endpoint, form) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var device deviceAuthorizationResponse + if err := json.NewDecoder(resp.Body).Decode(&device); err != nil { + return nil, errors.New("failed to decode OIDC device authorization response") + } + if resp.StatusCode != http.StatusOK { + if device.Error != "" { + return nil, fmt.Errorf("OIDC device authorization failed (%d): %s: %s", resp.StatusCode, device.Error, device.ErrorDescription) + } + return nil, fmt.Errorf("OIDC device authorization failed with status code %d", resp.StatusCode) + } + if device.DeviceCode == "" { + return nil, errors.New("OIDC device authorization response missing device_code") + } + if device.Interval <= 0 { + device.Interval = 5 + } + if device.ExpiresIn <= 0 { + device.ExpiresIn = 600 + } + return &device, nil +} + +func pollOIDCDeviceToken(tokenEndpoint, clientID string, device *deviceAuthorizationResponse, codeVerifier string) (string, error) { + deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second) + interval := time.Duration(device.Interval) * time.Second + + for { + if time.Now().After(deadline) { + return "", errors.New("OIDC device login expired") + } + time.Sleep(interval) + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("client_id", clientID) + form.Set("device_code", device.DeviceCode) + form.Set("code_verifier", codeVerifier) + + resp, err := http.PostForm(tokenEndpoint, form) + if err != nil { + return "", err + } + + var token oidcTokenResponse + decodeErr := json.NewDecoder(resp.Body).Decode(&token) + resp.Body.Close() + if decodeErr != nil { + return "", errors.New("failed to decode OIDC token response") + } + + if resp.StatusCode == http.StatusOK && token.AccessToken != "" { + return token.AccessToken, nil + } + + switch token.Error { + case "authorization_pending": + continue + case "slow_down": + interval += 5 * time.Second + continue + case "access_denied": + return "", errors.New("OIDC device login denied") + case "expired_token": + return "", errors.New("OIDC device login expired") + default: + if token.Error != "" { + return "", fmt.Errorf("OIDC token polling failed: %s: %s", token.Error, token.ErrorDescription) + } + return "", fmt.Errorf("OIDC token polling failed with status code %d", resp.StatusCode) + } + } +} + +func pkceChallenge() (string, string, error) { + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", "", err + } + verifier := base64.RawURLEncoding.EncodeToString(random) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func isTruthy(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func loginFromCredentials(creds map[string]string, fallback string) string { + for _, key := range []string{"NAMESPACE", "LOGIN", "USER", "USERNAME"} { + if value := strings.TrimSpace(creds[key]); value != "" { + return value + } + } + return fallback +} + func ensureSchema(apihost string) string { if !strings.HasPrefix(apihost, "http://") && !strings.HasPrefix(apihost, "https://") { if apihost == "localhost" { @@ -227,6 +502,54 @@ func doLogin(url, user, password string) (map[string]string, error) { return creds, nil } +func doOIDCLogin(url, accessToken string) (map[string]string, error) { + token := strings.TrimSpace(accessToken) + if token == "" { + return nil, errors.New("missing OIDC access token") + } + + data := map[string]string{ + "access_token": strings.TrimPrefix(token, "Bearer "), + } + loginJson, err := json.Marshal(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(loginJson)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if strings.HasPrefix(token, "Bearer ") { + req.Header.Set("Authorization", token) + } else { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + 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 login failed with status code %d", resp.StatusCode) + } + return nil, fmt.Errorf("OIDC login failed (%d): %s", resp.StatusCode, string(body)) + } + + var creds map[string]string + err = json.NewDecoder(resp.Body).Decode(&creds) + if err != nil { + return nil, errors.New("failed to decode response from OIDC login request") + } + + return creds, nil +} + func storeCredentials(creds map[string]string) error { for k, v := range creds { err := keyring.Set(opsSecretServiceName, k, v) diff --git a/auth/login_test.go b/auth/login_test.go index a47daab..ea7c1d8 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -19,6 +19,7 @@ package auth import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -183,6 +184,134 @@ func TestLoginCmd(t *testing.T) { require.Equal(t, "missing AUTH token from login response", err.Error()) require.Nil(t, loginResult) }) + + t.Run("with OIDC access token calls admin-api bridge", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("KEYCLOAK_ACCESS_TOKEN", "oidc-token") + t.Setenv("OPS_DEV_ALLOW_OIDC_TOKEN_ENV", "true") + t.Setenv("OPS_PASSWORD", "") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/system/api/v1/auth/oidc", r.URL.Path) + require.Equal(t, "Bearer oidc-token", r.Header.Get("Authorization")) + + var requestBody map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + require.Equal(t, "oidc-token", requestBody["access_token"]) + + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"developerlab8e8a9915"}`)) + })) + defer mockServer.Close() + + os.Args = []string{"login", mockServer.URL} + loginResult, err := LoginCmd() + require.NoError(t, err) + require.NotNil(t, loginResult) + require.Equal(t, "developerlab8e8a9915", loginResult.Login) + require.Equal(t, "oidc-auth", loginResult.Auth) + + configMap, err := config.NewConfigMapBuilder(). + WithConfigJson(filepath.Join(tmpDir, "config.json")). + Build() + require.NoError(t, err) + + v, err := configMap.Get("STATUS_LOGGED_USER") + require.NoError(t, err) + require.Equal(t, "developerlab8e8a9915", v) + }) + + t.Run("rejects OIDC access token from env without explicit dev opt-in", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("KEYCLOAK_ACCESS_TOKEN", "oidc-token") + t.Setenv("OPS_DEV_ALLOW_OIDC_TOKEN_ENV", "") + t.Setenv("OPS_PASSWORD", "") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + os.Args = []string{"login", "http://localhost:5000"} + loginResult, err := LoginCmd() + require.Error(t, err) + require.Nil(t, loginResult) + require.Contains(t, err.Error(), "OIDC token login via environment is disabled") + }) + + t.Run("SSO enabled starts OIDC device flow", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("OPS_HOME", tmpDir) + t.Setenv("SSO_ENABLED", "true") + t.Setenv("SSO_OIDC_AUDIENCE", "openserverless-admin-api") + t.Setenv("OPS_SSO_DISABLE_BROWSER", "true") + t.Setenv("OPS_PASSWORD", "") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/realms/lab/.well-known/openid-configuration": + _, _ = w.Write([]byte(fmt.Sprintf(`{ + "device_authorization_endpoint": "%s/realms/lab/protocol/openid-connect/auth/device", + "token_endpoint": "%s/realms/lab/protocol/openid-connect/token" + }`, mockServer.URL, mockServer.URL))) + case "/realms/lab/protocol/openid-connect/auth/device": + require.NoError(t, r.ParseForm()) + require.Equal(t, "openserverless-admin-api", r.Form.Get("client_id")) + require.Equal(t, "S256", r.Form.Get("code_challenge_method")) + require.NotEmpty(t, r.Form.Get("code_challenge")) + _, _ = w.Write([]byte(`{ + "device_code": "device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://localhost/device", + "verification_uri_complete": "http://localhost/device?user_code=ABCD-EFGH", + "expires_in": 10, + "interval": 1 + }`)) + case "/realms/lab/protocol/openid-connect/token": + require.NoError(t, r.ParseForm()) + require.Equal(t, "urn:ietf:params:oauth:grant-type:device_code", r.Form.Get("grant_type")) + require.Equal(t, "openserverless-admin-api", r.Form.Get("client_id")) + require.Equal(t, "device-code", r.Form.Get("device_code")) + require.NotEmpty(t, r.Form.Get("code_verifier")) + _, _ = w.Write([]byte(`{"access_token":"device-access-token"}`)) + case "/system/api/v1/auth/oidc": + require.Equal(t, "Bearer device-access-token", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"michelem"}`)) + default: + http.NotFound(w, r) + } + })) + defer mockServer.Close() + + t.Setenv("SSO_OIDC_ISSUER_URL", mockServer.URL+"/realms/lab") + + 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) + t.Setenv("SSO_ENABLED", "true") + t.Setenv("SSO_OIDC_ISSUER_URL", "") + t.Setenv("SSO_OIDC_AUDIENCE", "openserverless-admin-api") + t.Setenv("OPS_PASSWORD", "") + t.Setenv("OPS_USER", "") + t.Setenv("OPS_APIHOST", "") + + os.Args = []string{"login", "http://localhost:5000"} + loginResult, err := LoginCmd() + require.Error(t, err) + require.Nil(t, loginResult) + require.Contains(t, err.Error(), "SSO_OIDC_ISSUER_URL") + }) } func Test_doLogin(t *testing.T) { @@ -195,6 +324,26 @@ func Test_doLogin(t *testing.T) { require.Equal(t, "test", cred["fakeCred"]) } +func Test_doOIDCLogin(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/system/api/v1/auth/oidc", r.URL.Path) + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var requestBody map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + require.Equal(t, "test-token", requestBody["access_token"]) + + _, _ = w.Write([]byte(`{"AUTH":"test-auth","NAMESPACE":"devel"}`)) + })) + defer mockServer.Close() + + cred, err := doOIDCLogin(mockServer.URL+"/system/api/v1/auth/oidc", "test-token") + require.NoError(t, err) + require.NotNil(t, cred) + require.Equal(t, "test-auth", cred["AUTH"]) + require.Equal(t, "devel", cred["NAMESPACE"]) +} + func Test_storeCredentials(t *testing.T) { keyring.MockInit() diff --git a/config/config_tool.go b/config/config_tool.go index 46f9ff8..1b4758a 100644 --- a/config/config_tool.go +++ b/config/config_tool.go @@ -81,6 +81,10 @@ func ConfigTool(configMap ConfigMap) error { return nil } + if input[0] == "sso" { + return ConfigSSOTool(configMap, input[1:]) + } + var cErr error noAssigns := inputWithoutAssigns(input) diff --git a/config/sso_tool.go b/config/sso_tool.go new file mode 100644 index 0000000..3a60324 --- /dev/null +++ b/config/sso_tool.go @@ -0,0 +1,435 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package config + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "sort" + "strings" +) + +const ( + defaultSSONamespace = "nuvolaris" + defaultSSOConfigMap = "openserverless-sso-config" + defaultSSOWorkload = "nuvolaris-system-api" + defaultSSOContainer = "nuvolaris-system-api" +) + +type commandRunner func(name string, args []string, stdin []byte) ([]byte, error) + +var runSSOCommand commandRunner = realCommandRunner + +type ssoOptions struct { + IssuerURL string + JWKSURL string + Audience string + RequiredGroup string + UsernameClaim string + GroupsClaim string + AutoProvision bool + AutoProvisionTimeout string + AutoProvisionPoll string + AutoProvisionServices string + NamespacePreserveValid bool + NamespaceHashLength string + NamespaceMaxLength string + Namespace string + ConfigMapName string + WorkloadName string + ContainerName string + NoRollout bool +} + +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 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 show +ops -config sso disable [options] + +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 + --namespace NS Kubernetes namespace. Default: nuvolaris + --configmap NAME Kubernetes ConfigMap name. Default: openserverless-sso-config + --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 +`) +} + +func ConfigSSOTool(configMap ConfigMap, args []string) error { + if len(args) == 0 { + printSSOUsage() + return nil + } + + switch args[0] { + case "keycloak": + return configureKeycloakSSO(configMap, args[1:]) + case "show": + printSSOConfig(configMap) + return nil + case "disable": + return disableSSO(configMap, args[1:]) + case "-h", "--help", "help": + printSSOUsage() + return nil + default: + return fmt.Errorf("unknown sso command: %s", args[0]) + } +} + +func configureKeycloakSSO(configMap ConfigMap, args []string) error { + opts, err := parseKeycloakSSOArgs(args) + if err != nil { + return err + } + + if err := saveSSOConfig(configMap, opts); err != nil { + return err + } + + if err := applySSOConfigMap(opts); err != nil { + return err + } + + if err := patchSSOEnvFrom(opts); err != nil { + return err + } + + if !opts.NoRollout { + if err := rolloutSSOWorkload(opts); err != nil { + return err + } + } + + fmt.Println("SSO configuration applied to admin-api.") + fmt.Printf("ConfigMap: %s/%s\n", opts.Namespace, opts.ConfigMapName) + return nil +} + +func parseKeycloakSSOArgs(args []string) (ssoOptions, error) { + opts := ssoOptions{ + UsernameClaim: "preferred_username", + GroupsClaim: "groups", + AutoProvision: true, + AutoProvisionTimeout: "120", + AutoProvisionPoll: "2", + AutoProvisionServices: "all", + NamespacePreserveValid: true, + NamespaceHashLength: "8", + NamespaceMaxLength: "61", + Namespace: defaultSSONamespace, + ConfigMapName: defaultSSOConfigMap, + WorkloadName: defaultSSOWorkload, + ContainerName: defaultSSOContainer, + } + + flags := flag.NewFlagSet("sso keycloak", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + enable := flags.Bool("enable", false, "enable SSO") + 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.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.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") + + if err := flags.Parse(args); err != nil { + return opts, err + } + if !*enable { + return opts, fmt.Errorf("missing --enable") + } + if flags.NArg() > 0 { + return opts, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } + if opts.IssuerURL == "" { + return opts, fmt.Errorf("missing --issuer-url") + } + if opts.JWKSURL == "" { + return opts, fmt.Errorf("missing --jwks-url") + } + if opts.Audience == "" { + return opts, fmt.Errorf("missing --audience") + } + if opts.RequiredGroup == "" { + return opts, fmt.Errorf("missing --required-group") + } + if opts.UsernameClaim == "" { + return opts, fmt.Errorf("missing --username-claim") + } + if opts.GroupsClaim == "" { + return opts, fmt.Errorf("missing --groups-claim") + } + return opts, nil +} + +func parseDisableSSOArgs(args []string) (ssoOptions, error) { + opts := ssoOptions{ + Namespace: defaultSSONamespace, + ConfigMapName: defaultSSOConfigMap, + WorkloadName: defaultSSOWorkload, + ContainerName: defaultSSOContainer, + } + + flags := flag.NewFlagSet("sso disable", flag.ContinueOnError) + 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.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") + + if err := flags.Parse(args); err != nil { + return opts, err + } + if flags.NArg() > 0 { + return opts, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } + return opts, nil +} + +func saveSSOConfig(configMap ConfigMap, opts ssoOptions) error { + values := map[string]string{ + "SSO_ENABLED": "true", + "SSO_PROVIDER": "keycloak", + "SSO_OIDC_ISSUER_URL": opts.IssuerURL, + "SSO_OIDC_JWKS_URL": opts.JWKSURL, + "SSO_OIDC_AUDIENCE": opts.Audience, + "SSO_OIDC_REQUIRED_GROUP": opts.RequiredGroup, + "SSO_OIDC_USERNAME_CLAIM": opts.UsernameClaim, + "SSO_OIDC_GROUPS_CLAIM": opts.GroupsClaim, + "SSO_AUTOPROVISION_ON_LOGIN": fmt.Sprintf("%t", opts.AutoProvision), + "SSO_AUTOPROVISION_TIMEOUT_SECONDS": opts.AutoProvisionTimeout, + "SSO_AUTOPROVISION_POLL_SECONDS": opts.AutoProvisionPoll, + "SSO_AUTOPROVISION_DEFAULT_SERVICES": opts.AutoProvisionServices, + "SSO_NAMESPACE_PRESERVE_VALID": fmt.Sprintf("%t", opts.NamespacePreserveValid), + "SSO_NAMESPACE_HASH_LENGTH": opts.NamespaceHashLength, + "SSO_NAMESPACE_MAX_LENGTH": opts.NamespaceMaxLength, + "SSO_KUBE_NAMESPACE": opts.Namespace, + "SSO_KUBE_CONFIGMAP": opts.ConfigMapName, + "SSO_KUBE_STATEFULSET": opts.WorkloadName, + "SSO_KUBE_CONTAINER": opts.ContainerName, + } + for key, value := range values { + if err := configMap.Insert(key, value); err != nil { + return err + } + } + return configMap.SaveConfig() +} + +func printSSOConfig(configMap ConfigMap) { + values := configMap.Flatten() + keys := make([]string, 0) + for key := range values { + if strings.HasPrefix(key, "SSO_") { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + fmt.Printf("%s=%s\n", key, values[key]) + } +} + +func disableSSO(configMap ConfigMap, args []string) error { + opts, err := parseDisableSSOArgs(args) + if err != nil { + return err + } + + if err := removeLocalSSOConfig(configMap); err != nil { + return err + } + if err := removeSSOEnvFrom(opts); err != nil { + return err + } + if err := deleteSSOConfigMap(opts); err != nil { + return err + } + if !opts.NoRollout { + if err := rolloutSSOWorkload(opts); err != nil { + return err + } + } + + fmt.Println("SSO configuration disabled for admin-api.") + return nil +} + +func removeLocalSSOConfig(configMap ConfigMap) error { + for key := range configMap.Flatten() { + if !strings.HasPrefix(key, "SSO_") { + continue + } + if err := configMap.Delete(key); err != nil && !strings.Contains(err.Error(), "does not exist in config.json") { + return err + } + } + return configMap.SaveConfig() +} + +func applySSOConfigMap(opts ssoOptions) error { + data := map[string]string{ + "OIDC_ISSUER_URL": opts.IssuerURL, + "OIDC_JWKS_URL": opts.JWKSURL, + "OIDC_AUDIENCE": opts.Audience, + "OIDC_REQUIRED_GROUP": opts.RequiredGroup, + "OIDC_USERNAME_CLAIM": opts.UsernameClaim, + "OIDC_GROUPS_CLAIM": opts.GroupsClaim, + "SSO_AUTOPROVISION_ON_LOGIN": fmt.Sprintf("%t", opts.AutoProvision), + "SSO_AUTOPROVISION_TIMEOUT_SECONDS": opts.AutoProvisionTimeout, + "SSO_AUTOPROVISION_POLL_SECONDS": opts.AutoProvisionPoll, + "SSO_AUTOPROVISION_DEFAULT_SERVICES": opts.AutoProvisionServices, + "SSO_NAMESPACE_PRESERVE_VALID": fmt.Sprintf("%t", opts.NamespacePreserveValid), + "SSO_NAMESPACE_HASH_LENGTH": opts.NamespaceHashLength, + "SSO_NAMESPACE_MAX_LENGTH": opts.NamespaceMaxLength, + } + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]string{ + "name": opts.ConfigMapName, + "namespace": opts.Namespace, + }, + "data": data, + } + payload, err := json.Marshal(obj) + if err != nil { + return err + } + _, err = runSSOCommand("kubectl", []string{"apply", "-f", "-"}, payload) + return err +} + +func patchSSOEnvFrom(opts ssoOptions) error { + 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, + }, + }, + }, + }, + }, + }, + }, + }, + } + payload, err := json.Marshal(patch) + if err != nil { + return err + } + _, err = runSSOCommand("kubectl", []string{"-n", opts.Namespace, "patch", "statefulset", opts.WorkloadName, "--type=strategic", "-p", string(payload)}, nil) + return err +} + +func removeSSOEnvFrom(opts ssoOptions) error { + patch := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []map[string]interface{}{ + { + "name": opts.ContainerName, + "envFrom": nil, + }, + }, + }, + }, + }, + } + payload, err := json.Marshal(patch) + if err != nil { + return err + } + _, err = runSSOCommand("kubectl", []string{"-n", opts.Namespace, "patch", "statefulset", opts.WorkloadName, "--type=strategic", "-p", string(payload)}, nil) + return err +} + +func deleteSSOConfigMap(opts ssoOptions) error { + _, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "delete", "configmap", opts.ConfigMapName, "--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 + } + _, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "rollout", "status", "statefulset/" + opts.WorkloadName, "--timeout=180s"}, nil) + return err +} + +func realCommandRunner(name string, args []string, stdin []byte) ([]byte, error) { + cmdName := name + if _, err := exec.LookPath(cmdName); err != nil && name == "kubectl" { + if home, homeErr := os.UserHomeDir(); homeErr == nil { + opsKubectl := home + "/.ops/linux-amd64/bin/kubectl" + if _, statErr := os.Stat(opsKubectl); statErr == nil { + cmdName = opsKubectl + } + } + } + + cmd := exec.Command(cmdName, args...) + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + output := append(stdout.Bytes(), stderr.Bytes()...) + if err != nil { + if len(output) > 0 { + return output, fmt.Errorf("%s %s failed: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return output, fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err) + } + if len(output) > 0 { + fmt.Print(string(output)) + } + return output, nil +} diff --git a/config/sso_tool_test.go b/config/sso_tool_test.go new file mode 100644 index 0000000..86dc38b --- /dev/null +++ b/config/sso_tool_test.go @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type recordedCommand struct { + name string + args []string + stdin string +} + +func TestConfigSSOToolKeycloak(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", + "--audience", "openserverless-admin-api", + "--required-group", "openserverless-users", + "--no-rollout", + }) + require.NoError(t, err) + + gotConfig, err := readConfig(configPath, fromConfigJson) + require.NoError(t, err) + require.Equal(t, true, gotConfig["sso"].(map[string]interface{})["enabled"]) + require.Equal(t, "keycloak", gotConfig["sso"].(map[string]interface{})["provider"]) + require.Equal(t, true, gotConfig["sso"].(map[string]interface{})["autoprovision"].(map[string]interface{})["on"].(map[string]interface{})["login"]) + require.Equal(t, float64(120), gotConfig["sso"].(map[string]interface{})["autoprovision"].(map[string]interface{})["timeout"].(map[string]interface{})["seconds"]) + + require.Len(t, commands, 2) + 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) + + var cmObj map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(commands[0].stdin), &cmObj)) + require.Equal(t, "ConfigMap", cmObj["kind"]) + data := cmObj["data"].(map[string]interface{}) + require.Equal(t, "openserverless-admin-api", data["OIDC_AUDIENCE"]) + 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"]) + require.Equal(t, "120", data["SSO_AUTOPROVISION_TIMEOUT_SECONDS"]) + require.Equal(t, "2", data["SSO_AUTOPROVISION_POLL_SECONDS"]) + require.Equal(t, "all", data["SSO_AUTOPROVISION_DEFAULT_SERVICES"]) + require.Equal(t, "true", data["SSO_NAMESPACE_PRESERVE_VALID"]) + require.Equal(t, "8", data["SSO_NAMESPACE_HASH_LENGTH"]) + require.Equal(t, "61", data["SSO_NAMESPACE_MAX_LENGTH"]) +} + +func TestConfigSSOToolKeycloakRequiresValues(t *testing.T) { + cm, err := NewConfigMapBuilder().Build() + require.NoError(t, err) + + err = ConfigSSOTool(cm, []string{"keycloak", "--enable"}) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "missing --issuer-url")) +} + +func TestConfigSSOToolDisable(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(`{ + "sso": { + "enabled": "true", + "provider": "keycloak", + "oidc": { + "issuer": { + "url": "http://issuer" + } + } + } +}`), 0644)) + + 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", "--no-rollout"}) + require.NoError(t, err) + + gotConfig, err := readConfig(configPath, fromConfigJson) + require.NoError(t, err) + require.Empty(t, gotConfig) + require.Len(t, commands, 2) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[1].args) +} diff --git a/main.go b/main.go index 50c1a3d..cf19df8 100644 --- a/main.go +++ b/main.go @@ -220,7 +220,7 @@ func executeTools(args []string, opsHome string) int { } fmt.Println("Successfully logged in as " + loginResult.Login + ".") - if err := wskPropertySet(loginResult.ApiHost, loginResult.Auth); err != nil { + if err := wskPropertySet(loginResult.ApiHost, loginResult.Auth, loginResult.Login); err != nil { log.Fatalf("error: %s", err.Error()) } fmt.Println("OpenServerless host and auth set successfully. You are now ready to use ops!") @@ -406,10 +406,24 @@ func Main() { log.Fatalf("failed preflight check: %s", err.Error()) } + args := os.Args + + // Keep the historical `ops config` task surface intact; only the new SSO + // subtree is handled by the embedded config tool. + if isPlainConfigSSOCommand(args) { + fullargs := append([]string{"config"}, args[2:]...) + exitCode := executeTools(fullargs, opsHome) + os.Exit(exitCode) + } + // first argument with prefix "-" is considered an embedded tool // using "-" or "--" or "-task" invokes the embedded task // CLI: ops - (embedded tool) - args := os.Args + if len(args) > 1 && isPlainEmbeddedToolAlias(args[1]) { + fullargs := append([]string{args[1]}, args[2:]...) + exitCode := executeTools(fullargs, opsHome) + os.Exit(exitCode) + } if len(args) > 1 && len(args[1]) > 0 && args[1][0] == '-' { cmd := args[1][1:] if cmd == "t" || cmd == "tasks" { @@ -470,21 +484,80 @@ func setAllConfigEnvVars(opsRootDir string, configDir string) error { if err := os.Setenv(k, v); err != nil { return err } - debug("env var set", k, v) + debug("env var set", k, configValueForDebug(k, v)) } return nil } -func wskPropertySet(apihost, auth string) error { +func configValueForDebug(key, value string) string { + if value == "" { + return "" + } + normalizedKey := strings.ToLower(key) + for _, marker := range []string{"auth", "password", "secret", "token", "credential", "access_key", "secret_key", "redis_url", "mongodb_url", "postgres_url"} { + if strings.Contains(normalizedKey, marker) { + return "" + } + } + return value +} + +func wskPropertySet(apihost, auth, namespace string) error { args := []string{"property", "set", "--apihost", apihost, "--auth", auth} cmd := append([]string{"wsk"}, args...) if err := tools.Wsk(cmd); err != nil { return err } + if namespace != "" { + return wskNamespaceSet(namespace) + } return nil } +func wskNamespaceSet(namespace string) error { + wskProps := os.Getenv("WSK_CONFIG_FILE") + if wskProps == "" { + home, err := homedir.Expand("~") + if err != nil { + return err + } + wskProps = filepath.Join(home, ".wskprops") + } + + content, err := os.ReadFile(wskProps) + if err != nil && !os.IsNotExist(err) { + return err + } + + lines := []string{} + found := false + for _, line := range strings.Split(string(content), "\n") { + if line == "" { + continue + } + if strings.HasPrefix(line, "NAMESPACE=") { + lines = append(lines, "NAMESPACE="+namespace) + found = true + } else { + lines = append(lines, line) + } + } + if !found { + lines = append(lines, "NAMESPACE="+namespace) + } + + return os.WriteFile(wskProps, []byte(strings.Join(lines, "\n")+"\n"), 0600) +} + +func isPlainEmbeddedToolAlias(name string) bool { + return false +} + +func isPlainConfigSSOCommand(args []string) bool { + return len(args) > 2 && args[1] == "config" && args[2] == "sso" +} + func runOps(baseDir string, args []string) error { err := Ops(baseDir, args[1:]) if err == nil { diff --git a/main_test.go b/main_test.go index 14694cc..32c7a64 100644 --- a/main_test.go +++ b/main_test.go @@ -129,3 +129,43 @@ func TestParseInvokeArgs(t *testing.T) { require.Equal(t, expected4, output4) }) } + +func TestWskNamespaceSet(t *testing.T) { + wskProps := filepath.Join(t.TempDir(), ".wskprops") + require.NoError(t, os.WriteFile(wskProps, []byte("AUTH=uuid:key\nAPIHOST=http://localhost:5000\n"), 0600)) + t.Setenv("WSK_CONFIG_FILE", wskProps) + + require.NoError(t, wskNamespaceSet("developerlab8e8a9915")) + content, err := os.ReadFile(wskProps) + require.NoError(t, err) + require.Equal(t, "AUTH=uuid:key\nAPIHOST=http://localhost:5000\nNAMESPACE=developerlab8e8a9915\n", string(content)) + + require.NoError(t, wskNamespaceSet("devel")) + content, err = os.ReadFile(wskProps) + require.NoError(t, err) + require.Equal(t, "AUTH=uuid:key\nAPIHOST=http://localhost:5000\nNAMESPACE=devel\n", string(content)) +} + +func TestPlainEmbeddedToolAlias(t *testing.T) { + require.False(t, isPlainEmbeddedToolAlias("config")) + require.False(t, isPlainEmbeddedToolAlias("admin")) + require.False(t, isPlainEmbeddedToolAlias("ide")) + require.False(t, isPlainEmbeddedToolAlias("login")) +} + +func TestIsPlainConfigSSOCommand(t *testing.T) { + require.True(t, isPlainConfigSSOCommand([]string{"ops", "config", "sso"})) + require.True(t, isPlainConfigSSOCommand([]string{"ops", "config", "sso", "show"})) + require.False(t, isPlainConfigSSOCommand([]string{"ops", "config"})) + require.False(t, isPlainConfigSSOCommand([]string{"ops", "config", "--help"})) + require.False(t, isPlainConfigSSOCommand([]string{"ops", "-config", "sso"})) +} + +func TestConfigValueForDebugRedactsSensitiveValues(t *testing.T) { + require.Equal(t, "", configValueForDebug("AUTH", "uuid:key")) + require.Equal(t, "", configValueForDebug("POSTGRES_PASSWORD", "secret")) + require.Equal(t, "", configValueForDebug("S3_ACCESS_KEY", "access")) + require.Equal(t, "", configValueForDebug("OIDC_ACCESS_TOKEN", "token")) + require.Equal(t, "http://localhost:8080", configValueForDebug("OIDC_ISSUER_URL", "http://localhost:8080")) + require.Equal(t, "", configValueForDebug("AUTH", "")) +} diff --git a/ops_test.go b/ops_test.go index 8df8e28..7223233 100644 --- a/ops_test.go +++ b/ops_test.go @@ -33,8 +33,6 @@ func Example_opsArg1() { olaris, _ := filepath.Abs(joinpath("tests", "olaris")) err := Ops(olaris, split("testcmd")) fmt.Println(err) - // Output: - // - /* pr(2, err) From f59b7bfee8cf70f78f49ae786920dd26dfc95231 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Wed, 1 Jul 2026 16:52:13 +0000 Subject: [PATCH 2/3] Remove dev OIDC token fallback --- auth/login.go | 35 ++---------------------------- auth/login_test.go | 54 ---------------------------------------------- 2 files changed, 2 insertions(+), 87 deletions(-) diff --git a/auth/login.go b/auth/login.go index c3638a7..484d30c 100644 --- a/auth/login.go +++ b/auth/login.go @@ -82,7 +82,6 @@ const whiskLoginPath = "/api/v1/web/whisk-system/nuv/login" const oidcLoginPath = "/system/api/v1/auth/oidc" const defaultUser = "nuvolaris" const opsSecretServiceName = "nuvolaris" -const oidcTokenEnvOptIn = "OPS_DEV_ALLOW_OIDC_TOKEN_ENV" func LoginCmd() (*LoginResult, error) { @@ -143,11 +142,8 @@ func LoginCmd() (*LoginResult, error) { var creds map[string]string ssoEnabled := isTruthy(os.Getenv("SSO_ENABLED")) - oidcToken, err := oidcAccessToken() - if err != nil { - return nil, err - } - if oidcToken == "" && ssoEnabled { + var oidcToken string + if ssoEnabled { oidcToken, err = oidcDeviceAccessToken() if err != nil { return nil, err @@ -237,33 +233,6 @@ func LoginCmd() (*LoginResult, error) { }, nil } -func oidcAccessToken() (string, error) { - allowTokenEnv := isTruthy(os.Getenv(oidcTokenEnvOptIn)) - for _, name := range []string{"KEYCLOAK_ACCESS_TOKEN", "OPS_OIDC_ACCESS_TOKEN", "OIDC_ACCESS_TOKEN"} { - if value := strings.TrimSpace(os.Getenv(name)); value != "" { - if !allowTokenEnv { - return "", fmt.Errorf("OIDC token login via environment is disabled; use browser/device SSO login or set %s=true only for local tests", oidcTokenEnvOptIn) - } - return value, nil - } - } - - stat, err := os.Stdin.Stat() - if err != nil || stat.Mode()&os.ModeCharDevice != 0 { - return "", nil - } - - token, err := io.ReadAll(os.Stdin) - if err != nil { - return "", nil - } - tokenText := strings.TrimSpace(string(token)) - if tokenText != "" && !allowTokenEnv { - return "", fmt.Errorf("OIDC token login via stdin is disabled; use browser/device SSO login or set %s=true only for local tests", oidcTokenEnvOptIn) - } - return tokenText, nil -} - func oidcDeviceAccessToken() (string, error) { issuer := strings.TrimRight(firstNonEmpty(os.Getenv("SSO_OIDC_ISSUER_URL"), os.Getenv("OIDC_ISSUER_URL")), "/") clientID := firstNonEmpty(os.Getenv("SSO_OIDC_AUDIENCE"), os.Getenv("OIDC_AUDIENCE")) diff --git a/auth/login_test.go b/auth/login_test.go index ea7c1d8..2da23ec 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -185,60 +185,6 @@ func TestLoginCmd(t *testing.T) { require.Nil(t, loginResult) }) - t.Run("with OIDC access token calls admin-api bridge", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("OPS_HOME", tmpDir) - t.Setenv("KEYCLOAK_ACCESS_TOKEN", "oidc-token") - t.Setenv("OPS_DEV_ALLOW_OIDC_TOKEN_ENV", "true") - t.Setenv("OPS_PASSWORD", "") - t.Setenv("OPS_USER", "") - t.Setenv("OPS_APIHOST", "") - - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/system/api/v1/auth/oidc", r.URL.Path) - require.Equal(t, "Bearer oidc-token", r.Header.Get("Authorization")) - - var requestBody map[string]string - require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) - require.Equal(t, "oidc-token", requestBody["access_token"]) - - _, _ = w.Write([]byte(`{"AUTH":"oidc-auth","NAMESPACE":"developerlab8e8a9915"}`)) - })) - defer mockServer.Close() - - os.Args = []string{"login", mockServer.URL} - loginResult, err := LoginCmd() - require.NoError(t, err) - require.NotNil(t, loginResult) - require.Equal(t, "developerlab8e8a9915", loginResult.Login) - require.Equal(t, "oidc-auth", loginResult.Auth) - - configMap, err := config.NewConfigMapBuilder(). - WithConfigJson(filepath.Join(tmpDir, "config.json")). - Build() - require.NoError(t, err) - - v, err := configMap.Get("STATUS_LOGGED_USER") - require.NoError(t, err) - require.Equal(t, "developerlab8e8a9915", v) - }) - - t.Run("rejects OIDC access token from env without explicit dev opt-in", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("OPS_HOME", tmpDir) - t.Setenv("KEYCLOAK_ACCESS_TOKEN", "oidc-token") - t.Setenv("OPS_DEV_ALLOW_OIDC_TOKEN_ENV", "") - t.Setenv("OPS_PASSWORD", "") - t.Setenv("OPS_USER", "") - t.Setenv("OPS_APIHOST", "") - - os.Args = []string{"login", "http://localhost:5000"} - loginResult, err := LoginCmd() - require.Error(t, err) - require.Nil(t, loginResult) - require.Contains(t, err.Error(), "OIDC token login via environment is disabled") - }) - t.Run("SSO enabled starts OIDC device flow", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("OPS_HOME", tmpDir) From bf09630ce14d9b1f1d13bc2a0e20ea26b06ec931 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Wed, 1 Jul 2026 16:55:33 +0000 Subject: [PATCH 3/3] Avoid OIDC token env test naming --- main_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main_test.go b/main_test.go index 32c7a64..22a8af6 100644 --- a/main_test.go +++ b/main_test.go @@ -165,7 +165,7 @@ func TestConfigValueForDebugRedactsSensitiveValues(t *testing.T) { require.Equal(t, "", configValueForDebug("AUTH", "uuid:key")) require.Equal(t, "", configValueForDebug("POSTGRES_PASSWORD", "secret")) require.Equal(t, "", configValueForDebug("S3_ACCESS_KEY", "access")) - require.Equal(t, "", configValueForDebug("OIDC_ACCESS_TOKEN", "token")) + require.Equal(t, "", configValueForDebug("SERVICE_TOKEN", "token")) require.Equal(t, "http://localhost:8080", configValueForDebug("OIDC_ISSUER_URL", "http://localhost:8080")) require.Equal(t, "", configValueForDebug("AUTH", "")) }