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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
xurl
/xurl
.xurl_test
.DS_Store# Added by goreleaser init:
dist/
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
.PHONY: build
build:
go build -o xurl
go build -o xurl ./cmd/xurl

.PHONY: install
install:
go install
go install ./cmd/xurl

.PHONY: clean
clean:
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,23 @@ Installs to `~/.local/bin`. If it's not in your PATH, the script will tell you w

### Go
```bash
go install github.com/xdevplatform/xurl@latest
go install github.com/xdevplatform/xurl/cmd/xurl@latest
```

### Use as a Go library

Import by module path in other Go projects:

```go
import "github.com/xdevplatform/xurl"
```

If your consuming project uses a local checkout of this repo, you can use a `replace` directive in your `go.mod` while still importing by the full module path:

```go
require github.com/xdevplatform/xurl v0.0.0

replace github.com/xdevplatform/xurl => ../xurl
```


Expand Down
13 changes: 7 additions & 6 deletions api/client.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
package api

import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"bufio"
"mime/multipart"
"os"
"path/filepath"
"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
xurlErrors "github.com/xdevplatform/xurl/errors"
Expand Down 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()
// Use ForApp variants so the active app name (set via --app) is respected.
token := c.auth.TokenStore.GetFirstOAuth2TokenForApp(c.auth.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(c.auth.AppName())
if token != nil {
authHeader, err := c.auth.GetOAuth1Header(method, url, nil)
if err == nil {
Expand Down
79 changes: 79 additions & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
Expand Down Expand Up @@ -356,3 +357,81 @@ func TestStreamRequest(t *testing.T) {
assert.True(t, xurlErrors.IsAPIError(err), "Expected API error")
})
}

// futureExpiry returns a unix timestamp 1 hour in the future.
func futureExpiry() uint64 {
return uint64(time.Now().Add(time.Hour).Unix())
}

// TC 5.3: ApiClient with multi-app Auth; app-b only has Bearer → BuildRequest uses app-b's Bearer
func TestTC5_3_ApiClientUsesAppBBearerNotDefaultOAuth2(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl_api_multiapp_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

tempFile := filepath.Join(tempDir, ".xurl")
ts := &store.TokenStore{
Apps: make(map[string]*store.App),
DefaultApp: "app-a",
FilePath: tempFile,
}

// app-a: has OAuth2 (default/active for auto-selection cascade)
ts.Apps["app-a"] = &store.App{
ClientID: "id-a",
ClientSecret: "secret-a",
DefaultUser: "alice-a",
OAuth2Tokens: map[string]store.Token{
"alice-a": {
Type: store.OAuth2TokenType,
OAuth2: &store.OAuth2Token{
AccessToken: "oauth2-token-alice-a",
RefreshToken: "refresh-alice-a",
ExpirationTime: futureExpiry(),
},
},
},
BearerToken: &store.Token{
Type: store.BearerTokenType,
Bearer: "bearer-a",
},
}

// app-b: has ONLY Bearer token, no OAuth2
ts.Apps["app-b"] = &store.App{
ClientID: "id-b",
ClientSecret: "secret-b",
OAuth2Tokens: make(map[string]store.Token),
BearerToken: &store.Token{
Type: store.BearerTokenType,
Bearer: "bearer-b-only",
},
}

// Build Auth starting with app-a credentials
a := auth.NewAuth(&config.Config{
ClientID: "id-a",
ClientSecret: "secret-a",
APIBaseURL: "https://api.x.com",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
RedirectURI: "http://localhost:8080/callback",
InfoURL: "https://api.x.com/2/users/me",
}).WithTokenStore(ts)

// Switch to app-b
a.WithAppName("app-b")

cfg := &config.Config{APIBaseURL: "https://api.x.com"}
client := NewApiClient(cfg, a)

req, err := client.BuildRequest(RequestOptions{
Method: "GET",
Endpoint: "/2/users/me",
})
require.NoError(t, err)

authHeader := req.Header.Get("Authorization")
assert.Equal(t, "Bearer bearer-b-only", authHeader,
"Authorization header must use app-b's Bearer token, not app-a's OAuth2")
}
1 change: 1 addition & 0 deletions api/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"time"

"github.com/xdevplatform/xurl/utils"
)

Expand Down
29 changes: 15 additions & 14 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,20 @@ func (a *Auth) WithAppName(appName string) *Auth {
a.appName = appName
app := a.TokenStore.ResolveApp(appName)
if app != nil {
if a.clientID == "" {
a.clientID = app.ClientID
}
if a.clientSecret == "" {
a.clientSecret = app.ClientSecret
}
a.clientID = app.ClientID
a.clientSecret = app.ClientSecret
}
return a
}

// AppName returns the current app name override (may be empty).
func (a *Auth) AppName() string {
return a.appName
}

// 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 +147,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 +254,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 +267,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 +311,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 +321,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
Loading