diff --git a/README.md b/README.md index f41b419..da05db2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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: diff --git a/cmd/kion/config/config.go b/cmd/kion/config/config.go index 92e0ad8..9043131 100644 --- a/cmd/kion/config/config.go +++ b/cmd/kion/config/config.go @@ -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 { diff --git a/cmd/kion/credentialprocess/credentialprocess.go b/cmd/kion/credentialprocess/credentialprocess.go index a8d9b32..7747078 100644 --- a/cmd/kion/credentialprocess/credentialprocess.go +++ b/cmd/kion/credentialprocess/credentialprocess.go @@ -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 diff --git a/cmd/kion/key/key.go b/cmd/kion/key/key.go index df0f22f..bcbb92d 100644 --- a/cmd/kion/key/key.go +++ b/cmd/kion/key/key.go @@ -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 { @@ -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 } diff --git a/cmd/kion/login/login.go b/cmd/kion/login/login.go index 16f6d3c..fe6739a 100644 --- a/cmd/kion/login/login.go +++ b/cmd/kion/login/login.go @@ -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 diff --git a/cmd/kion/logout/logout.go b/cmd/kion/logout/logout.go index 274bff9..193fb0a 100644 --- a/cmd/kion/logout/logout.go +++ b/cmd/kion/logout/logout.go @@ -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 diff --git a/cmd/kion/setup/setup.go b/cmd/kion/setup/setup.go index 05a93ca..4fe51f3 100644 --- a/cmd/kion/setup/setup.go +++ b/cmd/kion/setup/setup.go @@ -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" @@ -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( @@ -193,22 +202,118 @@ 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)) + + 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, @@ -216,6 +321,18 @@ func run() error { 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) { diff --git a/cmd/kion/util/util.go b/cmd/kion/util/util.go index ecb4f78..ce2137a 100644 --- a/cmd/kion/util/util.go +++ b/cmd/kion/util/util.go @@ -6,11 +6,15 @@ import ( "github.com/corbaltcode/kion/cmd/kion/config" "github.com/corbaltcode/kion/internal/client" + "github.com/corbaltcode/kion/internal/saml" "github.com/zalando/go-keyring" ) const AppAPIKeyName = "Kion Tool" +// samlTokenGracePeriod re-authenticates this long before the cached SAML token expires. +const samlTokenGracePeriod = 30 * time.Second + func NewClient(cfg *config.Config, keyCfg *config.KeyConfig) (*client.Client, error) { host, err := cfg.StringErr("host") if err != nil { @@ -23,34 +27,44 @@ func NewClient(cfg *config.Config, keyCfg *config.KeyConfig) (*client.Client, er return nil, err } - if cfg.Bool("rotate-app-api-keys") { - expiry := keyCfg.Created.Add(appAPIKeyDuration) + expiry := keyCfg.Created.Add(appAPIKeyDuration) + now := time.Now().UTC() - // rotate if expiring within three days - if expiry.Before(time.Now().Add(time.Hour * 72)) { - kion := client.NewWithAppAPIKey(host, keyCfg.Key, expiry) - key, err := kion.RotateAppAPIKey(keyCfg.Key) - if err != nil { - return nil, err - } + // Use the app API key if it has not yet expired. + if now.Before(expiry) { + if cfg.Bool("rotate-app-api-keys") { + // rotate if expiring within three days + if expiry.Before(now.Add(time.Hour * 72)) { + kion := client.NewWithAppAPIKey(host, keyCfg.Key, expiry) + key, err := kion.RotateAppAPIKey(keyCfg.Key) + if err != nil { + return nil, err + } - // can't know exact expiry before getting metadata, so pass zero Time meaning "no expiry" - kion = client.NewWithAppAPIKey(host, key.Key, time.Time{}) - keyMetadata, err := kion.GetAppAPIKeyMetadata(key.ID) - if err != nil { - return nil, err - } + // can't know exact expiry before getting metadata, so pass zero Time meaning "no expiry" + kion = client.NewWithAppAPIKey(host, key.Key, time.Time{}) + keyMetadata, err := kion.GetAppAPIKeyMetadata(key.ID) + if err != nil { + return nil, err + } - keyCfg.Key = key.Key - keyCfg.Created = keyMetadata.Created - err = keyCfg.Save() - if err != nil { - return nil, err + keyCfg.Key = key.Key + keyCfg.Created = keyMetadata.Created + err = keyCfg.Save() + if err != nil { + return nil, err + } } } + + return client.NewWithAppAPIKey(host, keyCfg.Key, keyCfg.Created.Add(appAPIKeyDuration)), nil } + // Key is expired — fall through to re-authenticate. + } - return client.NewWithAppAPIKey(host, keyCfg.Key, keyCfg.Created.Add(appAPIKeyDuration)), nil + samlMetadata := cfg.String("saml-metadata") + if samlMetadata != "" { + return newSAMLClient(cfg, host, samlMetadata) } idms, err := cfg.IntErr("idms") @@ -70,6 +84,48 @@ func NewClient(cfg *config.Config, keyCfg *config.KeyConfig) (*client.Client, er return client.Login(host, idms, username, password) } +func newSAMLClient(cfg *config.Config, host string, metadataSource string) (*client.Client, error) { + // Default SP issuer matches Kion's standard registration with the IDP. + spIssuer := cfg.String("saml-sp-issuer") + if spIssuer == "" { + spIssuer = "https://" + host + "/api/v1/saml/auth" + } + + // Use cached SAML token if it is still valid. + tokenCfg, err := config.LoadSAMLTokenConfig() + if err != nil { + return nil, fmt.Errorf("loading cached SAML token: %w", err) + } + + now := time.Now().UTC() + if tokenCfg.Token != "" && now.Before(tokenCfg.Expires.Add(-samlTokenGracePeriod)) { + return client.NewWithToken(host, tokenCfg.Token, tokenCfg.Expires), nil + } + + // Token is absent or expiring soon — re-authenticate via browser. + printURL := cfg.Bool("saml-print-url") + + metadata, err := saml.Metadata(metadataSource) + if err != nil { + return nil, err + } + + token, err := saml.Authenticate(host, metadata, spIssuer, printURL) + if err != nil { + return nil, fmt.Errorf("SAML authentication failed: %w", err) + } + + // Kion SAML tokens are valid for 10 minutes. + expires := now.Add(10 * time.Minute) + tokenCfg = &config.SAMLTokenConfig{Token: token, Expires: expires} + if saveErr := tokenCfg.Save(); saveErr != nil { + // Non-fatal: we have a valid token for this invocation even if caching fails. + fmt.Printf("warning: could not cache SAML token: %v\n", saveErr) + } + + return client.NewWithToken(host, token, expires), nil +} + func KeyringService(host string, idms int) string { return fmt.Sprintf("%s/%d", host, idms) } diff --git a/go.mod b/go.mod index cc1ba80..61ca8a1 100644 --- a/go.mod +++ b/go.mod @@ -16,12 +16,15 @@ require ( require ( github.com/alessio/shellescape v1.4.1 // indirect + github.com/beevik/etree v1.1.0 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect @@ -29,6 +32,8 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/relvacode/iso8601 v1.3.0 // indirect + github.com/russellhaering/gosaml2 v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/term v0.5.0 // indirect diff --git a/go.sum b/go.sum index 84c3d30..52956c8 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,10 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= @@ -20,6 +23,9 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= @@ -32,6 +38,14 @@ github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHY github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= github.com/knadh/koanf/v2 v2.0.0 h1:XPQ5ilNnwnNaHrfQ1YpTVhUAjcGHnEKA+lRpipQv02Y= github.com/knadh/koanf/v2 v2.0.0/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= @@ -46,10 +60,18 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/relvacode/iso8601 v1.3.0 h1:HguUjsGpIMh/zsTczGN3DVJFxTU/GX+MMmzcKoMO7ko= github.com/relvacode/iso8601 v1.3.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= +github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= @@ -78,6 +100,10 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go index 1444026..5d23a29 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -37,9 +37,12 @@ type AppAPIKeyMetadata struct { Created time.Time } +const IDMSTypeSAML = 3 + type IDMS struct { - ID int - Name string + ID int `json:"id"` + TypeID int `json:"idms_type_id"` + Name string `json:"name"` } type TemporaryCredentials struct { @@ -68,7 +71,7 @@ type accessToken struct { } func (t *accessToken) IsExpired() bool { - return !t.Expiry.IsZero() && time.Now().After(t.Expiry) + return !t.Expiry.IsZero() && time.Now().UTC().After(t.Expiry) } // NewWithAppAPIKey creates a Client that authenticates with an App API Key. @@ -85,6 +88,20 @@ func NewWithAppAPIKey(host string, key string, expiry time.Time) *Client { } } +// NewWithToken creates a Client that authenticates with a pre-obtained bearer token +// (e.g. from a SAML authentication flow). expiry is used to detect when the token +// has expired; a zero expiry means no expiry check is performed. +func NewWithToken(host string, token string, expiry time.Time) *Client { + return &Client{ + Host: host, + accessToken: &accessToken{ + Token: token, + Expiry: expiry, + IsAppAPIKey: false, + }, + } +} + // TODO: how to use the refresh token (currently dropped)? func Login(host string, idms int, username string, password string) (*Client, error) { req := map[string]interface{}{ diff --git a/internal/saml/saml.go b/internal/saml/saml.go new file mode 100644 index 0000000..3513eba --- /dev/null +++ b/internal/saml/saml.go @@ -0,0 +1,375 @@ +package saml + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + "time" + + "github.com/pkg/browser" + saml2 "github.com/russellhaering/gosaml2" + samlTypes "github.com/russellhaering/gosaml2/types" + dsig "github.com/russellhaering/goxmldsig" +) + +// callbackPort is the localhost port used to receive the SAML assertion from the IDP. +// This must match the ACS URL registered with the IDP for this SP. +const callbackPort = 8400 + +const authPage = ` + +
+ +Authentication successful. You may close this window.
+ +