diff --git a/filelock.go b/filelock.go deleted file mode 100644 index 724a5c3..0000000 --- a/filelock.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" -) - -const ( - lockMaxRetries = 50 - lockRetryDelay = 100 * time.Millisecond - staleLockTimeout = 30 * time.Second -) - -// fileLock represents a file lock. -type fileLock struct { - lockFile *os.File - lockPath string -} - -// acquireFileLock acquires an exclusive lock on the token file. -// Uses a separate lock file to coordinate access across processes. -func acquireFileLock(filePath string) (*fileLock, error) { - lockPath := filePath + ".lock" - - for range lockMaxRetries { - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err == nil { - fmt.Fprintf(lockFile, "%d", os.Getpid()) - return &fileLock{ - lockFile: lockFile, - lockPath: lockPath, - }, nil - } - - if os.IsExist(err) { - if info, statErr := os.Stat(lockPath); statErr == nil { - if time.Since(info.ModTime()) > staleLockTimeout { - if remErr := os.Remove(lockPath); remErr != nil && !os.IsNotExist(remErr) { - return nil, fmt.Errorf( - "failed to remove stale lock file %s: %w", - lockPath, - remErr, - ) - } - continue - } - } - time.Sleep(lockRetryDelay) - continue - } - - return nil, fmt.Errorf("failed to acquire file lock: %w", err) - } - - return nil, fmt.Errorf( - "timeout waiting for file lock after %v", - time.Duration(lockMaxRetries)*lockRetryDelay, - ) -} - -// release releases the file lock. -func (fl *fileLock) release() error { - if fl.lockFile != nil { - fl.lockFile.Close() - } - return os.Remove(fl.lockPath) -} diff --git a/filelock_test.go b/filelock_test.go deleted file mode 100644 index 3305c82..0000000 --- a/filelock_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "sync" - "testing" - "time" -) - -func TestAcquireAndRelease(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "tokens.json") - - lock, err := acquireFileLock(target) - if err != nil { - t.Fatalf("acquireFileLock() error: %v", err) - } - - lockPath := target + ".lock" - if _, err := os.Stat(lockPath); os.IsNotExist(err) { - t.Error("lock file was not created") - } - - if err := lock.release(); err != nil { - t.Errorf("release() error: %v", err) - } - - if _, err := os.Stat(lockPath); !os.IsNotExist(err) { - t.Error("lock file was not removed after release") - } -} - -func TestConcurrentLocks(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "tokens.json") - - const goroutines = 10 - var wg sync.WaitGroup - var mu sync.Mutex - concurrent := 0 - - for i := range goroutines { - wg.Add(1) - go func(idx int) { - defer wg.Done() - - lock, err := acquireFileLock(target) - if err != nil { - t.Errorf("goroutine %d: acquireFileLock() error: %v", idx, err) - return - } - - mu.Lock() - concurrent++ - if concurrent > 1 { - t.Errorf("goroutine %d: more than one lock holder at a time: %d", idx, concurrent) - } - mu.Unlock() - - mu.Lock() - concurrent-- - mu.Unlock() - - _ = lock.release() - }(i) - } - - wg.Wait() -} - -func TestStaleLockRemoval(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "tokens.json") - lockPath := target + ".lock" - - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - t.Fatal(err) - } - f.Close() - - staleTime := time.Now().Add(-60 * time.Second) - if err := os.Chtimes(lockPath, staleTime, staleTime); err != nil { - t.Fatalf("os.Chtimes: %v", err) - } - - lock, err := acquireFileLock(target) - if err != nil { - t.Fatalf("acquireFileLock() with stale lock: %v", err) - } - _ = lock.release() -} diff --git a/go.mod b/go.mod index 903d7b9..f31c7d5 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,13 @@ require ( charm.land/bubbletea/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.0 github.com/appleboy/go-httpretry v0.11.0 + github.com/go-authgate/sdk-go v0.0.0-20260308143712-376f312901c8 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -20,11 +22,14 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index d0db718..4e6ae3f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= @@ -26,6 +28,16 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +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/go-authgate/sdk-go v0.0.0-20260308143712-376f312901c8 h1:cqsgCsNlvRew75W5gzXyzZcdzqpvwMxX2AizrnsT01M= +github.com/go-authgate/sdk-go v0.0.0-20260308143712-376f312901c8/go.mod h1:ZRyXFKqO8HqWXIAqIwhjSxJ0DE3RckTVn9UtlX7MvJ8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -36,13 +48,23 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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/main.go b/main.go index dda3e87..c80cdf3 100644 --- a/main.go +++ b/main.go @@ -17,12 +17,14 @@ import ( "syscall" "time" + "github.com/go-authgate/oauth-cli/tui" + "github.com/go-authgate/sdk-go/tokenstore" + tea "charm.land/bubbletea/v2" retry "github.com/appleboy/go-httpretry" + "github.com/google/uuid" "github.com/joho/godotenv" - - "github.com/go-authgate/oauth-cli/tui" ) var ( @@ -33,6 +35,8 @@ var ( callbackPort int scope string tokenFile string + tokenStoreMode string + tokenStore tokenstore.Store configInitialized bool retryClient *retry.Client configWarnings []string @@ -44,6 +48,7 @@ var ( flagCallbackPort *int flagScope *string flagTokenFile *string + flagTokenStore *string ) const ( @@ -82,6 +87,11 @@ func init() { "", "Token storage file (default: .authgate-tokens.json or TOKEN_FILE env)", ) + flagTokenStore = flag.String( + "token-store", + "", + "Token storage backend: auto, file, keyring (default: auto or TOKEN_STORE env)", + ) } // initConfig parses flags and initializes all configuration. @@ -155,6 +165,44 @@ func initConfig() { if err != nil { panic(fmt.Sprintf("failed to create retry client: %v", err)) } + + const defaultKeyringService = "authgate-oauth-cli" + tokenStoreMode = getConfig(*flagTokenStore, "TOKEN_STORE", "auto") + var warnings []string + var err2 error + tokenStore, warnings, err2 = initTokenStore(tokenStoreMode, tokenFile, defaultKeyringService) + if err2 != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err2) + os.Exit(1) + } + configWarnings = append(configWarnings, warnings...) +} + +// initTokenStore creates a token store based on the given mode. +// It returns the store, any warnings, and an error if the mode is invalid. +func initTokenStore(mode, filePath, keyringService string) (tokenstore.Store, []string, error) { + fileStore := tokenstore.NewFileStore(filePath) + var warnings []string + + switch mode { + case "file": + return fileStore, nil, nil + case "keyring": + return tokenstore.NewKeyringStore(keyringService), nil, nil + case "auto": + kr := tokenstore.NewKeyringStore(keyringService) + store := tokenstore.NewSecureStore(kr, fileStore) + if !store.UseKeyring() { + warnings = append(warnings, + "OS keyring unavailable, falling back to file-based token storage") + } + return store, warnings, nil + default: + return nil, nil, fmt.Errorf( + "invalid token-store value: %s (must be auto, file, or keyring)", + mode, + ) + } } func getConfig(flagValue, envKey, defaultValue string) string { @@ -223,73 +271,6 @@ func parseOAuthError(statusCode int, body []byte, action string) error { return fmt.Errorf("%s failed with status %d: %s", action, statusCode, string(body)) } -func loadTokens() (*tui.TokenStorage, error) { - data, err := os.ReadFile(tokenFile) - if err != nil { - return nil, err - } - var storageMap tui.TokenStorageMap - if err := json.Unmarshal(data, &storageMap); err != nil { - return nil, fmt.Errorf("failed to parse token file: %w", err) - } - if storageMap.Tokens == nil { - return nil, errors.New("no tokens in file") - } - if storage, ok := storageMap.Tokens[clientID]; ok { - return storage, nil - } - return nil, fmt.Errorf("no tokens found for client_id: %s", clientID) -} - -func saveTokens(storage *tui.TokenStorage) error { - if storage.ClientID == "" { - storage.ClientID = clientID - } - - lock, err := acquireFileLock(tokenFile) - if err != nil { - return fmt.Errorf("failed to acquire lock: %w", err) - } - defer func() { - if releaseErr := lock.release(); releaseErr != nil { - fmt.Fprintf(os.Stderr, "warning: failed to release file lock: %v\n", releaseErr) - } - }() - - var storageMap tui.TokenStorageMap - if existing, err := os.ReadFile(tokenFile); err == nil { - if unmarshalErr := json.Unmarshal(existing, &storageMap); unmarshalErr != nil { - storageMap.Tokens = make(map[string]*tui.TokenStorage) - } - } - if storageMap.Tokens == nil { - storageMap.Tokens = make(map[string]*tui.TokenStorage) - } - - storageMap.Tokens[storage.ClientID] = storage - - data, err := json.MarshalIndent(storageMap, "", " ") - if err != nil { - return err - } - - tempFile := tokenFile + ".tmp" - if err := os.WriteFile(tempFile, data, 0o600); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - if err := os.Rename(tempFile, tokenFile); err != nil { - if removeErr := os.Remove(tempFile); removeErr != nil { - return fmt.Errorf( - "failed to rename temp file: %v; also failed to remove temp file: %w", - err, - removeErr, - ) - } - return fmt.Errorf("failed to rename temp file: %w", err) - } - return nil -} - // validateTokenResponse performs basic sanity checks on a token response. func validateTokenResponse(accessToken, tokenType string, expiresIn int) error { if accessToken == "" { @@ -576,14 +557,16 @@ func main() { } deps := tui.Deps{ - LoadTokens: loadTokens, + LoadTokens: func() (*tui.TokenStorage, error) { + return tokenStore.Load(clientID) + }, RefreshToken: func(ctx context.Context, refreshToken string) (*tui.TokenStorage, string, error) { storage, err := refreshAccessToken(ctx, refreshToken) if err != nil { return nil, "", err } saveWarning := "" - if saveErr := saveTokens(storage); saveErr != nil { + if saveErr := tokenStore.Save(storage); saveErr != nil { saveWarning = fmt.Sprintf("Warning: Failed to save refreshed tokens: %v", saveErr) } return storage, saveWarning, nil @@ -594,7 +577,7 @@ func main() { OpenBrowser: openBrowser, StartCallback: startCallbackServer, ExchangeCode: exchangeCode, - SaveTokens: saveTokens, + SaveTokens: tokenStore.Save, VerifyToken: verifyToken, MakeAPICall: makeAPICallWithAutoRefresh, CallbackPort: callbackPort, diff --git a/main_test.go b/main_test.go index ec2eb6e..bedda2c 100644 --- a/main_test.go +++ b/main_test.go @@ -1,12 +1,12 @@ package main import ( - "encoding/json" - "os" + "path/filepath" "testing" "time" "github.com/go-authgate/oauth-cli/tui" + "github.com/go-authgate/sdk-go/tokenstore" ) func TestValidateServerURL(t *testing.T) { @@ -81,97 +81,63 @@ func TestValidateTokenResponse(t *testing.T) { } func TestSaveAndLoadTokens(t *testing.T) { - // Use a temp file for token storage. - tmpFile, err := os.CreateTemp(t.TempDir(), "tokens-*.json") - if err != nil { - t.Fatal(err) - } - tmpFile.Close() + // Use a non-existent path so FileStore starts fresh (empty file causes JSON parse error). + store := tokenstore.NewFileStore(filepath.Join(t.TempDir(), "tokens.json")) + const testClientID = "test-client-id" - originalTokenFile := tokenFile - originalClientID := clientID - t.Cleanup(func() { - tokenFile = originalTokenFile - clientID = originalClientID - }) - - tokenFile = tmpFile.Name() - clientID = "test-client-id" - - storage := &tui.TokenStorage{ + token := &tokenstore.Token{ AccessToken: "access-token-value", RefreshToken: "refresh-token-value", TokenType: "Bearer", ExpiresAt: time.Now().Add(time.Hour).UTC().Truncate(time.Second), - ClientID: clientID, + ClientID: testClientID, } - if err := saveTokens(storage); err != nil { - t.Fatalf("saveTokens() error: %v", err) + if err := store.Save(token); err != nil { + t.Fatalf("store.Save() error: %v", err) } - loaded, err := loadTokens() + loaded, err := store.Load(testClientID) if err != nil { - t.Fatalf("loadTokens() error: %v", err) + t.Fatalf("store.Load() error: %v", err) } - if loaded.AccessToken != storage.AccessToken { - t.Errorf("AccessToken mismatch: got %q, want %q", loaded.AccessToken, storage.AccessToken) + if loaded.AccessToken != token.AccessToken { + t.Errorf("AccessToken mismatch: got %q, want %q", loaded.AccessToken, token.AccessToken) } - if loaded.RefreshToken != storage.RefreshToken { - t.Errorf( - "RefreshToken mismatch: got %q, want %q", - loaded.RefreshToken, - storage.RefreshToken, - ) + if loaded.RefreshToken != token.RefreshToken { + t.Errorf("RefreshToken mismatch: got %q, want %q", loaded.RefreshToken, token.RefreshToken) } - if loaded.ClientID != storage.ClientID { - t.Errorf("ClientID mismatch: got %q, want %q", loaded.ClientID, storage.ClientID) + if loaded.ClientID != token.ClientID { + t.Errorf("ClientID mismatch: got %q, want %q", loaded.ClientID, token.ClientID) } } func TestSaveTokens_MultipleClients(t *testing.T) { - tmpFile, err := os.CreateTemp(t.TempDir(), "tokens-multi-*.json") - if err != nil { - t.Fatal(err) - } - tmpFile.Close() - - originalTokenFile := tokenFile - originalClientID := clientID - t.Cleanup(func() { - tokenFile = originalTokenFile - clientID = originalClientID - }) - - tokenFile = tmpFile.Name() + store := tokenstore.NewFileStore(filepath.Join(t.TempDir(), "tokens-multi.json")) // Save tokens for two different clients. for _, id := range []string{"client-a", "client-b"} { - clientID = id - if err := saveTokens(&tui.TokenStorage{ + if err := store.Save(&tokenstore.Token{ AccessToken: "token-" + id, RefreshToken: "refresh-" + id, TokenType: "Bearer", ExpiresAt: time.Now().Add(time.Hour), ClientID: id, }); err != nil { - t.Fatalf("saveTokens(%s) error: %v", id, err) + t.Fatalf("store.Save(%s) error: %v", id, err) } } - // Both clients should be present in the file. - data, _ := os.ReadFile(tokenFile) - var sm tui.TokenStorageMap - if err := json.Unmarshal(data, &sm); err != nil { - t.Fatalf("unmarshal error: %v", err) - } - if len(sm.Tokens) != 2 { - t.Errorf("expected 2 tokens, got %d", len(sm.Tokens)) - } + // Both clients should be loadable. for _, id := range []string{"client-a", "client-b"} { - if _, ok := sm.Tokens[id]; !ok { - t.Errorf("token for %s not found", id) + tok, err := store.Load(id) + if err != nil { + t.Errorf("store.Load(%s) error: %v", id, err) + continue + } + if tok.AccessToken != "token-"+id { + t.Errorf("client %s: AccessToken = %q, want %q", id, tok.AccessToken, "token-"+id) } } } @@ -232,6 +198,84 @@ func TestIsPublicClient(t *testing.T) { } } +func TestInitTokenStore_File(t *testing.T) { + store, warnings, err := initTokenStore( + "file", + filepath.Join(t.TempDir(), "tokens.json"), + "test-service", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } + if _, ok := store.(*tokenstore.FileStore); !ok { + t.Errorf("expected *tokenstore.FileStore, got %T", store) + } +} + +func TestInitTokenStore_Keyring(t *testing.T) { + store, warnings, err := initTokenStore( + "keyring", + filepath.Join(t.TempDir(), "tokens.json"), + "test-service", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } + if _, ok := store.(*tokenstore.KeyringStore); !ok { + t.Errorf("expected *tokenstore.KeyringStore, got %T", store) + } +} + +func TestInitTokenStore_Auto(t *testing.T) { + store, warnings, err := initTokenStore( + "auto", + filepath.Join(t.TempDir(), "tokens.json"), + "test-service", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + secureStore, ok := store.(*tokenstore.SecureStore) + if !ok { + t.Fatalf("expected *tokenstore.SecureStore, got %T", store) + } + // In CI / test environments the OS keyring is typically unavailable, + // so we expect the fallback warning. On systems with a keyring the + // warning list will be empty — both cases are valid. + if !secureStore.UseKeyring() { + if len(warnings) != 1 { + t.Errorf("expected 1 fallback warning, got %d: %v", len(warnings), warnings) + } + } else { + if len(warnings) != 0 { + t.Errorf("expected no warnings when keyring available, got %v", warnings) + } + } +} + +func TestInitTokenStore_Invalid(t *testing.T) { + store, _, err := initTokenStore( + "invalid", + filepath.Join(t.TempDir(), "tokens.json"), + "test-service", + ) + if err == nil { + t.Fatal("expected error for invalid mode, got nil") + } + if store != nil { + t.Errorf("expected nil store on error, got %T", store) + } + if !containsSubstring(err.Error(), "invalid token-store value") { + t.Errorf("unexpected error message: %v", err) + } +} + // containsSubstring is a helper to avoid importing strings in tests. func containsSubstring(s, sub string) bool { return len(s) >= len(sub) && findSubstring(s, sub) diff --git a/tui/types.go b/tui/types.go index 4276eb9..73885e6 100644 --- a/tui/types.go +++ b/tui/types.go @@ -2,25 +2,15 @@ package tui import ( "errors" - "time" + + "github.com/go-authgate/sdk-go/tokenstore" ) // ErrRefreshTokenExpired indicates the refresh token has expired or is invalid. var ErrRefreshTokenExpired = errors.New("refresh token expired or invalid") // TokenStorage holds persisted OAuth tokens for one client. -type TokenStorage struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresAt time.Time `json:"expires_at"` - ClientID string `json:"client_id"` -} - -// TokenStorageMap manages tokens for multiple clients in one file. -type TokenStorageMap struct { - Tokens map[string]*TokenStorage `json:"tokens"` -} +type TokenStorage = tokenstore.Token // PKCEParams holds the code verifier and challenge for PKCE (RFC 7636). type PKCEParams struct {