Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e5c2d8a
basic implementation
NishchayRajput Jun 14, 2026
261a1bd
moved to state based login with existing endopoint
NishchayRajput Jun 15, 2026
2423ed7
fix client error
NishchayRajput Jun 20, 2026
6032e10
basic refresh token support
NishchayRajput Jul 3, 2026
1aa8534
basic implementation
NishchayRajput Jun 14, 2026
96eff11
moved to state based login with existing endopoint
NishchayRajput Jun 15, 2026
2fafc55
added polling-token support
NishchayRajput Jul 4, 2026
a469bf0
added test
NishchayRajput Jul 4, 2026
7d57d58
fix: lint and genrate docs
NishchayRajput Jul 4, 2026
54fe507
basic implementation
NishchayRajput Jun 14, 2026
6d56600
moved to state based login with existing endopoint
NishchayRajput Jun 15, 2026
e7541a0
fix client error
NishchayRajput Jun 20, 2026
d7259e8
basic refresh token support
NishchayRajput Jul 3, 2026
d94bf4f
basic implementation
NishchayRajput Jun 14, 2026
9470b1c
moved to state based login with existing endopoint
NishchayRajput Jun 15, 2026
6f59953
added polling-token support
NishchayRajput Jul 4, 2026
a0fa532
added test
NishchayRajput Jul 4, 2026
1a370a5
fix: lint and genrate docs
NishchayRajput Jul 4, 2026
6ef5a88
Merge branch 'feat/implement-oidc-flow' of github.com:NishchayRajput/…
NishchayRajput Jul 4, 2026
ebb3f9d
fix: reviewed changes
NishchayRajput Jul 9, 2026
b50513f
fix: reviewed changes, added test and debug log
NishchayRajput Jul 20, 2026
3aec5de
fix: reviewed changes added logs for verbose mode and check agains in…
NishchayRajput Aug 6, 2026
b0728ac
fix: lint
NishchayRajput Aug 6, 2026
6c87765
fix: tests
NishchayRajput Aug 6, 2026
bc6600c
fix
NishchayRajput Aug 6, 2026
8e59df4
fix lint
NishchayRajput Aug 6, 2026
090f676
Merge branch 'main' into feat/implement-oidc-flow
bupd Aug 7, 2026
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
216 changes: 215 additions & 1 deletion cmd/harbor/root/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ package root

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/goharbor/go-client/pkg/harbor"
"github.com/goharbor/go-client/pkg/sdk/v2.0/client"
Expand All @@ -38,10 +43,33 @@ var (
Name string
passwordStdin bool
skipVerifyClient bool
authMode string
)

const (
cliAuthModeDB = "db"
cliAuthModeLDAP = "ldap"
cliAuthModeOIDC = "oidc"

harborAuthModeDB = "db_auth"
harborAuthModeLDAP = "ldap_auth"
harborAuthModeOIDC = "oidc_auth"
)

func resetLoginOptions() {
serverAddress = ""
Username = ""
Password = ""
Name = ""
passwordStdin = false
skipVerifyClient = false
authMode = ""
}

// LoginCommand creates a new `harbor login` command
func LoginCommand() *cobra.Command {
resetLoginOptions()

cmd := &cobra.Command{
Use: "login [server]",
Short: "Log in to Harbor registry",
Expand Down Expand Up @@ -69,7 +97,14 @@ func LoginCommand() *cobra.Command {
Name: Name,
}

var err error
effectiveMode, err := resolveLoginAuthMode(loginView)
if err != nil {
return err
}
if effectiveMode == cliAuthModeOIDC {
return RunOIDCLogin(serverAddress)
}

var config *utils.HarborConfig
config, err = utils.GetCurrentHarborConfig()
if err != nil {
Expand All @@ -87,13 +122,154 @@ 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.StringVar(&authMode, "auth-mode", "", "Authentication mode (db, ldap, oidc)")
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")

return cmd
}

func resolveLoginAuthMode(loginView login.LoginView) (string, error) {
requestedMode, err := normalizeCLIAuthMode(authMode)
if err != nil {
return "", err
}
log.Debugf("resolving login auth mode for server=%q requested_mode=%q username_provided=%t password_provided=%t", loginView.Server, requestedMode, loginView.Username != "", loginView.Password != "")

if requestedMode == "" {
if loginView.Server == "" {
return "", nil
}
if loginView.Username != "" || loginView.Password != "" {
return "", nil
}

harborMode, err := getHarborAuthMode(loginView.Server)
if err != nil {
return "", fmt.Errorf("unable to determine Harbor auth_mode from /api/v2.0/systeminfo. Please retry with --auth-mode db, --auth-mode ldap, or --auth-mode oidc: %w", err)
}
log.Debugf("auto-detected Harbor auth_mode=%q for server=%q", harborMode, loginView.Server)
switch harborMode {
case harborAuthModeDB:
log.Debug("selected CLI auth mode db from Harbor auth_mode db_auth")
return cliAuthModeDB, nil
case harborAuthModeLDAP:
log.Debug("selected CLI auth mode ldap from Harbor auth_mode ldap_auth")
return cliAuthModeLDAP, nil
case harborAuthModeOIDC:
log.Debug("selected CLI auth mode oidc from Harbor auth_mode oidc_auth")
return cliAuthModeOIDC, nil
default:
return "", fmt.Errorf("unsupported Harbor auth_mode %q returned by /api/v2.0/systeminfo", harborMode)
}
}

if loginView.Server == "" {
return "", fmt.Errorf("server address is required when --auth-mode is set")
}

if requestedMode == cliAuthModeOIDC && (loginView.Username != "" || loginView.Password != "") {
return "", fmt.Errorf("--auth-mode oidc cannot be used with --username, --password, or --password-stdin")
}

harborMode, err := getHarborAuthMode(loginView.Server)
if err != nil {
return "", fmt.Errorf("failed to determine Harbor auth_mode for --auth-mode %s: %w", requestedMode, err)
}
if err := validateAuthModeCombination(requestedMode, harborMode); err != nil {
return "", err
}
log.Debugf("validated explicit CLI auth mode %q against Harbor auth_mode %q", requestedMode, harborMode)

return requestedMode, nil
}

func normalizeCLIAuthMode(mode string) (string, error) {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "":
return "", nil
case cliAuthModeDB, cliAuthModeLDAP, cliAuthModeOIDC:
return mode, nil
default:
return "", fmt.Errorf("invalid auth mode %q. Valid values are: db, ldap, oidc", mode)
}
}

func validateAuthModeCombination(requestedMode, harborMode string) error {
switch requestedMode {
case cliAuthModeOIDC:
if harborMode != harborAuthModeOIDC {
return fmt.Errorf("OIDC login is not available because Harbor auth_mode is %s", harborMode)
}
case cliAuthModeLDAP:
if harborMode != harborAuthModeLDAP {
return fmt.Errorf("LDAP login is not available because Harbor auth_mode is %s", harborMode)
}
case cliAuthModeDB:
if harborMode == harborAuthModeLDAP {
return fmt.Errorf("DB login is not available because Harbor auth_mode is %s", harborMode)
}
default:
return fmt.Errorf("unsupported auth mode %q", requestedMode)
}
return nil
}

func getHarborAuthMode(server string) (string, error) {
server = utils.FormatUrl(server)
if err := utils.ValidateURL(server); err != nil {
return "", fmt.Errorf("invalid server URL: %w", err)
}

endpoint, err := joinServerPath(server, "/api/v2.0/systeminfo")
if err != nil {
return "", err
}
log.Debugf("querying Harbor system info at %s", endpoint)

resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(endpoint) //nolint:gosec // endpoint is user-provided Harbor server URL.
if err != nil {
return "", fmt.Errorf("failed to query Harbor system info: %w", err)
}
defer resp.Body.Close()
log.Debugf("received Harbor system info response status %d", resp.StatusCode)

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("unexpected status %d from /api/v2.0/systeminfo: %s", resp.StatusCode, string(body))
}

var payload struct {
AuthMode string `json:"auth_mode"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("failed to decode Harbor system info response: %w", err)
}
if payload.AuthMode == "" {
return "", fmt.Errorf("Harbor system info response is missing auth_mode")
}
log.Debugf("Harbor system info reported auth_mode=%q", payload.AuthMode)

return payload.AuthMode, nil
}

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)
}
basePath := u.Path
for len(basePath) > 0 && basePath[len(basePath)-1] == '/' {
basePath = basePath[:len(basePath)-1]
}
u.Path = basePath + path
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}

// ProcessLogin applies a simplified decision logic to run login or launch an interactive view.
func ProcessLogin(loginView login.LoginView, config *utils.HarborConfig) error {
// Auto-generate the name if not provided.
Expand Down Expand Up @@ -208,6 +384,44 @@ 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)
}
log.Debugf("starting Harbor CLI OIDC login for server %s", serverAddress)

loginResp, err := utils.InitiateOIDCLogin(serverAddress)
if err != nil {
return err
}
log.Debug("received Harbor CLI OIDC login redirect URL and poll token")

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.PollToken, 10*time.Minute)
if err != nil {
return err
}
log.Debugf("Harbor CLI OIDC login completed for user %s", tokenResp.Username)

harborData, err := utils.GetCurrentHarborData()
if err != nil {
return fmt.Errorf("failed to get current harbor data: %w", 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
Loading