From 11e28b06d8a89a447bf3a957d9857dc0a25fd74f Mon Sep 17 00:00:00 2001 From: Ramon Macias Date: Sat, 7 Mar 2026 20:43:47 -0500 Subject: [PATCH] fix: scope all token operations to the active app The --app flag was silently ignored across the codebase. While WithAppName correctly stored the app name, every subsequent token read, write, and clear still resolved to the default app. - fix token reads in auth.go (GetOAuth1Header, GetOAuth2Header, RefreshOAuth2Token, GetBearerTokenHeader) to use ForApp variants - fix OAuth2 token saves in OAuth2Flow and RefreshOAuth2Token to write back to the named app instead of the default - fix WithAppName to replace credentials even when env vars were already set - fix auto-detection probes in api/client.go (getAuthHeader) to check the named app for available tokens - fix cli/auth.go save and clear commands (bearer, oauth1, clear) to operate on the named app - fix cli/webhook.go CRC validation to read OAuth1 secret from the named app - add AppName() getter on Auth to expose appName to the api package - add tests covering token isolation per app, credential override, refresh token save target, and default-app regression guards --- api/client.go | 5 +- api/client_test.go | 34 ++++++++++ auth/auth.go | 25 ++++--- auth/auth_test.go | 159 +++++++++++++++++++++++++++++++++++++++++++++ cli/auth.go | 12 ++-- cli/webhook.go | 2 +- 6 files changed, 218 insertions(+), 19 deletions(-) diff --git a/api/client.go b/api/client.go index 494fbd8..fcb192f 100644 --- a/api/client.go +++ b/api/client.go @@ -346,7 +346,8 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username } // If no auth type is specified, try to use the first OAuth2 token - token := c.auth.TokenStore.GetFirstOAuth2Token() + appName := c.auth.AppName() + token := c.auth.TokenStore.GetFirstOAuth2TokenForApp(appName) if token != nil { accessToken, err := c.auth.GetOAuth2Header(username) if err == nil { @@ -355,7 +356,7 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username } // If no OAuth2 token is available, try to use the first OAuth1 token - token = c.auth.TokenStore.GetOAuth1Tokens() + token = c.auth.TokenStore.GetOAuth1TokensForApp(appName) if token != nil { authHeader, err := c.auth.GetOAuth1Header(method, url, nil) if err == nil { diff --git a/api/client_test.go b/api/client_test.go index 869ed2b..a0db1b0 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -306,6 +306,40 @@ func TestGetAuthHeader(t *testing.T) { assert.Error(t, err, "Expected an error") assert.True(t, xurlErrors.IsAuthError(err), "Expected auth error") }) + + t.Run("Auto-detect uses named app bearer token when --app is set", func(t *testing.T) { + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + tokenStore.AddApp("my-app", "id", "secret") + // Bearer token only in my-app, not in default + tokenStore.SaveBearerTokenForApp("my-app", "bearer-my-app") + + a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore).WithAppName("my-app") + client := NewApiClient(cfg, a) + + header, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "") + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-my-app", header) + }) + + t.Run("Auto-detect falls back to default app when no --app flag", func(t *testing.T) { + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + tokenStore.AddApp("other-app", "id", "secret") + // Bearer token only in default app + tokenStore.SaveBearerTokenForApp("default", "bearer-default") + tokenStore.SaveBearerTokenForApp("other-app", "bearer-other") + + // No WithAppName — should use default + a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore) + client := NewApiClient(cfg, a) + + header, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "") + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-default", header) + }) } func TestStreamRequest(t *testing.T) { diff --git a/auth/auth.go b/auth/auth.go index 9cffaa2..eccb4fe 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -77,15 +77,20 @@ func (a *Auth) WithTokenStore(tokenStore *store.TokenStore) *Auth { return a } +// AppName returns the active app name override (empty means use default). +func (a *Auth) AppName() string { + return a.appName +} + // WithAppName sets the explicit app name override. func (a *Auth) WithAppName(appName string) *Auth { a.appName = appName app := a.TokenStore.ResolveApp(appName) if app != nil { - if a.clientID == "" { + if app.ClientID != "" { a.clientID = app.ClientID } - if a.clientSecret == "" { + if app.ClientSecret != "" { a.clientSecret = app.ClientSecret } } @@ -94,7 +99,7 @@ func (a *Auth) WithAppName(appName string) *Auth { // GetOAuth1Header gets the OAuth1 header for a request func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[string]string) (string, error) { - token := a.TokenStore.GetOAuth1Tokens() + token := a.TokenStore.GetOAuth1TokensForApp(a.appName) if token == nil || token.OAuth1 == nil { return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("OAuth1 token not found")) } @@ -146,9 +151,9 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) { var token *store.Token if username != "" { - token = a.TokenStore.GetOAuth2Token(username) + token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username) } else { - token = a.TokenStore.GetFirstOAuth2Token() + token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName) } if token == nil { @@ -253,7 +258,7 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) - err = a.TokenStore.SaveOAuth2Token(usernameStr, token.AccessToken, token.RefreshToken, expirationTime) + err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, token.AccessToken, token.RefreshToken, expirationTime) if err != nil { return "", xurlErrors.NewAuthError("TokenStorageError", err) } @@ -266,9 +271,9 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { var token *store.Token if username != "" { - token = a.TokenStore.GetOAuth2Token(username) + token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username) } else { - token = a.TokenStore.GetFirstOAuth2Token() + token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName) } if token == nil || token.OAuth2 == nil { @@ -310,7 +315,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { expirationTime := uint64(time.Now().Add(time.Duration(newToken.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) - err = a.TokenStore.SaveOAuth2Token(usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime) + err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime) if err != nil { return "", xurlErrors.NewAuthError("RefreshTokenError", err) } @@ -320,7 +325,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { // GetBearerTokenHeader gets the bearer token from the token store func (a *Auth) GetBearerTokenHeader() (string, error) { - token := a.TokenStore.GetBearerToken() + token := a.TokenStore.GetBearerTokenForApp(a.appName) if token == nil { return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("bearer token not found")) } diff --git a/auth/auth_test.go b/auth/auth_test.go index 9faebbb..dfededa 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -1,9 +1,13 @@ package auth import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -209,6 +213,89 @@ func TestWithAppName(t *testing.T) { assert.Equal(t, "other-secret", a.clientSecret) } +func TestWithAppNameOverridesEnvCredentials(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xurl_auth_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + t.Setenv("HOME", tempDir) + + tokenStore, tsDir := createTempTokenStore(t) + defer os.RemoveAll(tsDir) + tokenStore.AddApp("my-app", "app-id", "app-secret") + + // Simulate env vars being set at startup + cfg := &config.Config{ClientID: "env-id", ClientSecret: "env-secret"} + a := NewAuth(cfg).WithTokenStore(tokenStore) + assert.Equal(t, "env-id", a.clientID) + + // --app override should replace env-var credentials with the named app's + a.WithAppName("my-app") + assert.Equal(t, "app-id", a.clientID) + assert.Equal(t, "app-secret", a.clientSecret) +} + +func TestAppFlagTokenIsolation(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xurl_auth_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + t.Setenv("HOME", tempDir) + + tokenStore, tsDir := createTempTokenStore(t) + defer os.RemoveAll(tsDir) + + tokenStore.AddApp("app-a", "id-a", "secret-a") + tokenStore.AddApp("app-b", "id-b", "secret-b") + + // Save a bearer token only in app-a + tokenStore.SaveBearerTokenForApp("app-a", "bearer-for-a") + + // Save OAuth1 tokens only in app-b + tokenStore.SaveOAuth1TokensForApp("app-b", "at-b", "ts-b", "ck-b", "cs-b") + + // Save OAuth2 token only in app-a + tokenStore.SaveOAuth2TokenForApp("app-a", "alice", "oauth2-for-a", "refresh-a", 9999999999) + + t.Run("Bearer token from named app", func(t *testing.T) { + cfg := &config.Config{} + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-a") + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-for-a", header) + }) + + t.Run("Bearer token not found in other app", func(t *testing.T) { + cfg := &config.Config{} + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-b") + _, err := a.GetBearerTokenHeader() + assert.Error(t, err, "app-b has no bearer token, expected error") + }) + + t.Run("OAuth1 header from named app", func(t *testing.T) { + cfg := &config.Config{} + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-b") + header, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) + require.NoError(t, err) + assert.Contains(t, header, "OAuth ") + }) + + t.Run("OAuth1 not found in other app", func(t *testing.T) { + cfg := &config.Config{} + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-a") + _, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) + assert.Error(t, err, "app-a has no OAuth1 token, expected error") + }) + + t.Run("Default app used when no --app flag", func(t *testing.T) { + tokenStore.SetDefaultApp("app-a") + cfg := &config.Config{} + // No WithAppName call — appName stays "" + a := NewAuth(cfg).WithTokenStore(tokenStore) + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-for-a", header) + }) +} + func TestWithAppNameNonexistent(t *testing.T) { tempDir, err := os.MkdirTemp("", "xurl_auth_test") require.NoError(t, err) @@ -264,3 +351,75 @@ func TestGetOAuth2HeaderNoToken(t *testing.T) { token := tokenStore.GetOAuth2Token("nobody") assert.Nil(t, token) } + +// mockTokenServer returns an httptest.Server that responds to token refresh +// requests with a new access token. +func mockTokenServer(t *testing.T, accessToken, refreshToken string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": refreshToken, + }) + })) +} + +func TestRefreshOAuth2TokenSavesToNamedApp(t *testing.T) { + server := mockTokenServer(t, "new-access-token", "new-refresh-token") + defer server.Close() + + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + tokenStore.AddApp("my-app", "client-id", "client-secret") + + // Save an already-expired token to "my-app" + expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix()) + tokenStore.SaveOAuth2TokenForApp("my-app", "alice", "old-access", "old-refresh", expiredTime) + + cfg := &config.Config{TokenURL: server.URL + "/token"} + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("my-app") + + newToken, err := a.RefreshOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "new-access-token", newToken) + + // Refreshed token must be saved to "my-app", not the default app + tok := tokenStore.GetOAuth2TokenForApp("my-app", "alice") + require.NotNil(t, tok) + assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken) + + // Default app must not have received the token + assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", "alice")) +} + +func TestRefreshOAuth2TokenSavesToDefaultAppWhenNoOverride(t *testing.T) { + server := mockTokenServer(t, "new-access-token", "new-refresh-token") + defer server.Close() + + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + tokenStore.Apps["default"].ClientID = "client-id" + tokenStore.Apps["default"].ClientSecret = "client-secret" + + // Save an expired token to the default app + expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix()) + tokenStore.SaveOAuth2TokenForApp("default", "bob", "old-access", "old-refresh", expiredTime) + + cfg := &config.Config{TokenURL: server.URL + "/token"} + // No WithAppName — appName stays "" + a := NewAuth(cfg).WithTokenStore(tokenStore) + + newToken, err := a.RefreshOAuth2Token("bob") + require.NoError(t, err) + assert.Equal(t, "new-access-token", newToken) + + // Token must be saved back to the default app + tok := tokenStore.GetOAuth2TokenForApp("default", "bob") + require.NotNil(t, tok) + assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken) +} diff --git a/cli/auth.go b/cli/auth.go index 6fa04d6..3d5206f 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -37,7 +37,7 @@ func createAuthBearerCmd(a *auth.Auth) *cobra.Command { Use: "app", Short: "Configure app-auth (bearer token)", Run: func(cmd *cobra.Command, args []string) { - err := a.TokenStore.SaveBearerToken(bearerToken) + err := a.TokenStore.SaveBearerTokenForApp(a.AppName(), bearerToken) if err != nil { fmt.Println("Error saving bearer token:", err) os.Exit(1) @@ -80,7 +80,7 @@ func createAuthOAuth1Cmd(a *auth.Auth) *cobra.Command { Use: "oauth1", Short: "Configure OAuth1 authentication", Run: func(cmd *cobra.Command, args []string) { - err := a.TokenStore.SaveOAuth1Tokens(accessToken, tokenSecret, consumerKey, consumerSecret) + err := a.TokenStore.SaveOAuth1TokensForApp(a.AppName(), accessToken, tokenSecret, consumerKey, consumerSecret) if err != nil { fmt.Println("Error saving OAuth1 tokens:", err) os.Exit(1) @@ -182,28 +182,28 @@ func createAuthClearCmd(a *auth.Auth) *cobra.Command { Short: "Clear authentication tokens", Run: func(cmd *cobra.Command, args []string) { if all { - err := a.TokenStore.ClearAll() + err := a.TokenStore.ClearAllForApp(a.AppName()) if err != nil { fmt.Println("Error clearing all tokens:", err) os.Exit(1) } fmt.Println("All authentication cleared!") } else if oauth1 { - err := a.TokenStore.ClearOAuth1Tokens() + err := a.TokenStore.ClearOAuth1TokensForApp(a.AppName()) if err != nil { fmt.Println("Error clearing OAuth1 tokens:", err) os.Exit(1) } fmt.Println("OAuth1 tokens cleared!") } else if oauth2Username != "" { - err := a.TokenStore.ClearOAuth2Token(oauth2Username) + err := a.TokenStore.ClearOAuth2TokenForApp(a.AppName(), oauth2Username) if err != nil { fmt.Println("Error clearing OAuth2 token:", err) os.Exit(1) } fmt.Println("OAuth2 token cleared for", oauth2Username+"!") } else if bearer { - err := a.TokenStore.ClearBearerToken() + err := a.TokenStore.ClearBearerTokenForApp(a.AppName()) if err != nil { fmt.Println("Error clearing bearer token:", err) os.Exit(1) diff --git a/cli/webhook.go b/cli/webhook.go index 5568a5a..4a6675d 100644 --- a/cli/webhook.go +++ b/cli/webhook.go @@ -48,7 +48,7 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { os.Exit(1) } - oauth1Token := authInstance.TokenStore.GetOAuth1Tokens() + oauth1Token := authInstance.TokenStore.GetOAuth1TokensForApp(authInstance.AppName()) if oauth1Token == nil || oauth1Token.OAuth1 == nil || oauth1Token.OAuth1.ConsumerSecret == "" { color.Red("Error: OAuth 1.0a consumer secret not found. Please configure OAuth 1.0a credentials using 'xurl auth oauth1'.") os.Exit(1)