diff --git a/cmd/caib/authcmd/authcmd.go b/cmd/caib/authcmd/authcmd.go index ec183744..21c1bef8 100644 --- a/cmd/caib/authcmd/authcmd.go +++ b/cmd/caib/authcmd/authcmd.go @@ -24,7 +24,7 @@ var ( func NewAuthCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "Manage authentication tokens", + Short: "Authentication and token management commands", Long: `Commands for inspecting and refreshing OIDC authentication tokens.`, } @@ -129,7 +129,7 @@ func runRefresh(cmd *cobra.Command, _ []string) { serverURL := config.DefaultServerWithDerive() if serverURL == "" { fmt.Println(color.RedString("No server configured.")) - fmt.Println("Run 'caib login ' or 'jmp login ' first.") + fmt.Println("Run 'caib login ' first.") return } diff --git a/cmd/caib/authcmd/authcmd_test.go b/cmd/caib/authcmd/authcmd_test.go index f72d735b..645087dc 100644 --- a/cmd/caib/authcmd/authcmd_test.go +++ b/cmd/caib/authcmd/authcmd_test.go @@ -73,4 +73,9 @@ var _ = Describe("NewAuthCmd", func() { } Fail("status subcommand not found") }) + + It("should use description aligned with jmp auth", func() { + cmd := NewAuthCmd() + Expect(cmd.Short).To(Equal("Authentication and token management commands")) + }) }) diff --git a/cmd/caib/common/api_client.go b/cmd/caib/common/api_client.go index 3f362456..fc72f41b 100644 --- a/cmd/caib/common/api_client.go +++ b/cmd/caib/common/api_client.go @@ -10,6 +10,7 @@ import ( "time" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/auth" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" "k8s.io/client-go/tools/clientcmd" ) @@ -20,19 +21,39 @@ func CreateBuildAPIClient(serverURL string, authToken *string, insecureSkipTLS b tokenValue := "" if authToken != nil { - tokenValue = strings.TrimSpace(*authToken) + tokenValue = sanitizeToken(*authToken) } setToken := func(token string) { - tokenValue = strings.TrimSpace(token) + tokenValue = sanitizeToken(token) if authToken != nil { *authToken = tokenValue } } - envToken := strings.TrimSpace(os.Getenv("CAIB_TOKEN")) - explicitToken := tokenValue != "" || envToken != "" + // Token resolution order: + // 1. --token flag (tokenValue already set by caller) + // 2. saved_token from cli.json (set by caib login --token) + // 3. CAIB_TOKEN env var + // 4. OIDC cached / browser flow + // 5. kubeconfig / oc whoami -t fallback + envToken := sanitizeToken(os.Getenv("CAIB_TOKEN")) + // Many commands bind --token with a default value from CAIB_TOKEN. + // If the pointer value exactly matches the env var, treat it as implicit + // and allow saved_token to take precedence for "subsequent commands" + // after `caib login --token`. + if tokenValue != "" && envToken != "" && tokenValue == envToken { + tokenValue = "" + } + if tokenValue == "" { + if saved := config.LoadSavedToken(); saved != "" { + setToken(saved) + } + } + if tokenValue == "" && envToken != "" { + setToken(envToken) + } - if !explicitToken { + if tokenValue == "" { token, didAuth, err := auth.GetTokenWithReauth(ctx, serverURL, "", insecureSkipTLS) if err != nil { fmt.Fprintf(os.Stderr, "Error: OIDC authentication failed: %v\n", err) @@ -50,12 +71,6 @@ func CreateBuildAPIClient(serverURL string, authToken *string, insecureSkipTLS b } else if tok, loadErr := LoadTokenFromKubeconfig(); loadErr == nil && strings.TrimSpace(tok) != "" { setToken(tok) } - } else if tokenValue == "" { - if envToken != "" { - setToken(envToken) - } else if tok, loadErr := LoadTokenFromKubeconfig(); loadErr == nil && strings.TrimSpace(tok) != "" { - setToken(tok) - } } var opts []buildapiclient.Option @@ -85,22 +100,28 @@ func ExecuteWithReauth( ctx := context.Background() currentToken := "" if authToken != nil { - currentToken = strings.TrimSpace(*authToken) + currentToken = sanitizeToken(*authToken) } setToken := func(token string) { - currentToken = strings.TrimSpace(token) + currentToken = sanitizeToken(token) if authToken != nil { *authToken = currentToken } } + // Determine whether the token is explicitly user-provided before the first + // call resolves it. CreateBuildAPIClient can fill authToken from kubeconfig, + // so we must capture this upfront rather than checking currentToken after. + savedToken := config.LoadSavedToken() + isExplicitToken := currentToken != "" || savedToken != "" + runWithFreshClient := func() error { client, err := CreateBuildAPIClient(serverURL, authToken, insecureSkipTLS) if err != nil { return err } if authToken != nil { - currentToken = strings.TrimSpace(*authToken) + currentToken = sanitizeToken(*authToken) } return fn(client) } @@ -113,6 +134,26 @@ func ExecuteWithReauth( return err } + // If the token was explicitly provided by the user (via caib login --token or + // the --token flag) and the server rejected it, give a clear actionable error + // rather than silently falling through to OIDC with a different identity. + if isExplicitToken { + if savedToken != "" { + return fmt.Errorf( + "saved token was rejected by the server (expired or invalid)\n"+ + "Refresh it with:\n"+ + " oc login # re-authenticate with your cluster\n"+ + " caib login --token $(oc whoami -t) %s", serverURL, + ) + } + return fmt.Errorf( + "provided token was rejected by the server (401)\n"+ + "Verify the token is valid, or re-authenticate:\n"+ + " oc login # re-authenticate with your cluster\n"+ + " caib login --token $(oc whoami -t) %s", serverURL, + ) + } + fmt.Fprintln(os.Stderr, "Authentication failed (401), re-authenticating...") newToken, _, err := auth.GetTokenWithReauth(ctx, serverURL, currentToken, insecureSkipTLS) if err != nil { @@ -197,3 +238,18 @@ func LoadTokenFromKubeconfig() (string, error) { } return "", fmt.Errorf("no bearer token found in kubeconfig") } + +// sanitizeToken strips any "Bearer " prefix and surrounding whitespace from a +// token string. Mirrors config.normalizeToken (unexported) — kept here to avoid +// a circular import between the common and config packages. +func sanitizeToken(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + parts := strings.Fields(trimmed) + if len(parts) >= 2 && strings.EqualFold(parts[0], "bearer") { + return strings.TrimSpace(strings.Join(parts[1:], " ")) + } + return trimmed +} diff --git a/cmd/caib/common/api_client_test.go b/cmd/caib/common/api_client_test.go new file mode 100644 index 00000000..aed24e77 --- /dev/null +++ b/cmd/caib/common/api_client_test.go @@ -0,0 +1,141 @@ +package caibcommon + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" +) + +// setupTempConfig redirects config reads/writes to a temp HOME directory. +// Returns a cleanup function. +func setupTempConfig(t *testing.T) func() { + t.Helper() + dir, err := os.MkdirTemp("", "caib-api-client-test-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + origHome := os.Getenv("HOME") + origXDG := os.Getenv("XDG_CONFIG_HOME") + _ = os.Setenv("HOME", dir) + _ = os.Unsetenv("XDG_CONFIG_HOME") + return func() { + _ = os.Setenv("HOME", origHome) + if origXDG != "" { + _ = os.Setenv("XDG_CONFIG_HOME", origXDG) + } else { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } + _ = os.RemoveAll(dir) + } +} + +// always401Handler is a handler that unconditionally returns 401. +var always401Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) +}) + +// authErrorFn is an ExecuteWithReauth callback that always returns a 401 error. +func authErrorFn(_ *buildapiclient.Client) error { + return &fakeAuthError{} +} + +// fakeAuthError satisfies the auth.IsAuthError check (its message contains "401"). +type fakeAuthError struct{} + +func (e *fakeAuthError) Error() string { return "401 Unauthorized" } + +func TestExecuteWithReauth_SavedTokenRejected(t *testing.T) { + cleanup := setupTempConfig(t) + defer cleanup() + + srv := httptest.NewServer(always401Handler) + defer srv.Close() + + if err := config.SaveToken("sha256~fakesavedtoken"); err != nil { + t.Fatalf("SaveToken: %v", err) + } + + // Empty --token flag (zero value) so CreateBuildAPIClient loads the saved token. + tok := "" + err := ExecuteWithReauth(srv.URL, &tok, false, authErrorFn) + + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "saved token was rejected") { + t.Errorf("expected 'saved token was rejected' in error, got: %v", err) + } +} + +func TestExecuteWithReauth_ExplicitTokenRejected(t *testing.T) { + cleanup := setupTempConfig(t) + defer cleanup() + + srv := httptest.NewServer(always401Handler) + defer srv.Close() + + // No saved token — user passed an explicit --token flag value. + tok := "sha256~explicit-flag-token" + err := ExecuteWithReauth(srv.URL, &tok, false, authErrorFn) + + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "provided token was rejected") { + t.Errorf("expected 'provided token was rejected' in error, got: %v", err) + } +} + +func TestExecuteWithReauth_NoTokenTriesOIDCFallback(t *testing.T) { + cleanup := setupTempConfig(t) + defer cleanup() + + // Server returns 401 for API calls and 404 for OIDC config (no OIDC configured). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "authconfig") { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + // No saved token, no explicit token — should attempt OIDC (non-interactively + // since no OIDC config) and return some error rather than panicking or opening + // a browser. + tok := "" + err := ExecuteWithReauth(srv.URL, &tok, false, authErrorFn) + if err == nil { + t.Fatal("expected error when no token and no OIDC available, got nil") + } +} + +func TestSanitizeToken(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"sha256~abc", "sha256~abc"}, + {"Bearer sha256~abc", "sha256~abc"}, + {"bearer sha256~abc", "sha256~abc"}, + {"BEARER sha256~abc", "sha256~abc"}, + {" Bearer sha256~abc ", "sha256~abc"}, + {"eyJhbGciOiJSUzI1NiJ9.e.sig", "eyJhbGciOiJSUzI1NiJ9.e.sig"}, + {"Bearer eyJhbGciOiJSUzI1NiJ9.e.sig", "eyJhbGciOiJSUzI1NiJ9.e.sig"}, + {"", ""}, + {" ", ""}, + // Single word "Bearer" with no token — treated as an opaque token, not a prefix. + {"Bearer", "Bearer"}, + } + for _, tc := range cases { + got := sanitizeToken(tc.input) + if got != tc.want { + t.Errorf("sanitizeToken(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} diff --git a/cmd/caib/config/config.go b/cmd/caib/config/config.go index 2e115229..42de5f67 100644 --- a/cmd/caib/config/config.go +++ b/cmd/caib/config/config.go @@ -28,7 +28,8 @@ var healthHTTPClient *http.Client // CLIConfig holds saved CLI settings. type CLIConfig struct { - ServerURL string `json:"server_url"` + ServerURL string `json:"server_url"` + SavedToken string `json:"saved_token,omitempty"` } // DefaultServer returns the effective default server URL: CAIB_SERVER env, then saved config. @@ -195,8 +196,49 @@ func Read() (*CLIConfig, error) { return &cfg, nil } -// SaveServerURL writes the given server URL to the local config file. +// SaveServerURL writes the given server URL to the local config file, +// preserving any other existing fields (e.g. SavedToken). func SaveServerURL(serverURL string) error { + return updateConfig(func(cfg *CLIConfig) { + cfg.ServerURL = strings.TrimSpace(serverURL) + }) +} + +// SaveToken saves an explicit bearer token to the local config file. +// The token is used as the primary auth credential for all API calls, +// bypassing OIDC. Accepts both opaque tokens (e.g. oc whoami -t) and JWTs. +// Pass an empty string to clear the saved token. +func SaveToken(token string) error { + return updateConfig(func(cfg *CLIConfig) { + cfg.SavedToken = normalizeToken(token) + }) +} + +// LoadSavedToken returns the token saved via SaveToken, or "" if none is set. +func LoadSavedToken() string { + cfg, err := Read() + if err != nil || cfg == nil { + return "" + } + return normalizeToken(cfg.SavedToken) +} + +func normalizeToken(token string) string { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + return "" + } + + parts := strings.Fields(trimmed) + if len(parts) >= 2 && strings.EqualFold(parts[0], "bearer") { + return strings.TrimSpace(strings.Join(parts[1:], " ")) + } + + return trimmed +} + +// updateConfig reads the current config (if any), applies fn, and writes it back. +func updateConfig(fn func(*CLIConfig)) error { path, err := configFilePath() if err != nil { return err @@ -204,7 +246,16 @@ func SaveServerURL(serverURL string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } - cfg := &CLIConfig{ServerURL: strings.TrimSpace(serverURL)} + + cfg := &CLIConfig{} + if data, readErr := os.ReadFile(path); readErr == nil { + if jsonErr := json.Unmarshal(data, cfg); jsonErr != nil { + fmt.Fprintf(os.Stderr, "Warning: caib config file is corrupted and will be reset (%s): %v\n", path, jsonErr) + } + } + + fn(cfg) + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err diff --git a/cmd/caib/config/config_test.go b/cmd/caib/config/config_test.go index c2bbfe21..4c0bcc23 100644 --- a/cmd/caib/config/config_test.go +++ b/cmd/caib/config/config_test.go @@ -309,6 +309,93 @@ var _ = Describe("DefaultServerWithDerive", func() { }) }) +var _ = Describe("SaveToken and LoadSavedToken", func() { + var tempDir string + var origHome, origXDG string + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "caib-savetoken-test-*") + Expect(err).NotTo(HaveOccurred()) + + origHome = os.Getenv("HOME") + origXDG = os.Getenv("XDG_CONFIG_HOME") + Expect(os.Setenv("HOME", tempDir)).To(Succeed()) + Expect(os.Unsetenv("XDG_CONFIG_HOME")).To(Succeed()) + }) + + AfterEach(func() { + _ = os.Setenv("HOME", origHome) + if origXDG != "" { + _ = os.Setenv("XDG_CONFIG_HOME", origXDG) + } else { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } + _ = os.RemoveAll(tempDir) + }) + + It("saves and loads an opaque token (e.g. oc whoami -t)", func() { + opaqueToken := "sha256~someRandomOpaqueToken" + Expect(SaveToken(opaqueToken)).To(Succeed()) + Expect(LoadSavedToken()).To(Equal(opaqueToken)) + }) + + It("saves and loads a JWT token", func() { + fakeJWT := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.sig" + Expect(SaveToken(fakeJWT)).To(Succeed()) + Expect(LoadSavedToken()).To(Equal(fakeJWT)) + }) + + It("normalizes bearer-prefixed tokens before saving", func() { + Expect(SaveToken("Bearer sha256~someRandomOpaqueToken")).To(Succeed()) + Expect(LoadSavedToken()).To(Equal("sha256~someRandomOpaqueToken")) + }) + + It("normalizes bearer-prefixed tokens with extra spaces", func() { + Expect(SaveToken(" Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.sig ")).To(Succeed()) + Expect(LoadSavedToken()).To(Equal("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.sig")) + }) + + It("returns empty string when no token is saved", func() { + Expect(LoadSavedToken()).To(Equal("")) + }) + + It("clears a saved token when empty string is passed", func() { + Expect(SaveToken("some-token")).To(Succeed()) + Expect(SaveToken("")).To(Succeed()) + Expect(LoadSavedToken()).To(Equal("")) + }) + + It("SaveServerURL preserves the saved token", func() { + Expect(SaveToken("my-token")).To(Succeed()) + Expect(SaveServerURL("https://build-api.example.com")).To(Succeed()) + + Expect(LoadSavedToken()).To(Equal("my-token")) + cfg, err := Read() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ServerURL).To(Equal("https://build-api.example.com")) + }) + + It("SaveToken preserves the server URL", func() { + Expect(SaveServerURL("https://build-api.example.com")).To(Succeed()) + Expect(SaveToken("my-token")).To(Succeed()) + + cfg, err := Read() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ServerURL).To(Equal("https://build-api.example.com")) + Expect(cfg.SavedToken).To(Equal("my-token")) + }) + + It("stores token in cli.json with 0600 permissions", func() { + Expect(SaveToken("secret-token")).To(Succeed()) + + configPath := filepath.Join(tempDir, ".config", "caib", "cli.json") + info, err := os.Stat(configPath) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) +}) + var _ = Describe("Read with XDG config override", func() { var tempDir string var origHome, origXDG string diff --git a/cmd/caib/login.go b/cmd/caib/login.go index 30593a4b..96915023 100644 --- a/cmd/caib/login.go +++ b/cmd/caib/login.go @@ -15,6 +15,36 @@ import ( "github.com/spf13/cobra" ) +var loginToken string + +func newLoginCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "login [server-url]", + Short: "Save server endpoint and authenticate for subsequent commands", + Long: `Login saves the Build API server URL in XDG config (typically ~/.config/caib/cli.json) so you do not need +to pass --server or set CAIB_SERVER for later commands. If the server uses OIDC, +this command also performs authentication and caches the token. + +If no URL is provided, the server endpoint is attempted to be derived from the current +Jumpstarter client config (~/.config/jumpstarter/clients/.yaml). + +Use --token to provide a bearer token explicitly (e.g. from 'oc whoami -t' or a +ServiceAccount token). The token is saved to config and used by all subsequent commands, +bypassing OIDC. This is the recommended approach for machine users and CI/CD pipelines. + +Examples: + caib login https://build-api.my-cluster.example.com + caib login --token "$(oc whoami -t)" https://build-api.my-cluster.example.com + caib login # derive endpoint from Jumpstarter config (if available)`, + Args: cobra.MaximumNArgs(1), + Run: runLogin, + } + + cmd.Flags().StringVar(&loginToken, "token", "", "bearer token to save and use for all API calls (bypasses OIDC)") + + return cmd +} + // normalizeServerURL parses and normalizes a raw server URL argument. // It prepends "https://" if no scheme is present, and rejects URLs with // invalid schemes, credentials, query parameters, fragments, or non-root paths. @@ -96,6 +126,16 @@ func runLogin(_ *cobra.Command, args []string) { } clilog.Infof("Server saved: %s\n", server) + // --token: save to config so all subsequent commands use it directly, + // bypassing OIDC. Works for both opaque tokens (oc whoami -t) and JWTs. + if loginToken != "" { + if err := config.SaveToken(loginToken); err != nil { + handleError(fmt.Errorf("failed to save token: %w", err)) + } + clilog.Infoln("Token saved. Subsequent commands will use it without re-authentication.") + return + } + ctx := context.Background() token, didAuth, err := auth.GetTokenWithReauth(ctx, server, "", insecureSkipTLS) if err != nil { diff --git a/cmd/caib/login_test.go b/cmd/caib/login_test.go index 174e061f..54969a22 100644 --- a/cmd/caib/login_test.go +++ b/cmd/caib/login_test.go @@ -11,6 +11,50 @@ import ( "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" ) +// TestNewLoginCmdTokenFlag verifies that newLoginCmd registers the --token flag +// with the expected default value and no shorthand. +func TestNewLoginCmdTokenFlag(t *testing.T) { + cmd := newLoginCmd() + + f := cmd.Flags().Lookup("token") + if f == nil { + t.Fatal("--token flag not registered on login command") + } + if f.DefValue != "" { + t.Errorf("--token default = %q, want %q", f.DefValue, "") + } + if f.Shorthand != "" { + t.Errorf("--token shorthand = %q, want no shorthand", f.Shorthand) + } +} + +// TestNewLoginCmdNoEndpointOrNoInteractiveFlags verifies that the removed flags +// are not present on the login command. +func TestNewLoginCmdNoEndpointOrNoInteractiveFlags(t *testing.T) { + cmd := newLoginCmd() + + for _, name := range []string{"endpoint", "nointeractive"} { + if f := cmd.Flags().Lookup(name); f != nil { + t.Errorf("flag --%s should not be registered on login command", name) + } + } +} + +// TestNewLoginCmdMaxOnePositionalArg verifies the command accepts at most one +// positional argument (the server URL). +func TestNewLoginCmdMaxOnePositionalArg(t *testing.T) { + cmd := newLoginCmd() + if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { + t.Error("expected error when two positional args are provided, got nil") + } + if err := cmd.Args(cmd, []string{"a"}); err != nil { + t.Errorf("unexpected error for one positional arg: %v", err) + } + if err := cmd.Args(cmd, []string{}); err != nil { + t.Errorf("unexpected error for zero positional args: %v", err) + } +} + func TestNormalizeServerURL(t *testing.T) { tests := []struct { input string diff --git a/cmd/caib/root.go b/cmd/caib/root.go index 542ce9ac..e5ff015b 100644 --- a/cmd/caib/root.go +++ b/cmd/caib/root.go @@ -93,22 +93,3 @@ func newRootCmd() *cobra.Command { return rootCmd } - -func newLoginCmd() *cobra.Command { - return &cobra.Command{ - Use: "login [server-url]", - Short: "Save server endpoint and authenticate for subsequent commands", - Long: `Login saves the Build API server URL in XDG config (typically ~/.config/caib/cli.json) so you do not need -to pass --server or set CAIB_SERVER for later commands. If the server uses OIDC, -this command also performs authentication and caches the token. - -If no URL is provided, the server endpoint is attempted to be derived from the current Jumpstarter -client config (~/.config/jumpstarter/clients/.yaml). - -Examples: - caib login https://build-api.my-cluster.example.com - caib login # attempt to derive endpoint from Jumpstarter config (if available)`, - Args: cobra.MaximumNArgs(1), - Run: runLogin, - } -}