diff --git a/go.mod b/go.mod index 69511be..f0d562e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/steipete/eightctl go 1.24.2 require ( + github.com/99designs/keyring v1.2.2 github.com/charmbracelet/log v0.4.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -10,6 +11,7 @@ require ( ) require ( + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect @@ -19,13 +21,18 @@ require ( github.com/clipperhouse/displaywidth v0.6.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.1.2 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -38,6 +45,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.3.0 // indirect golang.org/x/text v0.31.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index a492a1d..e0b6393 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= @@ -19,8 +23,13 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -29,12 +38,18 @@ github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkv github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -43,8 +58,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -67,6 +86,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -77,13 +100,17 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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/eightsleep.go b/internal/client/eightsleep.go index 9b61471..4e2364f 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -11,6 +11,9 @@ import ( "net/http" "net/url" "time" + + "github.com/charmbracelet/log" + "github.com/steipete/eightctl/internal/tokencache" ) const ( @@ -67,6 +70,8 @@ func (c *Client) Authenticate(ctx context.Context) error { if err := c.authTokenEndpoint(ctx); err == nil { return nil } + // OAuth failed - clear any stale cached token before trying legacy + tokencache.Clear() return c.authLegacyLogin(ctx) } @@ -133,6 +138,8 @@ func (c *Client) authTokenEndpoint(ctx context.Context) error { } defer resp.Body.Close() if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + log.Debug("token auth failed", "status", resp.Status, "headers", resp.Header, "body", string(b)) return fmt.Errorf("token auth failed: %s", resp.Status) } @@ -155,6 +162,11 @@ func (c *Client) authTokenEndpoint(ctx context.Context) error { if c.UserID == "" { c.UserID = res.UserID } + if err := tokencache.Save(c.token, c.tokenExp, c.UserID); err != nil { + log.Debug("failed to cache token", "error", err) + } else { + log.Debug("saved token to cache", "expires_at", c.tokenExp) + } return nil } @@ -180,6 +192,7 @@ func (c *Client) authLegacyLogin(ctx context.Context) error { defer resp.Body.Close() if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) + log.Debug("legacy login failed", "status", resp.Status, "headers", resp.Header, "body", string(b)) return fmt.Errorf("login failed: %s", string(b)) } var res struct { @@ -207,14 +220,34 @@ func (c *Client) authLegacyLogin(ctx context.Context) error { if c.UserID == "" { c.UserID = res.Session.UserID } + if err := tokencache.Save(c.token, c.tokenExp, c.UserID); err != nil { + log.Debug("failed to cache token", "error", err) + } else { + log.Debug("saved token to cache (legacy)", "expires_at", c.tokenExp) + } return nil } func (c *Client) ensureToken(ctx context.Context) error { - if c.token == "" || time.Now().After(c.tokenExp) { - return c.Authenticate(ctx) + if c.token != "" && time.Now().Before(c.tokenExp) { + log.Debug("using in-memory token", "expires_in", time.Until(c.tokenExp).Round(time.Second)) + return nil } - return nil + // Trust cached tokens without server validation. If token is invalid, + // the server will return 401 and we'll clear cache + re-authenticate. + if cached, err := tokencache.Load(); err == nil { + log.Debug("loaded token from cache", "expires_at", cached.ExpiresAt, "user_id", cached.UserID) + c.token = cached.Token + c.tokenExp = cached.ExpiresAt + if cached.UserID != "" && c.UserID == "" { + c.UserID = cached.UserID + } + return nil + } else { + log.Debug("no cached token", "reason", err) + } + log.Debug("authenticating with server") + return c.Authenticate(ctx) } // requireUser ensures UserID is populated. @@ -263,6 +296,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } if resp.StatusCode == http.StatusUnauthorized { c.token = "" + tokencache.Clear() if err := c.ensureToken(ctx); err != nil { return err } diff --git a/internal/cmd/logout.go b/internal/cmd/logout.go new file mode 100644 index 0000000..e1d384a --- /dev/null +++ b/internal/cmd/logout.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/steipete/eightctl/internal/tokencache" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Clear cached authentication token", + RunE: func(cmd *cobra.Command, args []string) error { + if err := tokencache.Clear(); err != nil { + return fmt.Errorf("clear token: %w", err) + } + fmt.Println("Logged out (token cache cleared)") + return nil + }, +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 31166b7..21f5fe6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -75,6 +75,7 @@ func init() { rootCmd.AddCommand(householdCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(whoamiCmd) + rootCmd.AddCommand(logoutCmd) } func initConfig() { @@ -105,7 +106,7 @@ func initConfig() { } if viper.GetBool("verbose") { - logger.SetLevel(log.DebugLevel) + log.SetLevel(log.DebugLevel) } } diff --git a/internal/tokencache/tokencache.go b/internal/tokencache/tokencache.go new file mode 100644 index 0000000..11f8226 --- /dev/null +++ b/internal/tokencache/tokencache.go @@ -0,0 +1,98 @@ +package tokencache + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/99designs/keyring" + "github.com/charmbracelet/log" +) + +const ( + serviceName = "eightctl" + tokenKey = "oauth-token" +) + +type CachedToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + UserID string `json:"user_id,omitempty"` +} + +var openKeyring = defaultOpenKeyring + +func defaultOpenKeyring() (keyring.Keyring, error) { + home, _ := os.UserHomeDir() + return keyring.Open(keyring.Config{ + ServiceName: serviceName, + AllowedBackends: []keyring.BackendType{ + keyring.KeychainBackend, + keyring.SecretServiceBackend, + keyring.WinCredBackend, + keyring.FileBackend, + }, + FileDir: filepath.Join(home, ".config", "eightctl", "keyring"), + FilePasswordFunc: filePassword, + }) +} + +func filePassword(_ string) (string, error) { + return serviceName + "-fallback", nil +} + +func Save(token string, expiresAt time.Time, userID string) error { + ring, err := openKeyring() + if err != nil { + log.Debug("keyring open failed (save)", "error", err) + return err + } + data, err := json.Marshal(CachedToken{ + Token: token, + ExpiresAt: expiresAt, + UserID: userID, + }) + if err != nil { + return err + } + if err := ring.Set(keyring.Item{ + Key: tokenKey, + Label: serviceName + " token", + Data: data, + }); err != nil { + log.Debug("keyring set failed", "error", err) + return err + } + log.Debug("keyring saved token") + return nil +} + +func Load() (*CachedToken, error) { + ring, err := openKeyring() + if err != nil { + log.Debug("keyring open failed (load)", "error", err) + return nil, err + } + item, err := ring.Get(tokenKey) + if err != nil { + log.Debug("keyring get failed", "error", err) + return nil, err + } + var cached CachedToken + if err := json.Unmarshal(item.Data, &cached); err != nil { + return nil, err + } + if time.Now().After(cached.ExpiresAt) { + return nil, keyring.ErrKeyNotFound + } + return &cached, nil +} + +func Clear() error { + ring, err := openKeyring() + if err != nil { + return err + } + return ring.Remove(tokenKey) +} diff --git a/internal/tokencache/tokencache_test.go b/internal/tokencache/tokencache_test.go new file mode 100644 index 0000000..afdc25f --- /dev/null +++ b/internal/tokencache/tokencache_test.go @@ -0,0 +1,213 @@ +package tokencache + +import ( + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/99designs/keyring" +) + +func testKeyring(t *testing.T) keyring.Keyring { + t.Helper() + tmpDir := t.TempDir() + ring, err := keyring.Open(keyring.Config{ + ServiceName: serviceName + "-test", + AllowedBackends: []keyring.BackendType{keyring.FileBackend}, + FileDir: filepath.Join(tmpDir, "keyring"), + FilePasswordFunc: func(_ string) (string, error) { return "test-pass", nil }, + }) + if err != nil { + t.Fatalf("failed to open test keyring: %v", err) + } + return ring +} + +func TestSaveAndLoad(t *testing.T) { + ring := testKeyring(t) + + token := "test-token-123" + expiresAt := time.Now().Add(time.Hour) + userID := "user-456" + + // Save token to keyring + data, err := json.Marshal(CachedToken{ + Token: token, + ExpiresAt: expiresAt, + UserID: userID, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := ring.Set(keyring.Item{Key: tokenKey, Data: data}); err != nil { + t.Fatalf("set: %v", err) + } + + // Load token from keyring + item, err := ring.Get(tokenKey) + if err != nil { + t.Fatalf("get: %v", err) + } + + var cached CachedToken + if err := json.Unmarshal(item.Data, &cached); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if cached.Token != token { + t.Errorf("token: got %s, want %s", cached.Token, token) + } + if cached.UserID != userID { + t.Errorf("userID: got %s, want %s", cached.UserID, userID) + } + if !cached.ExpiresAt.Equal(expiresAt) { + t.Errorf("expiresAt: got %v, want %v", cached.ExpiresAt, expiresAt) + } +} + +func TestExpiredToken(t *testing.T) { + ring := testKeyring(t) + + // Save expired token + expiredTime := time.Now().Add(-time.Hour) + data, err := json.Marshal(CachedToken{ + Token: "expired-token", + ExpiresAt: expiredTime, + UserID: "user-123", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := ring.Set(keyring.Item{Key: tokenKey, Data: data}); err != nil { + t.Fatalf("set: %v", err) + } + + // Try to load - should get error for expired token + item, err := ring.Get(tokenKey) + if err != nil { + t.Fatalf("get: %v", err) + } + + var cached CachedToken + if err := json.Unmarshal(item.Data, &cached); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if !time.Now().After(cached.ExpiresAt) { + t.Errorf("token should be expired") + } +} + +func TestClear(t *testing.T) { + ring := testKeyring(t) + + // Save a token + data, err := json.Marshal(CachedToken{ + Token: "test-token", + ExpiresAt: time.Now().Add(time.Hour), + UserID: "user-123", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := ring.Set(keyring.Item{Key: tokenKey, Data: data}); err != nil { + t.Fatalf("set: %v", err) + } + + // Verify token exists + if _, err := ring.Get(tokenKey); err != nil { + t.Fatalf("get before clear: %v", err) + } + + // Clear the token + if err := ring.Remove(tokenKey); err != nil { + t.Fatalf("remove: %v", err) + } + + // Verify token is gone + if _, err := ring.Get(tokenKey); err != keyring.ErrKeyNotFound { + t.Errorf("expected ErrKeyNotFound after clear, got: %v", err) + } +} + +func TestIntegrationSaveLoadClear(t *testing.T) { + // Override openKeyring for this test + tmpDir := t.TempDir() + origOpenKeyring := openKeyring + openKeyring = func() (keyring.Keyring, error) { + return keyring.Open(keyring.Config{ + ServiceName: serviceName + "-integration", + AllowedBackends: []keyring.BackendType{keyring.FileBackend}, + FileDir: filepath.Join(tmpDir, "keyring"), + FilePasswordFunc: func(_ string) (string, error) { return "test-pass", nil }, + }) + } + t.Cleanup(func() { openKeyring = origOpenKeyring }) + + token := "integration-token" + expiresAt := time.Now().Add(2 * time.Hour) + userID := "integration-user" + + // Test Save + if err := Save(token, expiresAt, userID); err != nil { + t.Fatalf("Save: %v", err) + } + + // Test Load + cached, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cached.Token != token { + t.Errorf("token: got %s, want %s", cached.Token, token) + } + if cached.UserID != userID { + t.Errorf("userID: got %s, want %s", cached.UserID, userID) + } + + // Test Clear + if err := Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + + // Verify cleared + if _, err := Load(); err != keyring.ErrKeyNotFound { + t.Errorf("expected ErrKeyNotFound after Clear, got: %v", err) + } +} + +func TestLoadExpiredTokenReturnsError(t *testing.T) { + tmpDir := t.TempDir() + origOpenKeyring := openKeyring + openKeyring = func() (keyring.Keyring, error) { + return keyring.Open(keyring.Config{ + ServiceName: serviceName + "-expired", + AllowedBackends: []keyring.BackendType{keyring.FileBackend}, + FileDir: filepath.Join(tmpDir, "keyring"), + FilePasswordFunc: func(_ string) (string, error) { return "test-pass", nil }, + }) + } + t.Cleanup(func() { openKeyring = origOpenKeyring }) + + // Save expired token + expiredTime := time.Now().Add(-time.Minute) + if err := Save("expired-token", expiredTime, "user-id"); err != nil { + t.Fatalf("Save: %v", err) + } + + // Load should return ErrKeyNotFound for expired token + if _, err := Load(); err != keyring.ErrKeyNotFound { + t.Errorf("expected ErrKeyNotFound for expired token, got: %v", err) + } +} + +func TestFilePasswordFunc(t *testing.T) { + pw, err := filePassword("any-string") + if err != nil { + t.Fatalf("filePassword: %v", err) + } + if pw != serviceName+"-fallback" { + t.Errorf("password: got %s, want %s", pw, serviceName+"-fallback") + } +}