Skip to content

Commit eeeb501

Browse files
fix: scope all token operations to the active app (#46)
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 Signed-off-by: Santiago Medina <santiagm08@gmail.com> Co-authored-by: Santiago Medina <santiagm08@gmail.com>
1 parent 3264654 commit eeeb501

6 files changed

Lines changed: 219 additions & 19 deletions

File tree

api/client.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,8 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username
346346
}
347347

348348
// If no auth type is specified, try to use the first OAuth2 token
349-
token := c.auth.TokenStore.GetFirstOAuth2Token()
349+
appName := c.auth.AppName()
350+
token := c.auth.TokenStore.GetFirstOAuth2TokenForApp(appName)
350351
if token != nil {
351352
accessToken, err := c.auth.GetOAuth2Header(username)
352353
if err == nil {
@@ -355,7 +356,7 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username
355356
}
356357

357358
// If no OAuth2 token is available, try to use the first OAuth1 token
358-
token = c.auth.TokenStore.GetOAuth1Tokens()
359+
token = c.auth.TokenStore.GetOAuth1TokensForApp(appName)
359360
if token != nil {
360361
authHeader, err := c.auth.GetOAuth1Header(method, url, nil)
361362
if err == nil {

api/client_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,40 @@ func TestGetAuthHeader(t *testing.T) {
306306
assert.Error(t, err, "Expected an error")
307307
assert.True(t, xurlErrors.IsAuthError(err), "Expected auth error")
308308
})
309+
310+
t.Run("Auto-detect uses named app bearer token when --app is set", func(t *testing.T) {
311+
tokenStore, tempDir := createTempTokenStore(t)
312+
defer os.RemoveAll(tempDir)
313+
314+
tokenStore.AddApp("my-app", "id", "secret")
315+
// Bearer token only in my-app, not in default
316+
tokenStore.SaveBearerTokenForApp("my-app", "bearer-my-app")
317+
318+
a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore).WithAppName("my-app")
319+
client := NewApiClient(cfg, a)
320+
321+
header, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "")
322+
require.NoError(t, err)
323+
assert.Equal(t, "Bearer bearer-my-app", header)
324+
})
325+
326+
t.Run("Auto-detect falls back to default app when no --app flag", func(t *testing.T) {
327+
tokenStore, tempDir := createTempTokenStore(t)
328+
defer os.RemoveAll(tempDir)
329+
330+
tokenStore.AddApp("other-app", "id", "secret")
331+
// Bearer token only in default app
332+
tokenStore.SaveBearerTokenForApp("default", "bearer-default")
333+
tokenStore.SaveBearerTokenForApp("other-app", "bearer-other")
334+
335+
// No WithAppName — should use default
336+
a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore)
337+
client := NewApiClient(cfg, a)
338+
339+
header, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "")
340+
require.NoError(t, err)
341+
assert.Equal(t, "Bearer bearer-default", header)
342+
})
309343
}
310344

311345
func TestStreamRequest(t *testing.T) {

auth/auth.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,20 @@ func (a *Auth) WithTokenStore(tokenStore *store.TokenStore) *Auth {
7777
return a
7878
}
7979

80+
// AppName returns the active app name override (empty means use default).
81+
func (a *Auth) AppName() string {
82+
return a.appName
83+
}
84+
8085
// WithAppName sets the explicit app name override.
8186
func (a *Auth) WithAppName(appName string) *Auth {
8287
a.appName = appName
8388
app := a.TokenStore.ResolveApp(appName)
8489
if app != nil {
85-
if a.clientID == "" {
90+
if app.ClientID != "" {
8691
a.clientID = app.ClientID
8792
}
88-
if a.clientSecret == "" {
93+
if app.ClientSecret != "" {
8994
a.clientSecret = app.ClientSecret
9095
}
9196
}
@@ -94,7 +99,7 @@ func (a *Auth) WithAppName(appName string) *Auth {
9499

95100
// GetOAuth1Header gets the OAuth1 header for a request
96101
func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[string]string) (string, error) {
97-
token := a.TokenStore.GetOAuth1Tokens()
102+
token := a.TokenStore.GetOAuth1TokensForApp(a.appName)
98103
if token == nil || token.OAuth1 == nil {
99104
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("OAuth1 token not found"))
100105
}
@@ -146,9 +151,9 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) {
146151
var token *store.Token
147152

148153
if username != "" {
149-
token = a.TokenStore.GetOAuth2Token(username)
154+
token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username)
150155
} else {
151-
token = a.TokenStore.GetFirstOAuth2Token()
156+
token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName)
152157
}
153158

154159
if token == nil {
@@ -253,7 +258,7 @@ func (a *Auth) OAuth2Flow(username string) (string, error) {
253258

254259
expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix())
255260

256-
err = a.TokenStore.SaveOAuth2Token(usernameStr, token.AccessToken, token.RefreshToken, expirationTime)
261+
err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, token.AccessToken, token.RefreshToken, expirationTime)
257262
if err != nil {
258263
return "", xurlErrors.NewAuthError("TokenStorageError", err)
259264
}
@@ -266,9 +271,9 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) {
266271
var token *store.Token
267272

268273
if username != "" {
269-
token = a.TokenStore.GetOAuth2Token(username)
274+
token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username)
270275
} else {
271-
token = a.TokenStore.GetFirstOAuth2Token()
276+
token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName)
272277
}
273278

274279
if token == nil || token.OAuth2 == nil {
@@ -310,7 +315,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) {
310315

311316
expirationTime := uint64(time.Now().Add(time.Duration(newToken.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix())
312317

313-
err = a.TokenStore.SaveOAuth2Token(usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime)
318+
err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime)
314319
if err != nil {
315320
return "", xurlErrors.NewAuthError("RefreshTokenError", err)
316321
}
@@ -320,7 +325,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) {
320325

321326
// GetBearerTokenHeader gets the bearer token from the token store
322327
func (a *Auth) GetBearerTokenHeader() (string, error) {
323-
token := a.TokenStore.GetBearerToken()
328+
token := a.TokenStore.GetBearerTokenForApp(a.appName)
324329
if token == nil {
325330
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("bearer token not found"))
326331
}

auth/auth_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package auth
22

33
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
47
"os"
58
"path/filepath"
69
"testing"
10+
"time"
711

812
"github.com/stretchr/testify/assert"
913
"github.com/stretchr/testify/require"
@@ -209,6 +213,89 @@ func TestWithAppName(t *testing.T) {
209213
assert.Equal(t, "other-secret", a.clientSecret)
210214
}
211215

216+
func TestWithAppNameOverridesEnvCredentials(t *testing.T) {
217+
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
218+
require.NoError(t, err)
219+
defer os.RemoveAll(tempDir)
220+
t.Setenv("HOME", tempDir)
221+
222+
tokenStore, tsDir := createTempTokenStore(t)
223+
defer os.RemoveAll(tsDir)
224+
tokenStore.AddApp("my-app", "app-id", "app-secret")
225+
226+
// Simulate env vars being set at startup
227+
cfg := &config.Config{ClientID: "env-id", ClientSecret: "env-secret"}
228+
a := NewAuth(cfg).WithTokenStore(tokenStore)
229+
assert.Equal(t, "env-id", a.clientID)
230+
231+
// --app override should replace env-var credentials with the named app's
232+
a.WithAppName("my-app")
233+
assert.Equal(t, "app-id", a.clientID)
234+
assert.Equal(t, "app-secret", a.clientSecret)
235+
}
236+
237+
func TestAppFlagTokenIsolation(t *testing.T) {
238+
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
239+
require.NoError(t, err)
240+
defer os.RemoveAll(tempDir)
241+
t.Setenv("HOME", tempDir)
242+
243+
tokenStore, tsDir := createTempTokenStore(t)
244+
defer os.RemoveAll(tsDir)
245+
246+
tokenStore.AddApp("app-a", "id-a", "secret-a")
247+
tokenStore.AddApp("app-b", "id-b", "secret-b")
248+
249+
// Save a bearer token only in app-a
250+
tokenStore.SaveBearerTokenForApp("app-a", "bearer-for-a")
251+
252+
// Save OAuth1 tokens only in app-b
253+
tokenStore.SaveOAuth1TokensForApp("app-b", "at-b", "ts-b", "ck-b", "cs-b")
254+
255+
// Save OAuth2 token only in app-a
256+
tokenStore.SaveOAuth2TokenForApp("app-a", "alice", "oauth2-for-a", "refresh-a", 9999999999)
257+
258+
t.Run("Bearer token from named app", func(t *testing.T) {
259+
cfg := &config.Config{}
260+
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-a")
261+
header, err := a.GetBearerTokenHeader()
262+
require.NoError(t, err)
263+
assert.Equal(t, "Bearer bearer-for-a", header)
264+
})
265+
266+
t.Run("Bearer token not found in other app", func(t *testing.T) {
267+
cfg := &config.Config{}
268+
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-b")
269+
_, err := a.GetBearerTokenHeader()
270+
assert.Error(t, err, "app-b has no bearer token, expected error")
271+
})
272+
273+
t.Run("OAuth1 header from named app", func(t *testing.T) {
274+
cfg := &config.Config{}
275+
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-b")
276+
header, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil)
277+
require.NoError(t, err)
278+
assert.Contains(t, header, "OAuth ")
279+
})
280+
281+
t.Run("OAuth1 not found in other app", func(t *testing.T) {
282+
cfg := &config.Config{}
283+
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-a")
284+
_, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil)
285+
assert.Error(t, err, "app-a has no OAuth1 token, expected error")
286+
})
287+
288+
t.Run("Default app used when no --app flag", func(t *testing.T) {
289+
tokenStore.SetDefaultApp("app-a")
290+
cfg := &config.Config{}
291+
// No WithAppName call — appName stays ""
292+
a := NewAuth(cfg).WithTokenStore(tokenStore)
293+
header, err := a.GetBearerTokenHeader()
294+
require.NoError(t, err)
295+
assert.Equal(t, "Bearer bearer-for-a", header)
296+
})
297+
}
298+
212299
func TestWithAppNameNonexistent(t *testing.T) {
213300
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
214301
require.NoError(t, err)
@@ -265,6 +352,79 @@ func TestGetOAuth2HeaderNoToken(t *testing.T) {
265352
assert.Nil(t, token)
266353
}
267354

355+
356+
// mockTokenServer returns an httptest.Server that responds to token refresh
357+
// requests with a new access token.
358+
func mockTokenServer(t *testing.T, accessToken, refreshToken string) *httptest.Server {
359+
t.Helper()
360+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
361+
w.Header().Set("Content-Type", "application/json")
362+
json.NewEncoder(w).Encode(map[string]interface{}{
363+
"access_token": accessToken,
364+
"token_type": "Bearer",
365+
"expires_in": 3600,
366+
"refresh_token": refreshToken,
367+
})
368+
}))
369+
}
370+
371+
func TestRefreshOAuth2TokenSavesToNamedApp(t *testing.T) {
372+
server := mockTokenServer(t, "new-access-token", "new-refresh-token")
373+
defer server.Close()
374+
375+
tokenStore, tempDir := createTempTokenStore(t)
376+
defer os.RemoveAll(tempDir)
377+
378+
tokenStore.AddApp("my-app", "client-id", "client-secret")
379+
380+
// Save an already-expired token to "my-app"
381+
expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix())
382+
tokenStore.SaveOAuth2TokenForApp("my-app", "alice", "old-access", "old-refresh", expiredTime)
383+
384+
cfg := &config.Config{TokenURL: server.URL + "/token"}
385+
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("my-app")
386+
387+
newToken, err := a.RefreshOAuth2Token("alice")
388+
require.NoError(t, err)
389+
assert.Equal(t, "new-access-token", newToken)
390+
391+
// Refreshed token must be saved to "my-app", not the default app
392+
tok := tokenStore.GetOAuth2TokenForApp("my-app", "alice")
393+
require.NotNil(t, tok)
394+
assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken)
395+
396+
// Default app must not have received the token
397+
assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", "alice"))
398+
}
399+
400+
func TestRefreshOAuth2TokenSavesToDefaultAppWhenNoOverride(t *testing.T) {
401+
server := mockTokenServer(t, "new-access-token", "new-refresh-token")
402+
defer server.Close()
403+
404+
tokenStore, tempDir := createTempTokenStore(t)
405+
defer os.RemoveAll(tempDir)
406+
407+
tokenStore.Apps["default"].ClientID = "client-id"
408+
tokenStore.Apps["default"].ClientSecret = "client-secret"
409+
410+
// Save an expired token to the default app
411+
expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix())
412+
tokenStore.SaveOAuth2TokenForApp("default", "bob", "old-access", "old-refresh", expiredTime)
413+
414+
cfg := &config.Config{TokenURL: server.URL + "/token"}
415+
// No WithAppName — appName stays ""
416+
a := NewAuth(cfg).WithTokenStore(tokenStore)
417+
418+
newToken, err := a.RefreshOAuth2Token("bob")
419+
require.NoError(t, err)
420+
assert.Equal(t, "new-access-token", newToken)
421+
422+
// Token must be saved back to the default app
423+
tok := tokenStore.GetOAuth2TokenForApp("default", "bob")
424+
require.NotNil(t, tok)
425+
assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken)
426+
}
427+
268428
func TestBrowserLaunchCommand(t *testing.T) {
269429
url := "https://x.com/i/oauth2/authorize?client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&scope=tweet.read+users.read&state=123&code_challenge=xyz&code_challenge_method=S256"
270430

cli/auth.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func createAuthBearerCmd(a *auth.Auth) *cobra.Command {
3737
Use: "app",
3838
Short: "Configure app-auth (bearer token)",
3939
Run: func(cmd *cobra.Command, args []string) {
40-
err := a.TokenStore.SaveBearerToken(bearerToken)
40+
err := a.TokenStore.SaveBearerTokenForApp(a.AppName(), bearerToken)
4141
if err != nil {
4242
fmt.Println("Error saving bearer token:", err)
4343
os.Exit(1)
@@ -85,7 +85,7 @@ func createAuthOAuth1Cmd(a *auth.Auth) *cobra.Command {
8585
Use: "oauth1",
8686
Short: "Configure OAuth1 authentication",
8787
Run: func(cmd *cobra.Command, args []string) {
88-
err := a.TokenStore.SaveOAuth1Tokens(accessToken, tokenSecret, consumerKey, consumerSecret)
88+
err := a.TokenStore.SaveOAuth1TokensForApp(a.AppName(), accessToken, tokenSecret, consumerKey, consumerSecret)
8989
if err != nil {
9090
fmt.Println("Error saving OAuth1 tokens:", err)
9191
os.Exit(1)
@@ -187,28 +187,28 @@ func createAuthClearCmd(a *auth.Auth) *cobra.Command {
187187
Short: "Clear authentication tokens",
188188
Run: func(cmd *cobra.Command, args []string) {
189189
if all {
190-
err := a.TokenStore.ClearAll()
190+
err := a.TokenStore.ClearAllForApp(a.AppName())
191191
if err != nil {
192192
fmt.Println("Error clearing all tokens:", err)
193193
os.Exit(1)
194194
}
195195
fmt.Println("All authentication cleared!")
196196
} else if oauth1 {
197-
err := a.TokenStore.ClearOAuth1Tokens()
197+
err := a.TokenStore.ClearOAuth1TokensForApp(a.AppName())
198198
if err != nil {
199199
fmt.Println("Error clearing OAuth1 tokens:", err)
200200
os.Exit(1)
201201
}
202202
fmt.Println("OAuth1 tokens cleared!")
203203
} else if oauth2Username != "" {
204-
err := a.TokenStore.ClearOAuth2Token(oauth2Username)
204+
err := a.TokenStore.ClearOAuth2TokenForApp(a.AppName(), oauth2Username)
205205
if err != nil {
206206
fmt.Println("Error clearing OAuth2 token:", err)
207207
os.Exit(1)
208208
}
209209
fmt.Println("OAuth2 token cleared for", oauth2Username+"!")
210210
} else if bearer {
211-
err := a.TokenStore.ClearBearerToken()
211+
err := a.TokenStore.ClearBearerTokenForApp(a.AppName())
212212
if err != nil {
213213
fmt.Println("Error clearing bearer token:", err)
214214
os.Exit(1)

cli/webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command {
4848
os.Exit(1)
4949
}
5050

51-
oauth1Token := authInstance.TokenStore.GetOAuth1Tokens()
51+
oauth1Token := authInstance.TokenStore.GetOAuth1TokensForApp(authInstance.AppName())
5252
if oauth1Token == nil || oauth1Token.OAuth1 == nil || oauth1Token.OAuth1.ConsumerSecret == "" {
5353
color.Red("Error: OAuth 1.0a consumer secret not found. Please configure OAuth 1.0a credentials using 'xurl auth oauth1'.")
5454
os.Exit(1)

0 commit comments

Comments
 (0)