Skip to content

Commit

Permalink
OCM-4966 | feat: Add keyring support for configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
tylercreller committed Mar 22, 2024
1 parent cd5638f commit 02ccd5c
Show file tree
Hide file tree
Showing 208 changed files with 23,413 additions and 88 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ $ cat ~/.docker/config.json | jq '.auths["registry.ci.openshift.org"]'
"auth": "token"
}
```
## Secure Credentials Storage
The `OCM_KEYRING` environment variable provides the ability to store the ROSA
configuration containing your authentication tokens in your OS keyring. This is provided
as an alternative to storing the configuration in plain-text on your system.
`OCM_KEYRING` will override all other token or configuration related flags.

`OCM_KEYRING` supports the following keyrings:

* [Windows Credential Manager](https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0) - `wincred`
* [macOS Keychain](https://support.apple.com/en-us/guide/keychain-access/welcome/mac) - `keychain`
* Secret Service ([Gnome Keyring](https://wiki.gnome.org/Projects/GnomeKeyring), [KWallet](https://apps.kde.org/kwalletmanager5/), etc.) - `secret-service`
* [Pass](https://www.passwordstore.org/) - `pass`

To ensure `OCM_KEYRING` is provided to all `rosa` commands, it is recommended to set it in your `~/.bashrc` file or equivalent.

| | wincred | keychain | secret-service | pass |
| ------------- | ------------- | ------------- | ------------- | ------------- |
| Windows | :heavy_check_mark: | :x: | :x: | :x: |
| macOS | :x: | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Linux | :x: | :x: | :heavy_check_mark: | :heavy_check_mark: |
## Have you got feedback?

We want to hear it. [Open an issue](https://github.com/openshift/rosa/issues/new) against the repo and someone from the team will be in touch.
12 changes: 11 additions & 1 deletion cmd/config/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/openshift/rosa/cmd/config/get"
"github.com/openshift/rosa/cmd/config/set"
"github.com/openshift/rosa/pkg/config"
"github.com/openshift/rosa/pkg/properties"
)

func longHelp() string {
Expand All @@ -44,7 +45,16 @@ The following variables are supported:
Note that "rosa config get access_token" gives whatever the file contains - may be missing or expired;
you probably want "rosa token" command instead which will obtain a fresh token if needed.
`, loc, strings.Join(config.ConfigVarDocs(), "\n"))
If '%s' is set, the configuration file is ignored and the keyring is used instead. The
following backends are supported for the keyring:
- macOS: keychain, pass
- Linux: secret-service, pass
- Windows: wincred
Available Keyrings on your OS: %s
`, loc, strings.Join(config.ConfigVarDocs(), "\n"), properties.KeyringEnvKey, strings.Join(config.GetKeyrings(), ", "))
}

func NewConfigCommand() *cobra.Command {
Expand Down
6 changes: 2 additions & 4 deletions cmd/config/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,13 @@ var _ = Describe("Run Command", Ordered, func() {
os.Setenv("OCM_CONFIG", "")
})

It("Shows error when config is empty", func() {
It("Does not show error when config is empty", func() {
os.Setenv("OCM_CONFIG", "//invalid/path")
config.Save(nil)
_, err := config.Load()
Expect(err).To(BeNil())
err = get.PrintConfig("access_token")
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(Equal("Config file '//invalid/path' does not exist. " +
"Please run the 'rosa login' command and try again."))
Expect(err).To(BeNil())
})
})
})
26 changes: 16 additions & 10 deletions cmd/config/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,26 @@ func run(_ *cobra.Command, argv []string) {
}

func PrintConfig(arg string) error {
// Load the configuration file:
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("Failed to load config file: %v", err)
// The following variables are not stored in the configuration
// and can skip loading configuration:
skipConfigLoadMap := map[string]bool{
"keyrings": true,
}

// If the configuration file doesn't exist yet assume that all the configuration settings
// are empty:
if cfg == nil {
loc, err := config.Location()
cfg := &config.Config{}
var err error
if !skipConfigLoadMap[arg] {
// Load the configuration:
cfg, err = config.Load()
if err != nil {
return fmt.Errorf("Failed to find config file location: %v", err)
return fmt.Errorf("can't load config: %v", err)
}
// If the configuration doesn't exist yet assume that all the configuration settings
// are empty:
if cfg == nil {
fmt.Fprintf(Writer, "\n")
return nil
}
return fmt.Errorf("Config file '%s' does not exist. Please run the 'rosa login' command and try again.", loc)
}

// Print the value of the requested configuration setting:
Expand Down
105 changes: 59 additions & 46 deletions cmd/login/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/golang-jwt/jwt/v4"
sdk "github.com/openshift-online/ocm-sdk-go"
"github.com/openshift-online/ocm-sdk-go/authentication"
"github.com/openshift-online/ocm-sdk-go/authentication/securestore"
"github.com/spf13/cobra"
errors "github.com/zgalor/weberr"

Expand All @@ -37,6 +38,7 @@ import (
"github.com/openshift/rosa/pkg/interactive"
"github.com/openshift/rosa/pkg/ocm"
"github.com/openshift/rosa/pkg/output"
"github.com/openshift/rosa/pkg/properties"
rprtr "github.com/openshift/rosa/pkg/reporter"
"github.com/openshift/rosa/pkg/rosa"
)
Expand Down Expand Up @@ -64,14 +66,15 @@ var args struct {
var Cmd = &cobra.Command{
Use: "login",
Short: "Log in to your Red Hat account",
Long: fmt.Sprintf("Log in to your Red Hat account, saving the credentials to the configuration file.\n"+
Long: fmt.Sprintf("Log in to your Red Hat account, saving the credentials to the configuration file or OS Keyring.\n"+
"The supported mechanism is by using a token, which can be obtained at: %s\n\n"+
"The application looks for the token in the following order, stopping when it finds it:\n"+
"\t1. Command-line flags\n"+
"\t2. Environment variable (ROSA_TOKEN)\n"+
"\t3. Environment variable (OCM_TOKEN)\n"+
"\t4. Configuration file\n"+
"\t5. Command-line prompt\n", uiTokenPage),
fmt.Sprintf("\t1. OS Keyring via Environment variable (%s)\n", properties.KeyringEnvKey)+
"\t2. Command-line flags\n"+
"\t3. Environment variable (ROSA_TOKEN)\n"+
"\t4. Environment variable (OCM_TOKEN)\n"+
"\t5. Configuration file\n"+
"\t6. Command-line prompt\n", uiTokenPage),
Example: fmt.Sprintf(` # Login to the OpenShift API with an existing token generated from %s
rosa login --token=$OFFLINE_ACCESS_TOKEN`, uiTokenPage),
Run: run,
Expand Down Expand Up @@ -139,15 +142,15 @@ func init() {
"use-auth-code",
false,
"Login using OAuth Authorization Code. This should be used for most cases where a "+
"browser is available.",
"browser is available. See --use-device-code for remote hosts and containers.",
)
flags.BoolVar(
&args.useDeviceCode,
"use-device-code",
false,
"Login using OAuth Device Code. "+
"This should only be used for remote hosts and containers where browsers are "+
"not available. Use auth code for all other scenarios.",
"not available. See --use-auth-code for all other scenarios.",
)
flags.StringVar(
&args.rhRegion,
Expand All @@ -163,8 +166,17 @@ func init() {
}

func run(cmd *cobra.Command, argv []string) {
ctx := cmd.Context()
r := rosa.NewRuntime()
defer r.Cleanup()
err := runWithRuntime(r, cmd, argv)
if err != nil {
r.Reporter.Errorf(err.Error())
os.Exit(1)
}
}

func runWithRuntime(r *rosa.Runtime, cmd *cobra.Command, argv []string) error {
ctx := cmd.Context()
var spin *spinner.Spinner
if r.Reporter.IsTerminal() && !output.HasFlag() {
spin = spinner.New(spinner.CharSets[9], 100*time.Millisecond)
Expand All @@ -173,6 +185,14 @@ func run(cmd *cobra.Command, argv []string) {
// Check mandatory options:
env := args.env

// Fail fast if config is keyring managed and invalid
if keyring, ok := config.IsKeyringManaged(); ok {
err := securestore.ValidateBackend(keyring)
if err != nil {
return fmt.Errorf("Error validating keyring: %v", err)
}
}

// Confirm that token is not passed with auth code flags
if (args.useAuthCode || args.useDeviceCode) && args.token != "" {
r.Reporter.Errorf("Token cannot be passed with '--use-auth-code' or '--use-device-code' commands")
Expand All @@ -198,8 +218,7 @@ func run(cmd *cobra.Command, argv []string) {
}
token, err := authentication.InitiateAuthCode(oauthClientId)
if err != nil {
r.Reporter.Errorf("An error occurred while retrieving the token: %v", err)
os.Exit(1)
return fmt.Errorf("An error occurred while retrieving the token: %v", err)
}
args.token = token
args.clientID = oauthClientId
Expand All @@ -210,8 +229,7 @@ func run(cmd *cobra.Command, argv []string) {
}
_, err := deviceAuthConfig.InitiateDeviceAuth(ctx)
if err != nil {
r.Reporter.Errorf("An error occurred while initiating device auth: %v", err)
os.Exit(1)
return fmt.Errorf("An error occurred while initiating device auth: %v", err)
}
deviceAuthResp := deviceAuthConfig.DeviceAuthResponse

Expand All @@ -220,8 +238,7 @@ func run(cmd *cobra.Command, argv []string) {
r.Reporter.Infof("Checking status every %v seconds...", deviceAuthResp.Interval)
token, err := deviceAuthConfig.PollForTokenExchange(ctx)
if err != nil {
r.Reporter.Errorf("An error occurred while polling for token exchange: %v", err)
os.Exit(1)
return fmt.Errorf("An error occurred while polling for token exchange: %v", err)
}
args.token = token
args.clientID = oauthClientId
Expand All @@ -230,8 +247,7 @@ func run(cmd *cobra.Command, argv []string) {
// Load the configuration file:
cfg, err := config.Load()
if err != nil {
r.Reporter.Errorf("Failed to load config file: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to load config file: %v", err)
}
if cfg == nil {
cfg = new(config.Config)
Expand All @@ -246,7 +262,7 @@ func run(cmd *cobra.Command, argv []string) {
os.Exit(1)
}

haveReqs := token != ""
haveReqs := token != "" || (args.clientID != "" && args.clientSecret != "")

// Verify environment variables:
if !haveReqs && !reAttempt && !fedramp.Enabled() {
Expand All @@ -261,8 +277,7 @@ func run(cmd *cobra.Command, argv []string) {
if !haveReqs {
armed, err := cfg.Armed()
if err != nil {
r.Reporter.Errorf("Failed to verify configuration: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to verify configuration: %v", err)
}
haveReqs = armed
}
Expand All @@ -275,15 +290,13 @@ func run(cmd *cobra.Command, argv []string) {
Required: true,
})
if err != nil {
r.Reporter.Errorf("Failed to parse token: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to parse token: %v", err)
}
haveReqs = token != ""
}

if !haveReqs {
r.Reporter.Errorf("Failed to login to OCM. See 'rosa login --help' for information.")
os.Exit(1)
return fmt.Errorf("Failed to login to OCM. See 'rosa login --help' for information.")
}

// Red Hat SSO does not issue encrypted refresh tokens, but AWS Cognito does. If the token
Expand Down Expand Up @@ -375,15 +388,13 @@ func run(cmd *cobra.Command, argv []string) {
// If a token has been provided parse it:
jwtToken, err := config.ParseToken(token)
if err != nil {
r.Reporter.Errorf("Failed to parse token: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to parse token: %v", err)
}

// Put the token in the place of the configuration that corresponds to its type:
typ, err := tokenType(jwtToken)
if err != nil {
r.Reporter.Errorf("Failed to extract type from 'typ' claim of token: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to extract type from 'typ' claim of token: %v", err)
}
switch typ {
case "Bearer", "":
Expand All @@ -393,8 +404,7 @@ func run(cmd *cobra.Command, argv []string) {
cfg.AccessToken = ""
cfg.RefreshToken = token
default:
r.Reporter.Errorf("Don't know how to handle token type '%s' in token", typ)
os.Exit(1)
return fmt.Errorf("Don't know how to handle token type '%s' in token", typ)
}
}
}
Expand All @@ -407,34 +417,33 @@ func run(cmd *cobra.Command, argv []string) {
if err != nil {
if strings.Contains(err.Error(), "token needs to be updated") && !reAttempt {
reattemptLogin(cmd, argv)
return
return nil
} else {
r.Reporter.Errorf("Failed to create OCM connection: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to create OCM connection: %v", err)
}
}
defer r.Cleanup()

accessToken, refreshToken, err := r.OCMClient.GetConnectionTokens()
if err != nil {
r.Reporter.Errorf("Failed to get token. Your session might be expired: %v", err)
r.Reporter.Infof("Get a new offline access token at %s", uiTokenPage)
os.Exit(1)
return fmt.Errorf(
"Failed to get token. Your session might be expired: %v\nGet a new offline access token at %s",
err, uiTokenPage)
}
reAttempt = false
// Save the configuration:
cfg.AccessToken = accessToken
cfg.RefreshToken = refreshToken
err = config.Save(cfg)
if err != nil {
r.Reporter.Errorf("Failed to save config file: %v", err)
os.Exit(1)
return fmt.Errorf("Failed to save config file: %v", err)
}

username, err := cfg.GetData("username")
username, err := cfg.GetData("preferred_username")
if err != nil {
r.Reporter.Errorf("Failed to get username: %v", err)
os.Exit(1)
username, err = cfg.GetData("username")
if err != nil {
return fmt.Errorf("Failed to get username: %v", err)
}
}

r.Reporter.Infof("Logged in as '%s' on '%s'", username, cfg.URL)
Expand All @@ -447,14 +456,15 @@ func run(cmd *cobra.Command, argv []string) {
if args.useAuthCode || args.useDeviceCode {
ssoURL, err := url.Parse(cfg.TokenURL)
if err != nil {
r.Reporter.Errorf("can't parse token url '%s': %v", args.tokenURL, err)
os.Exit(1)
return fmt.Errorf("can't parse token url '%s': %v", args.tokenURL, err)
}
ssoHost := ssoURL.Scheme + "://" + ssoURL.Hostname()

r.Reporter.Infof("To switch accounts, logout from %s and run `rosa logout` "+
"before attempting to login again", ssoHost)
}

return nil
}

func reattemptLogin(cmd *cobra.Command, argv []string) {
Expand Down Expand Up @@ -515,9 +525,12 @@ func Call(cmd *cobra.Command, argv []string, reporter *rprtr.Object) error {
}

if isLoggedIn {
username, err := cfg.GetData("username")
username, err := cfg.GetData("preferred_username")
if err != nil {
return fmt.Errorf("Failed to get username: %v", err)
username, err = cfg.GetData("username")
if err != nil {
return fmt.Errorf("Failed to get username: %v", err)
}
}

if reporter.IsTerminal() {
Expand Down
Loading

0 comments on commit 02ccd5c

Please sign in to comment.