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 11, 2024
1 parent d832875 commit 15d5d8f
Show file tree
Hide file tree
Showing 206 changed files with 23,455 additions and 67 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.
10 changes: 9 additions & 1 deletion cmd/config/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 configVarDocs() string {
Expand Down Expand Up @@ -57,7 +58,14 @@ 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, configVarDocs())
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
`, loc, configVarDocs(), properties.KeyringEnvKey)
}

func NewConfigCommand() *cobra.Command {
Expand Down
43 changes: 33 additions & 10 deletions cmd/config/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"io"
"os"

"github.com/openshift-online/ocm-sdk-go/authentication/securestore"
"github.com/spf13/cobra"

"github.com/openshift/rosa/pkg/config"
Expand Down Expand Up @@ -54,20 +55,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", loc)
}

// Print the value of the requested configuration setting:
Expand All @@ -90,8 +97,24 @@ func PrintConfig(arg string) error {
fmt.Fprintf(Writer, "%s\n", cfg.URL)
case "fedramp":
fmt.Fprintf(Writer, "%v\n", cfg.FedRAMP)
case "keyrings":
keyrings, err := getKeyrings()
if err != nil {
return err
}
for _, keyring := range keyrings {
fmt.Fprintf(Writer, "%s\n", keyring)
}
default:
return fmt.Errorf("'%s' is not a supported setting", arg)
}
return nil
}

func getKeyrings() ([]string, error) {
backends := securestore.AvailableBackends()
if len(backends) == 0 {
return backends, fmt.Errorf("error: no keyrings available")
}
return backends, nil
}
108 changes: 60 additions & 48 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"

"github.com/openshift/rosa/cmd/logout"
Expand All @@ -36,6 +37,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 All @@ -62,14 +64,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 @@ -136,23 +139,32 @@ 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.",
)
arguments.AddRegionFlag(flags)
fedramp.AddFlag(flags)
}

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 @@ -161,8 +173,15 @@ func run(cmd *cobra.Command, argv []string) {
// Check mandatory options:
env := args.env
if env == "" {
r.Reporter.Errorf("Option '--env' is mandatory")
os.Exit(1)
return fmt.Errorf("Option '--env' is mandatory")
}

// 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)
}
}

if args.useAuthCode {
Expand All @@ -178,8 +197,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 @@ -190,8 +208,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 @@ -200,8 +217,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 @@ -210,8 +226,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 @@ -238,7 +253,7 @@ func run(cmd *cobra.Command, argv []string) {
fedramp.Disable()
}

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

// Verify environment variables:
if !haveReqs && !reAttempt && !fedramp.Enabled() {
Expand All @@ -253,8 +268,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 @@ -267,15 +281,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 @@ -345,15 +357,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 @@ -363,8 +373,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 @@ -377,34 +386,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 @@ -417,14 +425,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 @@ -485,9 +494,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

0 comments on commit 15d5d8f

Please sign in to comment.