diff --git a/.env.example b/.env.example index 427e4cc..eb5bbc8 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ SCOPE=read write # Token storage TOKEN_FILE=.authgate-tokens.json +# Token storage backend: auto (default), file, keyring +# auto = use OS keyring if available, fallback to TOKEN_FILE +TOKEN_STORE=auto diff --git a/go.mod b/go.mod index f31c7d5..d36e97a 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,19 @@ module github.com/go-authgate/oauth-cli -go 1.24.2 +go 1.25.0 require ( charm.land/bubbles/v2 v2.0.0 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/go-authgate/sdk-go v0.2.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect + al.essio.dev/pkg/shellescape v1.6.0 // 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 @@ -22,8 +22,8 @@ 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/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // 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 @@ -31,5 +31,5 @@ require ( 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 + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 4e6ae3f..e5e4ad6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/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= @@ -30,12 +32,18 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ 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/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= 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/go-authgate/sdk-go v0.2.0 h1:w22f+sAg/YMqnLOcS/4SAuMZXTbPurzkSQBsjb1hcbw= +github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho= 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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 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= @@ -66,5 +74,7 @@ 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= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 c80cdf3..f8c3595 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ import ( "time" "github.com/go-authgate/oauth-cli/tui" - "github.com/go-authgate/sdk-go/tokenstore" + "github.com/go-authgate/sdk-go/credstore" tea "charm.land/bubbletea/v2" retry "github.com/appleboy/go-httpretry" @@ -36,7 +36,7 @@ var ( scope string tokenFile string tokenStoreMode string - tokenStore tokenstore.Store + tokenStore credstore.Store[credstore.Token] configInitialized bool retryClient *retry.Client configWarnings []string @@ -180,23 +180,24 @@ func initConfig() { // 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) +func initTokenStore( + mode, filePath, keyringService string, +) (credstore.Store[credstore.Token], []string, error) { + fileStore := credstore.NewTokenFileStore(filePath) var warnings []string switch mode { case "file": return fileStore, nil, nil case "keyring": - return tokenstore.NewKeyringStore(keyringService), nil, nil + return credstore.NewTokenKeyringStore(keyringService), nil, nil case "auto": - kr := tokenstore.NewKeyringStore(keyringService) - store := tokenstore.NewSecureStore(kr, fileStore) - if !store.UseKeyring() { + ss := credstore.DefaultTokenSecureStore(keyringService, filePath) + if !ss.UseKeyring() { warnings = append(warnings, "OS keyring unavailable, falling back to file-based token storage") } - return store, warnings, nil + return ss, warnings, nil default: return nil, nil, fmt.Errorf( "invalid token-store value: %s (must be auto, file, or keyring)", @@ -558,7 +559,11 @@ func main() { deps := tui.Deps{ LoadTokens: func() (*tui.TokenStorage, error) { - return tokenStore.Load(clientID) + tok, err := tokenStore.Load(clientID) + if err != nil { + return nil, err + } + return &tok, nil }, RefreshToken: func(ctx context.Context, refreshToken string) (*tui.TokenStorage, string, error) { storage, err := refreshAccessToken(ctx, refreshToken) @@ -566,7 +571,7 @@ func main() { return nil, "", err } saveWarning := "" - if saveErr := tokenStore.Save(storage); saveErr != nil { + if saveErr := tokenStore.Save(storage.ClientID, *storage); saveErr != nil { saveWarning = fmt.Sprintf("Warning: Failed to save refreshed tokens: %v", saveErr) } return storage, saveWarning, nil @@ -577,10 +582,12 @@ func main() { OpenBrowser: openBrowser, StartCallback: startCallbackServer, ExchangeCode: exchangeCode, - SaveTokens: tokenStore.Save, - VerifyToken: verifyToken, - MakeAPICall: makeAPICallWithAutoRefresh, - CallbackPort: callbackPort, + SaveTokens: func(storage *tui.TokenStorage) error { + return tokenStore.Save(storage.ClientID, *storage) + }, + VerifyToken: verifyToken, + MakeAPICall: makeAPICallWithAutoRefresh, + CallbackPort: callbackPort, } p := tea.NewProgram( diff --git a/main_test.go b/main_test.go index bedda2c..dc799af 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/go-authgate/oauth-cli/tui" - "github.com/go-authgate/sdk-go/tokenstore" + "github.com/go-authgate/sdk-go/credstore" ) func TestValidateServerURL(t *testing.T) { @@ -82,10 +82,10 @@ func TestValidateTokenResponse(t *testing.T) { func TestSaveAndLoadTokens(t *testing.T) { // Use a non-existent path so FileStore starts fresh (empty file causes JSON parse error). - store := tokenstore.NewFileStore(filepath.Join(t.TempDir(), "tokens.json")) + store := credstore.NewTokenFileStore(filepath.Join(t.TempDir(), "tokens.json")) const testClientID = "test-client-id" - token := &tokenstore.Token{ + token := credstore.Token{ AccessToken: "access-token-value", RefreshToken: "refresh-token-value", TokenType: "Bearer", @@ -93,7 +93,7 @@ func TestSaveAndLoadTokens(t *testing.T) { ClientID: testClientID, } - if err := store.Save(token); err != nil { + if err := store.Save(testClientID, token); err != nil { t.Fatalf("store.Save() error: %v", err) } @@ -114,11 +114,11 @@ func TestSaveAndLoadTokens(t *testing.T) { } func TestSaveTokens_MultipleClients(t *testing.T) { - store := tokenstore.NewFileStore(filepath.Join(t.TempDir(), "tokens-multi.json")) + store := credstore.NewTokenFileStore(filepath.Join(t.TempDir(), "tokens-multi.json")) // Save tokens for two different clients. for _, id := range []string{"client-a", "client-b"} { - if err := store.Save(&tokenstore.Token{ + if err := store.Save(id, credstore.Token{ AccessToken: "token-" + id, RefreshToken: "refresh-" + id, TokenType: "Bearer", @@ -210,8 +210,8 @@ func TestInitTokenStore_File(t *testing.T) { 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) + if _, ok := store.(*credstore.FileStore[credstore.Token]); !ok { + t.Errorf("expected *credstore.FileStore[credstore.Token], got %T", store) } } @@ -227,8 +227,8 @@ func TestInitTokenStore_Keyring(t *testing.T) { 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) + if _, ok := store.(*credstore.KeyringStore[credstore.Token]); !ok { + t.Errorf("expected *credstore.KeyringStore[credstore.Token], got %T", store) } } @@ -241,9 +241,9 @@ func TestInitTokenStore_Auto(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - secureStore, ok := store.(*tokenstore.SecureStore) + secureStore, ok := store.(*credstore.SecureStore[credstore.Token]) if !ok { - t.Fatalf("expected *tokenstore.SecureStore, got %T", store) + t.Fatalf("expected *credstore.SecureStore[credstore.Token], 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 diff --git a/tui/types.go b/tui/types.go index 73885e6..0fec9bf 100644 --- a/tui/types.go +++ b/tui/types.go @@ -3,14 +3,14 @@ package tui import ( "errors" - "github.com/go-authgate/sdk-go/tokenstore" + "github.com/go-authgate/sdk-go/credstore" ) // 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 = tokenstore.Token +type TokenStorage = credstore.Token // PKCEParams holds the code verifier and challenge for PKCE (RFC 7636). type PKCEParams struct {