Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions cmd/harbor/root/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -38,6 +39,7 @@ var (
Name string
passwordStdin bool
skipVerifyClient bool
oidcLogin bool
)

// LoginCommand creates a new `harbor login` command
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()

Expand Down
59 changes: 59 additions & 0 deletions cmd/harbor/root/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
39 changes: 39 additions & 0 deletions pkg/utils/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
102 changes: 98 additions & 4 deletions pkg/utils/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
24 changes: 24 additions & 0 deletions pkg/utils/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading