Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 15 additions & 10 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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"))
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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"))
}
Expand Down
159 changes: 159 additions & 0 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
12 changes: 6 additions & 6 deletions cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cli/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down