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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ $ go install github.com/corbaltcode/kion/cmd/kion@latest

## Setup

Run `kion setup` to set up kion interactively. This subcommand asks for your Kion host, login info, and other settings and writes `~/.config/kion/config.yml` similar to the following:
Run `kion setup` to set up kion interactively. This subcommand asks for your Kion host, login info, and other settings and writes `~/.config/kion/config.yml` similar to one of the following:

```yaml
app-api-key-duration: 168h0m0s
Expand All @@ -31,6 +31,16 @@ session-duration: 1h0m0s
username: alice
```

Or, for a SAML enabled IDMS:
```yaml
app-api-key-duration: 168h0m0s
host: kion-saml.example.com
idms: 1
rotate-app-api-keys: true
saml-metadata: https://id.example.com/sso/saml/metadata
session-duration: 1h0m0s
```

## Fetching Credentials

The `credentials` subcommand fetches and prints credentials:
Expand Down Expand Up @@ -152,13 +162,13 @@ If `rotate-app-api-keys` is set to `true` in `~/.config/kion/config.yml`, the Ki
The `key` subcommand also handles the situation where your key expires — for example, you don't run the Kion tool for a while. The `--force` flag permits the tool to overwrite an existing, possibly expired key:

```
### May prompt for user credentials
### May prompt for user credentials or launch a browser for SAML authentication
$ kion key create --force
```

## User Credentials

If you choose not to use an App API Key, `kion setup` stores user credentials in the system keyring (Secret Service on Linux, Keychain on macOS, Credential Manager on Windows).
If you choose not to use an App API Key, `kion setup` can store user credentials in the system keyring (Secret Service on Linux, Keychain on macOS, Credential Manager on Windows).

To update the user credentials in the system keyring (e.g. your password changes), use the interactive `login` subcommand:

Expand Down
49 changes: 49 additions & 0 deletions cmd/kion/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,55 @@ type KeyConfig struct {
Created time.Time
}

const samlTokenConfigFilename = "saml-token.yml"

type SAMLTokenConfig struct {
Token string
Expires time.Time
}

func LoadSAMLTokenConfig() (*SAMLTokenConfig, error) {
dir, err := UserConfigDir()
if err != nil {
return nil, err
}

cfg := SAMLTokenConfig{}
name := filepath.Join(dir, samlTokenConfigFilename)
f, err := os.Open(name)
if errors.Is(err, fs.ErrNotExist) {
return &cfg, nil
} else if err != nil {
return nil, err
}
defer f.Close()

if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}

func (c *SAMLTokenConfig) Save() error {
dir, err := UserConfigDir()
if err != nil {
return err
}

name := filepath.Join(dir, samlTokenConfigFilename)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}

f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer f.Close()

return yaml.NewEncoder(f).Encode(c)
}

func LoadKeyConfig() (*KeyConfig, error) {
dir, err := UserConfigDir()
if err != nil {
Expand Down
5 changes: 1 addition & 4 deletions cmd/kion/credentialprocess/credentialprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,7 @@ func run(cfg *config.Config, keyCfg *config.KeyConfig) error {
if err != nil {
return err
}
username, err := cfg.StringErr("username")
if err != nil {
return err
}
username := cfg.String("username")
accountID, err := cfg.StringErr("account-id")
if err != nil {
return err
Expand Down
32 changes: 1 addition & 31 deletions cmd/kion/key/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ package key

import (
"errors"
"fmt"
"time"

"github.com/AlecAivazis/survey/v2"
"github.com/corbaltcode/kion/cmd/kion/config"
"github.com/corbaltcode/kion/cmd/kion/util"
"github.com/corbaltcode/kion/internal/client"
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
)

func New(cfg *config.Config, keyCfg *config.KeyConfig) *cobra.Command {
Expand Down Expand Up @@ -50,34 +47,7 @@ func runCreate(cfg *config.Config, keyCfg *config.KeyConfig) error {
return errors.New("key exists; use --force to overwrite")
}

host, err := cfg.StringErr("host")
if err != nil {
return err
}
idms, err := cfg.IntErr("idms")
if err != nil {
return err
}
username, err := cfg.StringErr("username")
if err != nil {
return err
}

password, err := keyring.Get(util.KeyringService(host, idms), username)
if errors.Is(err, keyring.ErrNotFound) {
err = survey.AskOne(
&survey.Password{Message: fmt.Sprintf("Password for '%v' on '%v' (IDMS %v):", username, host, idms)},
&password,
survey.WithValidator(survey.Required),
)
if err != nil {
return err
}
} else if err != nil {
return err
}

kion, err := client.Login(host, idms, username, password)
kion, err := util.NewClient(cfg, keyCfg)
if err != nil {
return err
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/kion/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ func New(cfg *config.Config) *cobra.Command {
}

func run(cfg *config.Config) error {
if cfg.String("saml-metadata") != "" {
return errors.New("login is not required for SAML authentication; credentials are managed via browser")
}

host, err := cfg.StringErr("host")
if err != nil {
return err
Expand Down
5 changes: 5 additions & 0 deletions cmd/kion/logout/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ func New(cfg *config.Config) *cobra.Command {
}

func run(cfg *config.Config) error {
if cfg.String("saml-metadata") != "" {
tokenCfg := &config.SAMLTokenConfig{}
return tokenCfg.Save()
}

host, err := cfg.StringErr("host")
if err != nil {
return err
Expand Down
127 changes: 122 additions & 5 deletions cmd/kion/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/corbaltcode/kion/cmd/kion/config"
"github.com/corbaltcode/kion/cmd/kion/util"
"github.com/corbaltcode/kion/internal/client"
"github.com/corbaltcode/kion/internal/saml"
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -86,9 +87,17 @@ func run() error {
}
idms := idmss[idmsAnswer.Index]

if idms.TypeID == client.IDMSTypeSAML {
return runSAMLSetup(host, idms, userConfigName)
}
return runPasswordSetup(host, idms, userConfigName)
}

func runPasswordSetup(host string, idms client.IDMS, userConfigName string) error {
var username string
var password string
var kion *client.Client
var err error

for {
err = survey.AskOne(
Expand Down Expand Up @@ -193,29 +202,137 @@ func run() error {
"username": username,
}

userConfigDir := filepath.Dir(userConfigName)
err = os.MkdirAll(userConfigDir, 0700)
if err := writeConfig(userConfigName, settings); err != nil {
return err
}

keyCfg := config.KeyConfig{
Key: appAPIKey.Key,
Created: appAPIKeyMetadata.Created,
}
return keyCfg.Save()
}

func runSAMLSetup(host string, idms client.IDMS, userConfigName string) error {
var metadataSource string
err := survey.AskOne(
&survey.Input{Message: "SAML IDP metadata URL or file path:"},
&metadataSource,
survey.WithValidator(survey.Required),
)
if err != nil {
return err
}
f, err := os.OpenFile(userConfigName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)

fmt.Println("Fetching and validating SAML metadata...")
metadata, err := saml.Metadata(metadataSource)
if err != nil {
return fmt.Errorf("invalid SAML metadata: %w", err)
}
fmt.Println("Metadata looks good.")

var appAPIKeyAnswer survey.OptionAnswer
err = survey.AskOne(
&survey.Select{
Message: "Create App API Key?",
Options: []string{"Yes (recommended)", "No (re-authenticate via browser on each use)"},
},
&appAPIKeyAnswer,
)
if err != nil {
return err
}
defer f.Close()

err = yaml.NewEncoder(f).Encode(settings)
appAPIKey := &client.AppAPIKey{}
appAPIKeyMetadata := &client.AppAPIKeyMetadata{}
var rotateAppAPIKeys bool
var appAPIKeyDuration time.Duration

if appAPIKeyAnswer.Index == 0 {
spIssuer := "https://" + host + "/api/v1/saml/auth"
fmt.Println("A browser window will open to authenticate and create the App API Key.")
token, err := saml.Authenticate(host, metadata, spIssuer, false)
if err != nil {
return fmt.Errorf("SAML authentication failed: %w", err)
}
kion := client.NewWithToken(host, token, time.Now().Add(10*time.Minute))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change all occurrences of time.Now() to time.Now().UTC() so we're not dealing with DST issues and local time zones?

Copy link
Author

@jlegate jlegate Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not against this change, but I don't know that it would make a functional difference in the operation. All of the places where we use time.Now() should be self-referential. The source string is ISO 8601 in the response (cf apiV1getAppAPIKey). I've changed them to all use UTC

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere, one of these gets persisted out to ~/.config/kion/saml-token.yaml:

expires: 2026-03-16T11:20:34.236699-07:00

The CLI still has a bug somewhere where it doesn't respect the expiration date of credentials in the credentials cache, and part of me once thought it might be due to a TZ issue.


appAPIKey, err = kion.CreateAppAPIKey(util.AppAPIKeyName)
if err != nil {
return err
}
appAPIKeyMetadata, err = kion.GetAppAPIKeyMetadata(appAPIKey.ID)
if err != nil {
return err
}

err = survey.AskOne(
&survey.Confirm{
Message: "Automatically rotate App API Keys?",
Default: true,
},
&rotateAppAPIKeys,
)
if err != nil {
return err
}

err = survey.AskOne(
&survey.Input{Message: "Duration of App API Keys:", Default: "168h"},
&appAPIKeyDuration,
survey.WithValidator(survey.Required),
survey.WithValidator(validateDuration),
)
if err != nil {
return err
}
}

var sessionDuration time.Duration
err = survey.AskOne(
&survey.Input{Message: "Duration of temporary credentials:", Default: "60m"},
&sessionDuration,
survey.WithValidator(survey.Required),
survey.WithValidator(validateDuration),
)
if err != nil {
return err
}

settings := map[string]interface{}{
"host": host,
"idms": idms.ID,
"saml-metadata": metadataSource,
"session-duration": sessionDuration,
}
if appAPIKeyAnswer.Index == 0 {
settings["app-api-key-duration"] = appAPIKeyDuration
settings["rotate-app-api-keys"] = rotateAppAPIKeys
}

if err := writeConfig(userConfigName, settings); err != nil {
return err
}

keyCfg := config.KeyConfig{
Key: appAPIKey.Key,
Created: appAPIKeyMetadata.Created,
}
return keyCfg.Save()
}

func writeConfig(name string, settings map[string]interface{}) error {
if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil {
return err
}
f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer f.Close()
return yaml.NewEncoder(f).Encode(settings)
}

func fileExists(name string) (bool, error) {
_, err := os.Stat(name)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
Expand Down
Loading