From 176f380bbe7eaa602ffc0fdb9c9f5b63d02239ee Mon Sep 17 00:00:00 2001 From: Jason Legate Date: Wed, 11 Mar 2026 12:04:44 -0700 Subject: [PATCH 1/6] Add SAML SP-initiated authentication support Adds internal/saml package implementing a full SAML SP-initiated flow: - Loads and validates IDP metadata from a URL or local file - Binds a local HTTP server on port 8400 (registered with the IDP as the ACS URL) to receive the SAML assertion from the browser - Forwards the assertion to Kion's /api/v1/saml/callback, extracts the SSO code, and exchanges it for a bearer token via /api/v2/login/sso-provider - Opens the system browser via github.com/pkg/browser; falls back to printing the URL if the browser cannot be opened Extends internal/client with: - IDMSTypeSAML constant (idms_type_id == 3) - JSON struct tags and TypeID field on IDMS to support type detection - NewWithToken constructor for clients authenticating with a bearer token --- go.mod | 5 + go.sum | 26 +++ internal/client/client.go | 21 ++- internal/saml/saml.go | 375 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 internal/saml/saml.go 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..afe128e 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 { @@ -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 = ` + + + + Kion + + + +
+

Authentication successful. You may close this window.

+ +
+ +` + +type csrfResponse struct { + Data string `json:"data"` +} + +type ssoAuthResponse struct { + Data struct { + Access struct { + Token string `json:"token"` + } `json:"access"` + } `json:"data"` +} + +type callbackResult struct { + token string + err error +} + +// Metadata loads SAML IDP metadata from a URL or local file path. +func Metadata(source string) (*samlTypes.EntityDescriptor, error) { + if source == "" { + return nil, errors.New("saml metadata source is empty") + } + var rawMetadata []byte + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + res, err := http.Get(source) //nolint:noctx + if err != nil { + return nil, fmt.Errorf("failed to download SAML metadata from %q: %w", source, err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download SAML metadata from %q: HTTP %d", source, res.StatusCode) + } + rawMetadata, err = io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading SAML metadata from %q: %w", source, err) + } + } else { + var err error + rawMetadata, err = os.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("error reading SAML metadata file %q: %w", source, err) + } + } + + if len(rawMetadata) == 0 { + return nil, fmt.Errorf("SAML metadata from %q is empty", source) + } + + metadata := &samlTypes.EntityDescriptor{} + if err := xml.Unmarshal(rawMetadata, metadata); err != nil { + return nil, fmt.Errorf("error parsing SAML metadata XML from %q: %w", source, err) + } + return metadata, nil +} + +func validateMetadata(metadata *samlTypes.EntityDescriptor) error { + if metadata == nil { + return errors.New("SAML metadata is nil") + } + if metadata.EntityID == "" { + return errors.New("SAML metadata is missing EntityID") + } + if metadata.IDPSSODescriptor == nil { + return errors.New("SAML metadata is missing IDPSSODescriptor; verify you are using IDP metadata (not SP metadata)") + } + if len(metadata.IDPSSODescriptor.SingleSignOnServices) == 0 { + return errors.New("SAML metadata IDPSSODescriptor has no SingleSignOnServices defined") + } + if metadata.IDPSSODescriptor.SingleSignOnServices[0].Location == "" { + return errors.New("SAML metadata SingleSignOnService Location is empty") + } + if len(metadata.IDPSSODescriptor.KeyDescriptors) == 0 { + return errors.New("SAML metadata has no KeyDescriptors") + } + for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors { + for _, xcert := range kd.KeyInfo.X509Data.X509Certificates { + if xcert.Data != "" { + return nil + } + } + } + return errors.New("SAML metadata KeyDescriptors contain no valid X509 certificates") +} + +// Authenticate performs a SAML SP-initiated authentication flow against the given Kion host. +// It opens the system browser to the IDP, receives the SAML assertion on localhost:8400 +// (which must be registered as the ACS URL with the IDP), forwards it to Kion, and +// exchanges the resulting SSO code for a bearer token. +// Set printURL to true to print the auth URL instead of opening a browser. +func Authenticate(host string, metadata *samlTypes.EntityDescriptor, spIssuer string, printURL bool) (string, error) { + if host == "" { + return "", errors.New("kion host is required") + } + if spIssuer == "" { + return "", errors.New("SAML SP issuer is required") + } + if err := validateMetadata(metadata); err != nil { + return "", fmt.Errorf("SAML metadata validation failed: %w", err) + } + + certStore := dsig.MemoryX509CertificateStore{Roots: []*x509.Certificate{}} + for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors { + for idx, xcert := range kd.KeyInfo.X509Data.X509Certificates { + if xcert.Data == "" { + return "", fmt.Errorf("metadata certificate %d is empty", idx) + } + certData, err := base64.StdEncoding.DecodeString(xcert.Data) + if err != nil { + return "", err + } + idpCert, err := x509.ParseCertificate(certData) + if err != nil { + return "", err + } + certStore.Roots = append(certStore.Roots, idpCert) + } + } + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", callbackPort)) + if err != nil { + return "", fmt.Errorf("failed to bind SAML callback listener on port %d (is something else using it?): %w", callbackPort, err) + } + + sp := &saml2.SAMLServiceProvider{ + IdentityProviderSSOURL: metadata.IDPSSODescriptor.SingleSignOnServices[0].Location, + IdentityProviderIssuer: metadata.EntityID, + ServiceProviderIssuer: spIssuer, + AssertionConsumerServiceURL: fmt.Sprintf("http://localhost:%d/callback", callbackPort), + SignAuthnRequests: false, + IDPCertificateStore: &certStore, + SPKeyStore: dsig.RandomKeyStoreForTest(), + } + + resultChan := make(chan callbackResult, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + if strings.Contains(req.URL.String(), "/favicon.ico") { + http.NotFound(rw, req) + return + } + if req.Method == http.MethodOptions { + return + } + + b, err := io.ReadAll(req.Body) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + resultChan <- callbackResult{err: fmt.Errorf("bad SAML callback request: %w", err)} + return + } + + httpClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + csrfToken, csrfCookies, err := getCSRFToken("https://"+host, httpClient) + if err != nil { + resultChan <- callbackResult{err: fmt.Errorf("error getting CSRF token: %w", err)} + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + resultChan <- callbackResult{err: fmt.Errorf("failed to create cookie jar: %w", err)} + return + } + u, err := url.Parse("https://" + host) + if err != nil { + resultChan <- callbackResult{err: fmt.Errorf("failed to parse kion URL: %w", err)} + return + } + jar.SetCookies(u, csrfCookies) + httpClient.Jar = jar + + r, err := http.NewRequest(http.MethodPost, "https://"+host+"/api/v1/saml/callback", bytes.NewReader(b)) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + resultChan <- callbackResult{err: fmt.Errorf("error creating SAML callback request: %w", err)} + return + } + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(r) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + resultChan <- callbackResult{err: fmt.Errorf("error posting SAML assertion: %w", err)} + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + resultChan <- callbackResult{err: fmt.Errorf("error reading SAML callback response: %w", err)} + return + } + + ssoCodeRegexp := regexp.MustCompile(`code=(.+)">`) + groups := ssoCodeRegexp.FindStringSubmatch(string(body)) + if len(groups) < 2 { + resultChan <- callbackResult{err: fmt.Errorf("could not find SSO code in SAML response (response: %s)", truncate(string(body), 300))} + return + } + + authToken, err := getAuthToken("https://"+host, groups[1], csrfToken, httpClient) + if err != nil { + resultChan <- callbackResult{err: fmt.Errorf("failed to exchange SSO code for auth token: %w", err)} + return + } + + rw.Write([]byte(authPage)) //nolint:errcheck + resultChan <- callbackResult{token: authToken} + }) + + server := &http.Server{Handler: mux} + + return runAuth(sp, server, listener, resultChan, printURL) +} + +func runAuth(sp *saml2.SAMLServiceProvider, server *http.Server, listener net.Listener, resultChan chan callbackResult, printURL bool) (string, error) { + authURL, err := sp.BuildAuthURL("") + if err != nil { + return "", fmt.Errorf("failed to build SAML auth URL: %w", err) + } + + timeout := 60 * time.Second + if printURL { + fmt.Printf("Please open the following URL in your browser to authenticate:\n\n%s\n\n", authURL) + timeout = 180 * time.Second + } else { + browser.Stdout = os.Stderr + if err := browser.OpenURL(authURL); err != nil { + fmt.Printf("Could not open browser automatically. Please open the following URL:\n\n%s\n\n", authURL) + } + } + + timer := time.NewTimer(timeout) + go func() { + select { + case result := <-resultChan: + timer.Stop() + server.Shutdown(context.Background()) //nolint:errcheck + resultChan <- result + case <-timer.C: + server.Shutdown(context.Background()) //nolint:errcheck + resultChan <- callbackResult{err: errors.New("authentication timed out")} + } + }() + + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + return "", fmt.Errorf("SAML callback server error: %w", err) + } + + result := <-resultChan + return result.token, result.err +} + +func getCSRFToken(appURL string, client *http.Client) (string, []*http.Cookie, error) { + req, err := http.NewRequest(http.MethodGet, appURL+"/api/v2/csrf-token", nil) + if err != nil { + return "", nil, fmt.Errorf("failed to create CSRF token request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("failed to request CSRF token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", nil, fmt.Errorf("CSRF token request failed with HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("failed to read CSRF token response: %w", err) + } + var data csrfResponse + if err := json.Unmarshal(body, &data); err != nil { + return "", nil, fmt.Errorf("failed to parse CSRF token response: %w", err) + } + if data.Data == "" { + return "", nil, errors.New("CSRF token response contained empty token") + } + return data.Data, resp.Cookies(), nil +} + +func getAuthToken(appURL string, ssoCode string, csrfToken string, client *http.Client) (string, error) { + req, err := http.NewRequest(http.MethodGet, appURL+"/api/v2/login/sso-provider?code="+ssoCode, nil) + if err != nil { + return "", fmt.Errorf("failed to create auth token request: %w", err) + } + req.Header.Set("X-Csrf-Token", csrfToken) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to exchange SSO code for auth token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read auth token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("auth token request failed with HTTP %d: %s", resp.StatusCode, truncate(string(body), 300)) + } + + var data ssoAuthResponse + if err := json.Unmarshal(body, &data); err != nil { + return "", fmt.Errorf("failed to parse auth token response: %w", err) + } + if data.Data.Access.Token == "" { + return "", errors.New("auth token response contained empty token") + } + return data.Data.Access.Token, nil +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} From febb71e0b45487781ea480725fbb3e924ad2b482 Mon Sep 17 00:00:00 2001 From: Jason Legate Date: Wed, 11 Mar 2026 12:18:19 -0700 Subject: [PATCH 2/6] Integrate SAML auth into CLI config, auth routing, and setup config: add SAMLTokenConfig to cache the short-lived Kion bearer token obtained after SAML authentication (~10 min lifetime, stored in ~/.config/kion/saml-token.yml). util: add newSAMLClient which loads the cached token (with a 30s grace period for re-auth) or triggers a fresh browser-based SAML flow. Move the SAML check to the top of NewClient so a leftover key.yml does not cause a spurious app-api-key-duration error for SAML users. setup: drive the password vs SAML setup path from the selected IDMS type (idms_type_id == 3) rather than an upfront auth-method question. SAML setup optionally creates an App API Key (triggering a browser auth to do so) and asks about auto-rotation and key duration, mirroring the password setup flow. The SP issuer is derived automatically as https:///api/v1/saml/auth. --- cmd/kion/config/config.go | 49 +++++++++++++++ cmd/kion/setup/setup.go | 127 ++++++++++++++++++++++++++++++++++++-- cmd/kion/util/util.go | 50 +++++++++++++++ 3 files changed, 221 insertions(+), 5 deletions(-) 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/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..0e475ba 100644 --- a/cmd/kion/util/util.go +++ b/cmd/kion/util/util.go @@ -6,17 +6,27 @@ 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 { return nil, err } + // SAML takes precedence when configured, regardless of any leftover key.yml. + samlMetadata := cfg.String("saml-metadata") + if samlMetadata != "" { + return newSAMLClient(cfg, host, samlMetadata) + } + if keyCfg.Key != "" { appAPIKeyDuration, err := cfg.DurationErr("app-api-key-duration") if err != nil { @@ -70,6 +80,46 @@ 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) + } + if tokenCfg.Token != "" && time.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 := time.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) } From 1f463a6e1fe547cc539998159e88f420d036c18b Mon Sep 17 00:00:00 2001 From: Jason Legate Date: Wed, 11 Mar 2026 12:18:42 -0700 Subject: [PATCH 3/6] Fix existing commands for SAML compatibility key create: replace hardcoded keyring/username flow with util.NewClient so SAML users are not prompted for credentials that don't exist. login: return a clear error for SAML users; browser-based auth needs no stored credentials. logout: clear the cached SAML token (~/.config/kion/saml-token.yml) instead of attempting to delete a non-existent keyring entry. credential-process: make username optional (cfg.String instead of cfg.StringErr) since it is only used as part of the on-disk cache key and SAML users have no username in their config. --- .../credentialprocess/credentialprocess.go | 5 +-- cmd/kion/key/key.go | 32 +------------------ cmd/kion/login/login.go | 4 +++ cmd/kion/logout/logout.go | 5 +++ 4 files changed, 11 insertions(+), 35 deletions(-) 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 From df47ca782e811b14a3d0878bd26468f9932c6963 Mon Sep 17 00:00:00 2001 From: Jason Legate Date: Wed, 11 Mar 2026 12:33:15 -0700 Subject: [PATCH 4/6] Prefer app API key over SAML when valid, fall through on expiry Previously SAML always took precedence over a configured app API key. Now NewClient uses the app API key if it exists and has not expired, only falling through to SAML (or password) auth when the key is absent or expired. This also fixes the rotation guard so it only fires on a still-valid key rather than an already-expired one. --- cmd/kion/util/util.go | 63 ++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/cmd/kion/util/util.go b/cmd/kion/util/util.go index 0e475ba..2717b41 100644 --- a/cmd/kion/util/util.go +++ b/cmd/kion/util/util.go @@ -21,46 +21,49 @@ func NewClient(cfg *config.Config, keyCfg *config.KeyConfig) (*client.Client, er return nil, err } - // SAML takes precedence when configured, regardless of any leftover key.yml. - samlMetadata := cfg.String("saml-metadata") - if samlMetadata != "" { - return newSAMLClient(cfg, host, samlMetadata) - } - if keyCfg.Key != "" { appAPIKeyDuration, err := cfg.DurationErr("app-api-key-duration") if err != nil { return nil, err } - if cfg.Bool("rotate-app-api-keys") { - expiry := keyCfg.Created.Add(appAPIKeyDuration) - - // 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 - } - - // 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 + expiry := keyCfg.Created.Add(appAPIKeyDuration) + + // Use the app API key if it has not yet expired. + if time.Now().Before(expiry) { + if cfg.Bool("rotate-app-api-keys") { + // 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 + } + + // 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 + } } } + + 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") From 80302098d0cc7866cdd96c24096093928e281a44 Mon Sep 17 00:00:00 2001 From: Jason Legate Date: Wed, 11 Mar 2026 12:44:40 -0700 Subject: [PATCH 5/6] Update README Calls out specific SAML related configuration options and clarifies when logins/prompts may occur --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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: From 373af955f7b7ef29f43acba58a6315d3283805d9 Mon Sep 17 00:00:00 2001 From: Jason Legate Date: Tue, 17 Mar 2026 08:38:20 -0700 Subject: [PATCH 6/6] Switch to time.Now().UTC(), and re-use locally scoped now Could potentially protect against some edge-of-expiry-window race conditions. --- cmd/kion/util/util.go | 11 +++++++---- internal/client/client.go | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/kion/util/util.go b/cmd/kion/util/util.go index 2717b41..ce2137a 100644 --- a/cmd/kion/util/util.go +++ b/cmd/kion/util/util.go @@ -28,12 +28,13 @@ func NewClient(cfg *config.Config, keyCfg *config.KeyConfig) (*client.Client, er } expiry := keyCfg.Created.Add(appAPIKeyDuration) + now := time.Now().UTC() // Use the app API key if it has not yet expired. - if time.Now().Before(expiry) { + if now.Before(expiry) { if cfg.Bool("rotate-app-api-keys") { // rotate if expiring within three days - if expiry.Before(time.Now().Add(time.Hour * 72)) { + if expiry.Before(now.Add(time.Hour * 72)) { kion := client.NewWithAppAPIKey(host, keyCfg.Key, expiry) key, err := kion.RotateAppAPIKey(keyCfg.Key) if err != nil { @@ -95,7 +96,9 @@ func newSAMLClient(cfg *config.Config, host string, metadataSource string) (*cli if err != nil { return nil, fmt.Errorf("loading cached SAML token: %w", err) } - if tokenCfg.Token != "" && time.Now().Before(tokenCfg.Expires.Add(-samlTokenGracePeriod)) { + + now := time.Now().UTC() + if tokenCfg.Token != "" && now.Before(tokenCfg.Expires.Add(-samlTokenGracePeriod)) { return client.NewWithToken(host, tokenCfg.Token, tokenCfg.Expires), nil } @@ -113,7 +116,7 @@ func newSAMLClient(cfg *config.Config, host string, metadataSource string) (*cli } // Kion SAML tokens are valid for 10 minutes. - expires := time.Now().Add(10 * time.Minute) + 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. diff --git a/internal/client/client.go b/internal/client/client.go index afe128e..5d23a29 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -71,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.