diff --git a/cmd/harbor/root/login.go b/cmd/harbor/root/login.go index e79c77561..a21a074ea 100644 --- a/cmd/harbor/root/login.go +++ b/cmd/harbor/root/login.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/goharbor/go-client/pkg/harbor" "github.com/goharbor/go-client/pkg/sdk/v2.0/client" @@ -38,6 +39,7 @@ var ( Name string passwordStdin bool skipVerifyClient bool + oidcLogin bool ) // LoginCommand creates a new `harbor login` command @@ -52,6 +54,10 @@ func LoginCommand() *cobra.Command { serverAddress = args[0] } + if oidcLogin { + return RunOIDCLogin(serverAddress) + } + if passwordStdin { fmt.Print("Password: ") passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) // #nosec G115 - fd fits in int on all supported platforms @@ -87,9 +93,13 @@ func LoginCommand() *cobra.Command { flags.StringVarP(&Name, "context-name", "n", "", "Login context name (optional)") flags.StringVarP(&Password, "password", "p", "", "Password (not recommended, use --password-stdin for better security)") flags.BoolVar(&passwordStdin, "password-stdin", false, "Take the password from stdin") + flags.BoolVar(&oidcLogin, "oidc", false, "Authenticate using Harbor OIDC browser login") flags.BoolVarP(&skipVerifyClient, "skip-verify-client", "", false, "Skip whether the clients basic auth credentials shall be validated against the Harbor server during login. This is not recommended as it may lead to storing invalid credentials. Use this flag if you want to skip validation of credentials during login, for example, when the Harbor server is not reachable at the moment of login but you still want to store the credentials for later use.") cmd.MarkFlagsMutuallyExclusive("password", "password-stdin") + cmd.MarkFlagsMutuallyExclusive("oidc", "username") + cmd.MarkFlagsMutuallyExclusive("oidc", "password") + cmd.MarkFlagsMutuallyExclusive("oidc", "password-stdin") return cmd } @@ -208,6 +218,41 @@ func RunLogin(opts login.LoginView) error { return nil } +func RunOIDCLogin(serverAddress string) error { + if serverAddress == "" { + return fmt.Errorf("server address is required for OIDC login") + } + serverAddress = utils.FormatUrl(serverAddress) + if err := utils.ValidateURL(serverAddress); err != nil { + return fmt.Errorf("invalid server URL: %w", err) + } + + loginResp, err := utils.InitiateOIDCLogin(serverAddress) + if err != nil { + return err + } + + fmt.Printf("Open this URL in your browser to authenticate:\n\n %s\n\n", loginResp.RedirectURL) + fmt.Print("Waiting for authentication...\n") + + tokenResp, err := utils.PollForOIDCToken(serverAddress, loginResp.State, 10*time.Minute) + if err != nil { + return err + } + + harborData, err := utils.GetCurrentHarborData() + if err != nil { + return fmt.Errorf("failed to get current harbor data: %s", err) + } + + if err := utils.AddOIDCCredentials(serverAddress, tokenResp.Username, tokenResp.IDToken, tokenResp.RefreshToken, tokenResp.ExpiresAt, harborData.ConfigPath); err != nil { + return fmt.Errorf("failed to store OIDC credential: %w", err) + } + + fmt.Printf("Login successful for %s at %s\n", tokenResp.Username, serverAddress) + return nil +} + func validateClientConnection(client *client.HarborAPI) error { ctx := context.Background() diff --git a/cmd/harbor/root/login_test.go b/cmd/harbor/root/login_test.go index 22ae6d1ec..91b6ed74f 100644 --- a/cmd/harbor/root/login_test.go +++ b/cmd/harbor/root/login_test.go @@ -14,10 +14,14 @@ package root_test import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/goharbor/harbor-cli/cmd/harbor/root" helpers "github.com/goharbor/harbor-cli/test/helper" + "github.com/goharbor/harbor-cli/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -122,3 +126,58 @@ func Test_Login_Failure_MutuallyExclusiveFlags(t *testing.T) { err := cmd.Execute() assert.Error(t, err, "Expected error when both --password and --password-stdin are set") } + +func Test_Login_Failure_OIDCMutuallyExclusiveFlags(t *testing.T) { + tempDir := t.TempDir() + data := helpers.Initialize(t, tempDir) + defer helpers.ConfigCleanup(t, data) + + cmd := root.LoginCommand() + cmd.SetArgs([]string{"http://demo.goharbor.io"}) + + assert.NoError(t, cmd.Flags().Set("oidc", "true")) + assert.NoError(t, cmd.Flags().Set("username", "admin")) + + err := cmd.Execute() + assert.Error(t, err, "Expected error when --oidc and --username are set") +} + +func Test_RunOIDCLogin_Failure_MissingServer(t *testing.T) { + err := root.RunOIDCLogin("") + assert.Error(t, err) +} + +func Test_RunOIDCLogin_Success(t *testing.T) { + tempDir := t.TempDir() + helpers.SetMockKeyring(t) + data := helpers.Initialize(t, tempDir) + defer helpers.ConfigCleanup(t, data) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/c/oidc/login": + assert.Equal(t, "cli", r.URL.Query().Get("mode")) + assert.NoError(t, json.NewEncoder(w).Encode(utils.OIDCLoginResponse{ + RedirectURL: "https://idp.example/authorize", + State: "state-1", + })) + case "/c/oidc/cli-token": + assert.Equal(t, "state-1", r.URL.Query().Get("state")) + assert.NoError(t, json.NewEncoder(w).Encode(utils.OIDCPollResponse{ + Status: "ready", + IDToken: "id-token", + Username: "alice", + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + err := root.RunOIDCLogin(server.URL) + assert.NoError(t, err) + + cred, err := utils.GetCredentials(utils.DefaultCredentialName("alice", server.URL)) + assert.NoError(t, err) + assert.Equal(t, utils.AuthTypeOIDC, cred.AuthType) +} diff --git a/pkg/utils/client.go b/pkg/utils/client.go index 8929661ca..4720dd5af 100644 --- a/pkg/utils/client.go +++ b/pkg/utils/client.go @@ -16,8 +16,12 @@ package utils import ( "context" "fmt" + "net/url" "sync" + "time" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" "github.com/goharbor/go-client/pkg/harbor" v2client "github.com/goharbor/go-client/pkg/sdk/v2.0/client" log "github.com/sirupsen/logrus" @@ -75,6 +79,9 @@ func GetClientByCredentialName(credentialName string) (*v2client.HarborAPI, erro if err != nil { return nil, fmt.Errorf("failed to get credential %s: %w", credentialName, err) } + if credential.AuthType == AuthTypeOIDC { + return getOIDCClient(credential) + } // Get encryption key key, err := GetEncryptionKey() @@ -95,3 +102,35 @@ func GetClientByCredentialName(credentialName string) (*v2client.HarborAPI, erro } return GetClientByConfig(clientConfig), nil } + +func getOIDCClient(credential Credential) (*v2client.HarborAPI, error) { + idToken, err := GetDecryptedIDToken(credential.Name) + if err != nil { + return nil, err + } + + if credential.ExpiresAt > 0 && time.Now().Unix() >= credential.ExpiresAt-60 { + return nil, fmt.Errorf("OIDC session expired or is about to expire. Please run `harbor login %s --oidc` again", credential.ServerAddress) + } + + return buildClientWithToken(credential.ServerAddress, idToken) +} + +func buildClientWithToken(serverAddress, idToken string) (*v2client.HarborAPI, error) { + u, err := url.Parse(serverAddress) + if err != nil { + return nil, fmt.Errorf("failed to parse server URL: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("invalid server URL: %s", serverAddress) + } + + cfg := &harbor.Config{ + URL: u, + AuthInfo: runtime.ClientAuthInfoWriterFunc(func(req runtime.ClientRequest, _ strfmt.Registry) error { + return req.SetHeaderParam("Authorization", "Bearer "+idToken) + }), + } + + return v2client.New(cfg.ToV2Config()), nil +} diff --git a/pkg/utils/config.go b/pkg/utils/config.go index cecc7628e..2b3f1248f 100644 --- a/pkg/utils/config.go +++ b/pkg/utils/config.go @@ -27,12 +27,21 @@ import ( ) type Credential struct { - Name string `yaml:"name"` - Username string `yaml:"username"` - Password string `yaml:"password"` - ServerAddress string `yaml:"serveraddress"` + Name string `mapstructure:"name" yaml:"name"` + Username string `mapstructure:"username" yaml:"username"` + Password string `mapstructure:"password,omitempty" yaml:"password,omitempty"` + ServerAddress string `mapstructure:"serveraddress" yaml:"serveraddress"` + AuthType string `mapstructure:"auth-type,omitempty" yaml:"auth-type,omitempty"` + IDToken string `mapstructure:"id-token,omitempty" yaml:"id-token,omitempty"` + RefreshToken string `mapstructure:"refresh-token,omitempty" yaml:"refresh-token,omitempty"` + ExpiresAt int64 `mapstructure:"expires-at,omitempty" yaml:"expires-at,omitempty"` } +const ( + AuthTypeBasic = "basic" + AuthTypeOIDC = "oidc" +) + type HarborConfig struct { CurrentCredentialName string `mapstructure:"current-credential-name" yaml:"current-credential-name"` Credentials []Credential `mapstructure:"credentials" yaml:"credentials"` @@ -505,6 +514,7 @@ func AddCredentialsToConfigFile(credential Credential, configPath string) error log.Fatalf("failed to write updated config file: %v", err) } + CurrentHarborConfig = &c fmt.Printf("Added credential '%s' to config file at %s\n", credential.Name, configPath) return nil } @@ -550,7 +560,91 @@ func UpdateCredentialsInConfigFile(updatedCredential Credential, configPath stri log.Fatalf("failed to write updated config file: %v", err) } + CurrentHarborConfig = &c fmt.Printf("Updated credential '%s' in config file at %s.\n", updatedCredential.Name, configPath) fmt.Printf("Switched to context '%s'\n", updatedCredential.Name) return nil } + +func AddOIDCCredentials(serverAddress, username, idToken, refreshToken string, expiresAt int64, configPath string) error { + if err := GenerateEncryptionKey(); err != nil { + return fmt.Errorf("failed to generate encryption key: %w", err) + } + key, err := GetEncryptionKey() + if err != nil { + return fmt.Errorf("failed to get encryption key: %w", err) + } + + encryptedIDToken, err := Encrypt(key, []byte(idToken)) + if err != nil { + return fmt.Errorf("failed to encrypt id token: %w", err) + } + + var encryptedRefreshToken string + if refreshToken != "" { + encryptedRefreshToken, err = Encrypt(key, []byte(refreshToken)) + if err != nil { + return fmt.Errorf("failed to encrypt refresh token: %w", err) + } + } + + credential := Credential{ + Name: DefaultCredentialName(username, serverAddress), + Username: username, + ServerAddress: serverAddress, + AuthType: AuthTypeOIDC, + IDToken: encryptedIDToken, + RefreshToken: encryptedRefreshToken, + ExpiresAt: expiresAt, + } + + if _, err := GetCredentials(credential.Name); err == nil { + return UpdateCredentialsInConfigFile(credential, configPath) + } + return AddCredentialsToConfigFile(credential, configPath) +} + +func GetDecryptedIDToken(credentialName string) (string, error) { + credential, err := GetCredentials(credentialName) + if err != nil { + return "", err + } + if credential.AuthType != AuthTypeOIDC { + return "", fmt.Errorf("credential %q is not an OIDC credential", credentialName) + } + key, err := GetEncryptionKey() + if err != nil { + return "", fmt.Errorf("failed to get encryption key: %w", err) + } + return Decrypt(key, credential.IDToken) +} + +func UpdateOIDCTokens(credentialName, idToken, refreshToken string, expiresAt int64, configPath string) error { + credential, err := GetCredentials(credentialName) + if err != nil { + return err + } + if credential.AuthType != AuthTypeOIDC { + return fmt.Errorf("credential %q is not an OIDC credential", credentialName) + } + key, err := GetEncryptionKey() + if err != nil { + return fmt.Errorf("failed to get encryption key: %w", err) + } + encryptedIDToken, err := Encrypt(key, []byte(idToken)) + if err != nil { + return fmt.Errorf("failed to encrypt id token: %w", err) + } + credential.IDToken = encryptedIDToken + credential.ExpiresAt = expiresAt + + if refreshToken != "" { + encryptedRefreshToken, err := Encrypt(key, []byte(refreshToken)) + if err != nil { + return fmt.Errorf("failed to encrypt refresh token: %w", err) + } + credential.RefreshToken = encryptedRefreshToken + } + + return UpdateCredentialsInConfigFile(credential, configPath) +} diff --git a/pkg/utils/config_test.go b/pkg/utils/config_test.go index 11fba03f3..d84bbbfed 100644 --- a/pkg/utils/config_test.go +++ b/pkg/utils/config_test.go @@ -107,3 +107,27 @@ func Test_Config_Flag(t *testing.T) { assert.NotNil(t, currentConfig.Credentials, "Credentials should not be nil") assert.NotNil(t, data.ConfigPath, "ConfigPath should not be nil") } + +func Test_AddOIDCCredentials(t *testing.T) { + tempDir := t.TempDir() + helpers.SetMockKeyring(t) + data := helpers.Initialize(t, tempDir) + defer helpers.ConfigCleanup(t, data) + + err := utils.AddOIDCCredentials("https://demo.goharbor.io", "alice", "id-token", "refresh-token", 12345, data.ConfigPath) + assert.NoError(t, err) + + cred, err := utils.GetCredentials("alice@https-demo-goharbor-io") + assert.NoError(t, err) + assert.Equal(t, utils.AuthTypeOIDC, cred.AuthType) + assert.Equal(t, "alice", cred.Username) + assert.Equal(t, "https://demo.goharbor.io", cred.ServerAddress) + assert.Equal(t, int64(12345), cred.ExpiresAt) + assert.NotEmpty(t, cred.IDToken) + assert.NotEmpty(t, cred.RefreshToken) + assert.Empty(t, cred.Password) + + idToken, err := utils.GetDecryptedIDToken(cred.Name) + assert.NoError(t, err) + assert.Equal(t, "id-token", idToken) +} diff --git a/pkg/utils/oidc.go b/pkg/utils/oidc.go new file mode 100644 index 000000000..1a152b392 --- /dev/null +++ b/pkg/utils/oidc.go @@ -0,0 +1,176 @@ +// Copyright Project Harbor Authors +// +// Licensed 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 utils + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + oidcCLILoginPath = "/c/oidc/login" + oidcCLITokenPath = "/c/oidc/cli-token" +) + +type OIDCLoginResponse struct { + RedirectURL string `json:"redirect_url"` + State string `json:"state"` +} + +type OIDCPollResponse struct { + Status string `json:"status"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Username string `json:"username,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` +} + +func InitiateOIDCLogin(serverAddress string) (*OIDCLoginResponse, error) { + serverAddress = FormatUrl(serverAddress) + if err := ValidateURL(serverAddress); err != nil { + return nil, fmt.Errorf("invalid server URL: %w", err) + } + + endpoint, err := joinServerPath(serverAddress, oidcCLILoginPath) + if err != nil { + return nil, err + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to parse OIDC login endpoint: %w", err) + } + q := u.Query() + q.Set("mode", "cli") + u.RawQuery = q.Encode() + + resp, err := http.Get(u.String()) //nolint:gosec // endpoint is user-provided Harbor server URL for login. + if err != nil { + return nil, fmt.Errorf("failed to initiate OIDC login: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("failed to initiate OIDC login: status %d: %s", resp.StatusCode, string(body)) + } + + var loginResp OIDCLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, fmt.Errorf("failed to decode OIDC login response: %w", err) + } + if loginResp.RedirectURL == "" || loginResp.State == "" { + return nil, fmt.Errorf("invalid OIDC login response: missing redirect_url or state") + } + return &loginResp, nil +} + +func PollForOIDCToken(serverAddress, state string, timeout time.Duration) (*OIDCPollResponse, error) { + if state == "" { + return nil, fmt.Errorf("state is required") + } + serverAddress = FormatUrl(serverAddress) + if err := ValidateURL(serverAddress); err != nil { + return nil, fmt.Errorf("invalid server URL: %w", err) + } + + endpoint, err := joinServerPath(serverAddress, oidcCLITokenPath) + if err != nil { + return nil, err + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to parse OIDC token endpoint: %w", err) + } + q := u.Query() + q.Set("state", state) + u.RawQuery = q.Encode() + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + result, ready, err := pollOIDCTokenOnce(u.String()) + if err != nil { + return nil, err + } + if ready { + return result, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timed out waiting for OIDC authentication") + } + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, fmt.Errorf("timed out waiting for OIDC authentication") + } + select { + case <-ticker.C: + case <-time.After(remaining): + return nil, fmt.Errorf("timed out waiting for OIDC authentication") + } + } +} + +func pollOIDCTokenOnce(endpoint string) (*OIDCPollResponse, bool, error) { + resp, err := http.Get(endpoint) //nolint:gosec // endpoint is the Harbor server URL validated by PollForOIDCToken. + if err != nil { + return nil, false, fmt.Errorf("failed to poll OIDC token: %w", err) + } + defer resp.Body.Close() + + var pollResp OIDCPollResponse + switch resp.StatusCode { + case http.StatusAccepted: + return &OIDCPollResponse{Status: "pending"}, false, nil + case http.StatusOK: + if err := json.NewDecoder(resp.Body).Decode(&pollResp); err != nil { + return nil, false, fmt.Errorf("failed to decode OIDC token response: %w", err) + } + if pollResp.Status != "ready" { + return nil, false, fmt.Errorf("unexpected OIDC token status: %s", pollResp.Status) + } + if pollResp.IDToken == "" || pollResp.Username == "" { + return nil, false, fmt.Errorf("invalid OIDC token response: missing id_token or username") + } + return &pollResp, true, nil + case http.StatusBadRequest: + if err := json.NewDecoder(resp.Body).Decode(&pollResp); err != nil { + return nil, false, fmt.Errorf("OIDC authentication failed") + } + if pollResp.Error != "" { + return nil, false, fmt.Errorf("OIDC authentication failed: %s", pollResp.Error) + } + return nil, false, fmt.Errorf("OIDC authentication failed") + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, false, fmt.Errorf("failed to poll OIDC token: status %d: %s", resp.StatusCode, string(body)) + } +} + +func joinServerPath(serverAddress, path string) (string, error) { + u, err := url.Parse(serverAddress) + if err != nil { + return "", fmt.Errorf("failed to parse server URL: %w", err) + } + u.Path = path + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} diff --git a/pkg/utils/oidc_test.go b/pkg/utils/oidc_test.go new file mode 100644 index 000000000..4508738e7 --- /dev/null +++ b/pkg/utils/oidc_test.go @@ -0,0 +1,80 @@ +// Copyright Project Harbor Authors +// +// Licensed 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 utils_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitiateOIDCLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/c/oidc/login", r.URL.Path) + assert.Equal(t, "cli", r.URL.Query().Get("mode")) + _ = json.NewEncoder(w).Encode(utils.OIDCLoginResponse{ + RedirectURL: "https://idp.example/authorize", + State: "state-1", + }) + })) + defer server.Close() + + resp, err := utils.InitiateOIDCLogin(server.URL) + + require.NoError(t, err) + assert.Equal(t, "https://idp.example/authorize", resp.RedirectURL) + assert.Equal(t, "state-1", resp.State) +} + +func TestPollForOIDCTokenReady(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/c/oidc/cli-token", r.URL.Path) + assert.Equal(t, "state-1", r.URL.Query().Get("state")) + _ = json.NewEncoder(w).Encode(utils.OIDCPollResponse{ + Status: "ready", + IDToken: "id-token", + Username: "alice", + }) + })) + defer server.Close() + + resp, err := utils.PollForOIDCToken(server.URL, "state-1", time.Second) + + require.NoError(t, err) + assert.Equal(t, "ready", resp.Status) + assert.Equal(t, "id-token", resp.IDToken) + assert.Equal(t, "alice", resp.Username) +} + +func TestPollForOIDCTokenFailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(utils.OIDCPollResponse{ + Status: "failed", + Error: "state expired", + }) + })) + defer server.Close() + + resp, err := utils.PollForOIDCToken(server.URL, "state-1", time.Second) + + assert.Nil(t, resp) + assert.ErrorContains(t, err, "state expired") +}