diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dec0ff9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All user-visible bugs and enhancements should be recorded here. + +## Unreleased + +Last updated: 2026-04-19 23:48:20 CEST + +### Fixed + +- [2026-04-19 23:08:51 CEST] OAuth2 callback listeners now bind to the host and port derived from the effective redirect URI instead of always listening on `127.0.0.1:8080`. For `localhost`, `xurl` now listens on both `127.0.0.1` and `::1`, which fixes browser-dependent loopback resolution failures while still supporting non-default callback paths. +- [2026-04-19 23:08:51 CEST] The OAuth2 listener now starts listening before the browser opens, which removes a race where the browser could reach the callback URL before the local server was ready. +- [2026-04-19 23:08:51 CEST] OAuth2 token refresh no longer depends on `/2/users/me` succeeding. If username discovery fails, `xurl` keeps the refreshed token instead of failing the request. +- [2026-04-19 23:08:51 CEST] Shortcut commands that need the current user ID now fall back to `--username` lookups when `/2/users/me` is unavailable. +- [2026-04-19 23:08:51 CEST] `GetOAuth2Header` now consistently returns a `Bearer` header even when it has to trigger a fresh OAuth2 flow. + +### Enhanced + +- [2026-04-19 23:08:51 CEST] OAuth2 tokens can now be retained without a discovered username label when X’s `/2/users/me` lookup is unavailable. Status output makes that state visible as `(unknown user)` instead of silently dropping the token. +- [2026-04-19 23:08:51 CEST] Repo documentation now describes the effective redirect URI as the source of callback host, port, and path, calls out explicit username authentication as the safer fallback when username discovery is unreliable, and documents the new stored `redirect_uri` behavior. +- [2026-04-19 23:08:51 CEST] Apps can now store a per-app `redirect_uri` in `~/.xurl`, `REDIRECT_URI` from the environment still takes precedence, and `xurl auth apps redirect-uri get/set` plus `auth apps update --redirect-uri` make that configuration visible and editable from the CLI. +- [2026-04-19 23:48:20 CEST] Documentation now records the confirmed X platform enrollment requirement behind `client-forbidden` / `client-not-enrolled` read failures: moving the app to the `Pay-per-use` package and the `Production` environment fixed live `/2/*` reads after OAuth had already succeeded. diff --git a/README.md b/README.md index 3fa8e4b..a631851 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ Register your X API app credentials so they're stored in `~/.xurl` (no env vars xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET ``` +If you want the app to keep its own callback configuration in `~/.xurl`, you can store the redirect URI there too: + +```bash +xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET --redirect-uri http://localhost:8080/callback +``` + You can register multiple apps: ```bash xurl auth apps add prod-app --client-id PROD_ID --client-secret PROD_SECRET @@ -58,12 +64,15 @@ xurl auth apps add dev-app --client-id DEV_ID --client-secret DEV_SECRET ``` > **Legacy / env-var flow:** You can also set `CLIENT_ID` and `CLIENT_SECRET` as environment variables. They'll be auto-saved into the active app on first use. +> +> `REDIRECT_URI` now resolves in this order: `REDIRECT_URI` environment variable, then the app's stored `redirect_uri` in `~/.xurl`, then the built-in default `http://localhost:8080/callback`. #### OAuth 2.0 User-Context **Note:** For OAuth 2.0 authentication, you must specify the redirect URI in the [X API developer portal](https://developer.x.com/en/portal/dashboard). 1. Create an app at the [X API developer portal](https://developer.x.com/en/portal/dashboard). -2. Go to authentication settings and set the redirect URI to `http://localhost:8080/callback`. +2. Go to authentication settings and set the redirect URI to the same value that `xurl` will use through `REDIRECT_URI`. + The default is `http://localhost:8080/callback`, and `xurl` derives the callback host, port, and path from the effective redirect URI. The effective value is resolved from `REDIRECT_URI`, then the app's stored `redirect_uri`, then the built-in default. When you use `localhost`, `xurl` listens on both `127.0.0.1` and `::1` so browser loopback resolution does not break the callback. ![Setup](./assets/setup.png) ![Redirect URI](./assets/callback.png) 3. Register the app (if you haven't already): @@ -75,6 +84,24 @@ xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT xurl auth oauth2 ``` +If X returns a `client-forbidden` / `client-not-enrolled` error even though auth completed successfully, check the app’s package and environment in the X developer console. On current X platform setup, the working fix was: + +1. Go to `Apps` -> `Manage apps` +2. Open the app +3. Use `Move to package` +4. Choose `Pay-per-use` +5. Move the app to the `Production` environment + +Without that enrollment step, `xurl whoami` and other `/2/*` reads can fail even when the OAuth callback and tokens are valid. + +If X does not return your username reliably through `/2/users/me`, authenticate with an explicit handle instead: + +```bash +xurl auth oauth2 YOUR_USERNAME +``` + +That keeps the OAuth2 token associated with the expected username and also gives shortcut commands a fallback when `/2/users/me` is unavailable. + #### App authentication (bearer token): ```bash xurl auth app --bearer-token BEARER_TOKEN @@ -95,6 +122,19 @@ xurl auth apps list Update credentials on an existing app: ```bash xurl auth apps update my-app --client-id NEW_ID --client-secret NEW_SECRET +xurl auth apps update my-app --redirect-uri http://localhost:8080/callback +``` + +`REDIRECT_URI` from the environment still overrides the stored app value at runtime, so `auth apps update --redirect-uri` is best for your default per-app callback while env vars remain the temporary override path. + +View the effective and stored redirect URI for an app: +```bash +xurl auth apps redirect-uri get my-app +``` + +Set the stored redirect URI for an app: +```bash +xurl auth apps redirect-uri set my-app http://localhost:8080/callback ``` Remove an app: @@ -124,20 +164,28 @@ View authentication status across all apps: xurl auth status ``` +This output shows the effective redirect URI for each app and, when `REDIRECT_URI` is set in the environment, also shows the stored app value separately so precedence is visible. + Example output: ``` ▸ my-app [client_id: VUttdG9P…] + redirect_uri: http://localhost:8080/callback [app config] ▸ oauth2: alice oauth2: bob oauth1: ✓ bearer: ✓ dev-app [client_id: OTHER789…] + redirect_uri: http://localhost:8080/callback [built-in default] oauth2: (none) oauth1: – bearer: – ``` +### X Platform Enrollment Troubleshooting + +If OAuth succeeds but reads like `xurl whoami` fail with an error body containing `client-forbidden` or `client-not-enrolled`, the current X platform fix is to move the app into the `Pay-per-use` package and use the `Production` environment in the developer console. This is an X platform enrollment issue, not a local callback-listener issue in `xurl`. + `▸` on the left = default app. `▸` next to a user = default user. ### Clear Authentication @@ -283,6 +331,7 @@ apps: my-app: client_id: abc123 client_secret: secret456 + redirect_uri: http://localhost:8080/callback default_user: alice oauth2_tokens: alice: diff --git a/SKILL.md b/SKILL.md index f398692..41d83f4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -40,13 +40,15 @@ For multiple pre-configured apps, switch between them: xurl auth default prod-app # set default app xurl auth default prod-app alice # set default app + user xurl --app dev-app /2/users/me # one-off override +xurl auth apps redirect-uri get prod-app +xurl auth apps redirect-uri set prod-app http://localhost:8080/callback ``` ### Other auth methods Examples with inline secret flags are intentionally omitted. If OAuth1 or app-only auth is needed, the user must run those commands manually outside agent/LLM context. -Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated tokens. Do not read this file through the agent/LLM. Once authenticated, every command below will auto‑attach the right `Authorization` header. +Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated tokens and may also store a `redirect_uri`. `REDIRECT_URI` in the environment still takes precedence over the stored app value. Do not read this file through the agent/LLM. Once authenticated, every command below will auto‑attach the right `Authorization` header. --- @@ -87,7 +89,9 @@ Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated | **App Management** | | | Register app | Manual, outside agent (do not pass secrets via agent) | | List apps | `xurl auth apps list` | -| Update app creds | Manual, outside agent (do not pass secrets via agent) | +| Update app config | Manual, outside agent (do not pass secrets via agent) | +| View app redirect URI | `xurl auth apps redirect-uri get [NAME]` | +| Set app redirect URI | `xurl auth apps redirect-uri set NAME URI` | | Remove app | `xurl auth apps remove NAME` | | Set default (interactive) | `xurl auth default` | | Set default (command) | `xurl auth default APP_NAME [USERNAME]` | @@ -382,7 +386,8 @@ xurl --app staging /2/users/me # one-off request against staging - Non‑zero exit code on any error. - API errors are printed as JSON to stdout (so you can still parse them). - Auth errors suggest re‑running `xurl auth oauth2` or checking your tokens. -- If a command requires your user ID (like, repost, bookmark, follow, etc.), xurl will automatically fetch it via `/2/users/me`. If that fails, you'll see an auth error. +- If a command requires your user ID (like, repost, bookmark, follow, etc.), xurl will automatically fetch it via `/2/users/me`. When that endpoint is unreliable, use `--username USERNAME` or authenticate with `xurl auth oauth2 USERNAME` so xurl can fall back to username lookup. +- If X returns `client-forbidden` / `client-not-enrolled` after successful auth, check the app’s X developer-console package and environment. In current testing, moving the app to `Pay-per-use` and `Production` fixed `/2/*` read failures without changing local `xurl` auth data. --- @@ -391,7 +396,10 @@ xurl --app staging /2/users/me # one-off request against staging - **Rate limits:** The X API enforces rate limits per endpoint. If you get a 429 error, wait and retry. Write endpoints (post, reply, like, repost) have stricter limits than read endpoints. - **Scopes:** OAuth 2.0 tokens are requested with broad scopes. If you get a 403 on a specific action, your token may lack the required scope — re‑run `xurl auth oauth2` to get a fresh token. - **Token refresh:** OAuth 2.0 tokens auto‑refresh when expired. No manual intervention needed. -- **Multiple apps:** Each app has its own isolated credentials and tokens. Configure credentials manually outside agent/LLM context, then switch with `xurl auth default` or `--app`. +- **Multiple apps:** Each app has its own isolated credentials, tokens, and optional stored `redirect_uri`. Configure credentials manually outside agent/LLM context, then switch with `xurl auth default` or `--app`. +- **Redirect URI precedence:** The effective redirect URI resolves from `REDIRECT_URI` in the environment first, then the app's stored `redirect_uri` in `~/.xurl`, then the built-in default. +- **Redirect URI management:** Use `xurl auth apps redirect-uri get [NAME]`, `xurl auth apps redirect-uri set NAME URI`, or `xurl auth apps update NAME --redirect-uri URI` to inspect and manage the stored per-app callback value. +- **X platform enrollment:** A successful OAuth callback does not guarantee `/2/*` reads will work. If you see `client-not-enrolled`, verify the app is in the correct X package/environment. Current confirmed fix: `Apps` -> `Manage apps` -> `Move to package` -> choose `Pay-per-use`, then move the app to `Production`. - **Multiple accounts:** You can authenticate multiple OAuth 2.0 accounts per app and switch between them with `--username` / `-u` or set a default with `xurl auth default APP USER`. - **Default user:** When no `-u` flag is given, xurl uses the default user for the active app (set via `xurl auth default`). If no default user is set, it uses the first available token. - **Token storage:** `~/.xurl` is YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context. diff --git a/auth/auth.go b/auth/auth.go index 339e3f9..33bfe68 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "math/big" + "net" "net/http" "net/url" "os/exec" @@ -29,16 +30,21 @@ import ( ) type Auth struct { - TokenStore *store.TokenStore - infoURL string - clientID string - clientSecret string - authURL string - tokenURL string - redirectURI string - appName string // explicit app override (empty = use default) + TokenStore *store.TokenStore + infoURL string + clientID string + clientSecret string + authURL string + tokenURL string + redirectURI string + redirectURIFromEnv bool + appName string // explicit app override (empty = use default) } +var openBrowserFunc = openBrowser + +var startListenerFunc = StartListener + // NewAuth creates a new Auth object. // Credentials are resolved in order: env-var config → active app in .xurl store. // If env var credentials are present, they're also backfilled into any migrated @@ -60,14 +66,15 @@ func NewAuth(cfg *config.Config) *Auth { } return &Auth{ - TokenStore: ts, - infoURL: cfg.InfoURL, - clientID: clientID, - clientSecret: clientSecret, - authURL: cfg.AuthURL, - tokenURL: cfg.TokenURL, - redirectURI: cfg.RedirectURI, - appName: appName, + TokenStore: ts, + infoURL: cfg.InfoURL, + clientID: clientID, + clientSecret: clientSecret, + authURL: cfg.AuthURL, + tokenURL: cfg.TokenURL, + redirectURI: cfg.RedirectURI, + redirectURIFromEnv: cfg.RedirectURIFromEnv, + appName: appName, } } @@ -94,9 +101,20 @@ func (a *Auth) WithAppName(appName string) *Auth { a.clientSecret = app.ClientSecret } } + if !a.redirectURIFromEnv { + a.redirectURI = a.resolveRedirectURIForApp(appName) + } return a } +func (a *Auth) resolveRedirectURIForApp(appName string) string { + app := a.TokenStore.ResolveApp(appName) + if app != nil && app.RedirectURI != "" { + return app.RedirectURI + } + return config.DefaultRedirectURI +} + // GetOAuth1Header gets the OAuth1 header for a request func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[string]string) (string, error) { token := a.TokenStore.GetOAuth1TokensForApp(a.appName) @@ -157,7 +175,11 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) { } if token == nil { - return a.OAuth2Flow(username) + accessToken, err := a.OAuth2Flow(username) + if err != nil { + return "", err + } + return "Bearer " + accessToken, nil } accessToken, err := a.RefreshOAuth2Token(username) @@ -192,13 +214,14 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256")) - err := openBrowser(authURL) + listenerConfig, err := listenerConfigFromRedirectURI(a.redirectURI) if err != nil { - fmt.Println("Failed to open browser automatically. Please visit this URL manually:") - fmt.Println(authURL) + return "", xurlErrors.NewAuthError("InvalidRedirectURI", err) } codeChan := make(chan string, 1) + listenerReady := make(chan struct{}) + listenerErrChan := make(chan error, 1) callback := func(code, receivedState string) error { if receivedState != state { @@ -214,21 +237,22 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { } go func() { - parsedURL, err := url.Parse(a.redirectURI) - if err != nil { - codeChan <- "" - return + if err := startListenerFunc(listenerConfig.Addresses, listenerConfig.CallbackPath, callback, listenerReady); err != nil { + listenerErrChan <- err } + }() - port := 8080 - if parsedURL.Port() != "" { - fmt.Sscanf(parsedURL.Port(), "%d", &port) - } + select { + case <-listenerReady: + case err := <-listenerErrChan: + return "", xurlErrors.NewAuthError("ListenerError", err) + } - if err := StartListener(port, callback); err != nil { - fmt.Printf("Error in OAuth listener: %v\n", err) - } - }() + err = openBrowserFunc(authURL) + if err != nil { + fmt.Println("Failed to open browser automatically. Please visit this URL manually:") + fmt.Println(authURL) + } var code string select { @@ -236,6 +260,8 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { if code == "" { return "", xurlErrors.NewAuthError("ListenerError", errors.New("oauth2 listener failed")) } + case err := <-listenerErrChan: + return "", xurlErrors.NewAuthError("ListenerError", err) case <-time.After(5 * time.Minute): return "", xurlErrors.NewAuthError("Timeout", errors.New("authentication timed out")) } @@ -245,37 +271,21 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { return "", xurlErrors.NewAuthError("TokenExchangeError", err) } - var usernameStr string - if username != "" { - usernameStr = username - } else { - fetchedUsername, err := a.fetchUsername(token.AccessToken) - if err != nil { - return "", err - } - usernameStr = fetchedUsername - } - - expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) - - err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, token.AccessToken, token.RefreshToken, expirationTime) - if err != nil { + usernameStr, resolvedFromLookup := a.resolveStorageUsername(username, token.AccessToken) + if err := a.saveOAuth2Token(usernameStr, token); err != nil { return "", xurlErrors.NewAuthError("TokenStorageError", err) } + if username == "" && !resolvedFromLookup { + fmt.Println("Warning: authenticated successfully, but could not resolve your username via /2/users/me.") + fmt.Println("The OAuth2 token was saved without a username label. Re-run `xurl auth oauth2 YOUR_USERNAME` if you want a named token.") + } return token.AccessToken, nil } // RefreshOAuth2Token validates and refreshes an OAuth2 token if needed func (a *Auth) RefreshOAuth2Token(username string) (string, error) { - var token *store.Token - - if username != "" { - token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username) - } else { - token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName) - } - + storedUsername, token := a.getOAuth2TokenRecord(username) if token == nil || token.OAuth2 == nil { return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("oauth2 token not found")) } @@ -302,25 +312,90 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { return "", xurlErrors.NewAuthError("RefreshTokenError", err) } - var usernameStr string - if username != "" { - usernameStr = username - } else { - fetchedUsername, err := a.fetchUsername(newToken.AccessToken) - if err != nil { - return "", xurlErrors.NewAuthError("UsernameFetchError", err) + usernameStr := storedUsername + if usernameStr == "" { + resolvedUsername, _ := a.resolveStorageUsername("", newToken.AccessToken) + usernameStr = resolvedUsername + } + if storedUsername == "" && usernameStr != "" { + if err := a.TokenStore.ClearOAuth2TokenForApp(a.appName, storedUsername); err != nil { + return "", xurlErrors.NewAuthError("RefreshTokenError", err) } - usernameStr = fetchedUsername } + if err := a.saveOAuth2Token(usernameStr, newToken); err != nil { + return "", xurlErrors.NewAuthError("RefreshTokenError", err) + } + + return newToken.AccessToken, nil +} - expirationTime := uint64(time.Now().Add(time.Duration(newToken.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) +type oauth2ListenerConfig struct { + Addresses []string + CallbackPath string +} - err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime) +func listenerConfigFromRedirectURI(redirectURI string) (oauth2ListenerConfig, error) { + parsedURL, err := url.Parse(redirectURI) if err != nil { - return "", xurlErrors.NewAuthError("RefreshTokenError", err) + return oauth2ListenerConfig{}, err } - return newToken.AccessToken, nil + host := parsedURL.Hostname() + if host == "" { + host = "localhost" + } + + port := parsedURL.Port() + if port == "" { + port = "8080" + } + + callbackPath := parsedURL.Path + if callbackPath == "" { + callbackPath = "/callback" + } + + return oauth2ListenerConfig{ + Addresses: listenerAddressesForHost(host, port), + CallbackPath: callbackPath, + }, nil +} + +func listenerAddressesForHost(host, port string) []string { + if strings.EqualFold(host, "localhost") { + return []string{ + net.JoinHostPort("127.0.0.1", port), + net.JoinHostPort("::1", port), + } + } + + return []string{net.JoinHostPort(host, port)} +} + +func (a *Auth) resolveStorageUsername(explicitUsername, accessToken string) (string, bool) { + if explicitUsername != "" { + return explicitUsername, true + } + + username, err := a.fetchUsername(accessToken) + if err != nil { + return "", false + } + + return username, true +} + +func (a *Auth) getOAuth2TokenRecord(username string) (string, *store.Token) { + if username != "" { + return username, a.TokenStore.GetOAuth2TokenForApp(a.appName, username) + } + + return a.TokenStore.GetFirstOAuth2TokenRecordForApp(a.appName) +} + +func (a *Auth) saveOAuth2Token(username string, token *oauth2.Token) error { + expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) + return a.TokenStore.SaveOAuth2TokenForApp(a.appName, username, token.AccessToken, token.RefreshToken, expirationTime) } // GetBearerTokenHeader gets the bearer token from the token store diff --git a/auth/auth_test.go b/auth/auth_test.go index 0c9c35e..7c98fd8 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -200,6 +200,8 @@ func TestWithAppName(t *testing.T) { // Add a second app with different credentials tokenStore.AddApp("other", "other-id", "other-secret") + err = tokenStore.SetAppRedirectURI("other", "http://localhost:9090/callback") + require.NoError(t, err) cfg := &config.Config{} a := NewAuth(cfg).WithTokenStore(tokenStore) @@ -211,6 +213,7 @@ func TestWithAppName(t *testing.T) { a.WithAppName("other") assert.Equal(t, "other-id", a.clientID) assert.Equal(t, "other-secret", a.clientSecret) + assert.Equal(t, "http://localhost:9090/callback", a.redirectURI) } func TestWithAppNameOverridesEnvCredentials(t *testing.T) { @@ -222,9 +225,16 @@ func TestWithAppNameOverridesEnvCredentials(t *testing.T) { tokenStore, tsDir := createTempTokenStore(t) defer os.RemoveAll(tsDir) tokenStore.AddApp("my-app", "app-id", "app-secret") + err = tokenStore.SetAppRedirectURI("my-app", "http://localhost:9090/callback") + require.NoError(t, err) // Simulate env vars being set at startup - cfg := &config.Config{ClientID: "env-id", ClientSecret: "env-secret"} + cfg := &config.Config{ + ClientID: "env-id", + ClientSecret: "env-secret", + RedirectURI: "http://127.0.0.1:7777/callback", + RedirectURIFromEnv: true, + } a := NewAuth(cfg).WithTokenStore(tokenStore) assert.Equal(t, "env-id", a.clientID) @@ -232,6 +242,7 @@ func TestWithAppNameOverridesEnvCredentials(t *testing.T) { a.WithAppName("my-app") assert.Equal(t, "app-id", a.clientID) assert.Equal(t, "app-secret", a.clientSecret) + assert.Equal(t, "http://127.0.0.1:7777/callback", a.redirectURI) } func TestAppFlagTokenIsolation(t *testing.T) { @@ -352,7 +363,6 @@ func TestGetOAuth2HeaderNoToken(t *testing.T) { 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 { @@ -449,3 +459,116 @@ func TestBrowserLaunchCommand(t *testing.T) { assert.Equal(t, []string{url}, args) }) } + +func TestListenerConfigFromRedirectURI(t *testing.T) { + testCases := []struct { + name string + redirectURI string + wantAddresses []string + wantCallback string + }{ + { + name: "localhost redirect listens on both loopback families", + redirectURI: "http://localhost:8080/callback", + wantAddresses: []string{"127.0.0.1:8080", "[::1]:8080"}, + wantCallback: "/callback", + }, + { + name: "ipv4 loopback redirect uses configured host and port", + redirectURI: "http://127.0.0.1:9090/oauth/callback", + wantAddresses: []string{"127.0.0.1:9090"}, + wantCallback: "/oauth/callback", + }, + { + name: "missing host and port fall back safely", + redirectURI: "/callback", + wantAddresses: []string{"127.0.0.1:8080", "[::1]:8080"}, + wantCallback: "/callback", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + config, err := listenerConfigFromRedirectURI(tc.redirectURI) + require.NoError(t, err) + assert.Equal(t, tc.wantAddresses, config.Addresses) + assert.Equal(t, tc.wantCallback, config.CallbackPath) + }) + } +} + +func TestRefreshOAuth2TokenPreservesUnnamedTokenWhenUsernameLookupFails(t *testing.T) { + tokenServer := mockTokenServer(t, "new-access-token", "new-refresh-token") + defer tokenServer.Close() + + infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{}, + }) + })) + defer infoServer.Close() + + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix()) + err := tokenStore.SaveOAuth2TokenForApp("default", "", "old-access", "old-refresh", expiredTime) + require.NoError(t, err) + + cfg := &config.Config{ + TokenURL: serverURL(tokenServer, "/token"), + InfoURL: infoServer.URL, + } + a := NewAuth(cfg).WithTokenStore(tokenStore) + + newToken, err := a.RefreshOAuth2Token("") + require.NoError(t, err) + assert.Equal(t, "new-access-token", newToken) + + tok := tokenStore.GetOAuth2TokenForApp("default", "") + require.NotNil(t, tok) + assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken) + assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", "alice")) +} + +func TestRefreshOAuth2TokenMigratesUnnamedTokenWhenUsernameLookupSucceeds(t *testing.T) { + tokenServer := mockTokenServer(t, "new-access-token", "new-refresh-token") + defer tokenServer.Close() + + infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "username": "alice", + }, + }) + })) + defer infoServer.Close() + + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix()) + err := tokenStore.SaveOAuth2TokenForApp("default", "", "old-access", "old-refresh", expiredTime) + require.NoError(t, err) + + cfg := &config.Config{ + TokenURL: serverURL(tokenServer, "/token"), + InfoURL: infoServer.URL, + } + a := NewAuth(cfg).WithTokenStore(tokenStore) + + newToken, err := a.RefreshOAuth2Token("") + require.NoError(t, err) + assert.Equal(t, "new-access-token", newToken) + + assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", "")) + tok := tokenStore.GetOAuth2TokenForApp("default", "alice") + require.NotNil(t, tok) + assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken) +} + +func serverURL(server *httptest.Server, suffix string) string { + return server.URL + suffix +} diff --git a/auth/listener.go b/auth/listener.go index b9465ca..1e4c48f 100644 --- a/auth/listener.go +++ b/auth/listener.go @@ -4,21 +4,33 @@ import ( "context" "errors" "fmt" + "net" "net/http" + "sync" "time" xurlErrors "github.com/xdevplatform/xurl/errors" ) -func StartListener(port int, callback func(code, state string) error) error { - server := &http.Server{ - Addr: fmt.Sprintf("127.0.0.1:%d", port), - Handler: http.DefaultServeMux, - } - +func StartListener(addresses []string, callbackPath string, callback func(code, state string) error, ready chan<- struct{}) error { + mux := http.NewServeMux() done := make(chan error, 1) + servers := make([]*http.Server, 0, len(addresses)) + listeners := make([]net.Listener, 0, len(addresses)) + var doneOnce sync.Once - http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + finish := func(err error) { + doneOnce.Do(func() { + done <- err + go func() { + for _, server := range servers { + _ = server.Shutdown(context.Background()) + } + }() + }) + } + + mux.HandleFunc(callbackPath, func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") @@ -26,31 +38,51 @@ func StartListener(port int, callback func(code, state string) error) error { if err != nil { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "Error: %s", err.Error()) - done <- err + finish(err) return } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Authentication successful! You can close this window.") - done <- nil - - go func() { - server.Shutdown(context.Background()) - }() + finish(nil) }) - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - done <- xurlErrors.NewAuthError("ServerError", err) + for _, address := range addresses { + listener, err := net.Listen("tcp", address) + if err != nil { + for _, existing := range listeners { + _ = existing.Close() + } + return xurlErrors.NewAuthError("ServerError", err) } - }() + listeners = append(listeners, listener) + servers = append(servers, &http.Server{ + Addr: address, + Handler: mux, + }) + } + + if ready != nil { + close(ready) + } + + for i, listener := range listeners { + server := servers[i] + go func(server *http.Server, listener net.Listener) { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + finish(xurlErrors.NewAuthError("ServerError", err)) + } + }(server, listener) + } select { case err := <-done: return err case <-time.After(5 * time.Minute): - server.Shutdown(context.Background()) + for _, server := range servers { + _ = server.Shutdown(context.Background()) + } return xurlErrors.NewAuthError("Timeout", errors.New("timeout waiting for callback")) } } diff --git a/cli/auth.go b/cli/auth.go index f438fa7..e237d3b 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/xdevplatform/xurl/auth" + "github.com/xdevplatform/xurl/config" "github.com/xdevplatform/xurl/store" ) @@ -138,14 +139,24 @@ func createAuthStatusCmd() *cobra.Command { } fmt.Printf("%s %s [%s]\n", marker, name, clientHint) + redirectURI, fromEnv, source := config.ResolveRedirectURI(name) + if fromEnv { + fmt.Printf(" redirect_uri: %s [effective via %s]\n", redirectURI, source) + if app.RedirectURI != "" { + fmt.Printf(" stored_redirect_uri: %s\n", app.RedirectURI) + } + } else { + fmt.Printf(" redirect_uri: %s [%s]\n", redirectURI, source) + } + // OAuth2 users usernames := ts.GetOAuth2UsernamesForApp(name) if len(usernames) > 0 { for _, u := range usernames { if u == app.DefaultUser { - fmt.Printf(" ▸ oauth2: %s\n", u) + fmt.Printf(" ▸ oauth2: %s\n", displayOAuth2Username(u)) } else { - fmt.Printf(" oauth2: %s\n", u) + fmt.Printf(" oauth2: %s\n", displayOAuth2Username(u)) } } } else { @@ -241,12 +252,13 @@ func createAppCmd(a *auth.Auth) *cobra.Command { appCmd.AddCommand(createAppUpdateCmd(a)) appCmd.AddCommand(createAppRemoveCmd(a)) appCmd.AddCommand(createAppListCmd()) + appCmd.AddCommand(createAppRedirectURICmd(a)) return appCmd } func createAppAddCmd(a *auth.Auth) *cobra.Command { - var clientID, clientSecret string + var clientID, clientSecret, redirectURI string cmd := &cobra.Command{ Use: "add NAME", @@ -254,7 +266,8 @@ func createAppAddCmd(a *auth.Auth) *cobra.Command { Long: `Register a new X API app with a client ID and secret. Examples: - xurl auth apps add my-app --client-id abc --client-secret xyz`, + xurl auth apps add my-app --client-id abc --client-secret xyz + xurl auth apps add my-app --client-id abc --client-secret xyz --redirect-uri http://localhost:8080/callback`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] @@ -263,6 +276,12 @@ Examples: fmt.Printf("\033[31mError: %v\033[0m\n", err) os.Exit(1) } + if redirectURI != "" { + if err := a.TokenStore.SetAppRedirectURI(name, redirectURI); err != nil { + fmt.Printf("\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + } fmt.Printf("\033[32mApp %q registered!\033[0m\n", name) if len(a.TokenStore.ListApps()) == 1 { fmt.Printf(" (set as default app)\n") @@ -272,6 +291,7 @@ Examples: cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 client ID") cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 client secret") + cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "Stored OAuth2 redirect URI for this app") cmd.MarkFlagRequired("client-id") cmd.MarkFlagRequired("client-secret") @@ -279,7 +299,7 @@ Examples: } func createAppUpdateCmd(a *auth.Auth) *cobra.Command { - var clientID, clientSecret string + var clientID, clientSecret, redirectURI string cmd := &cobra.Command{ Use: "update NAME", @@ -288,25 +308,32 @@ func createAppUpdateCmd(a *auth.Auth) *cobra.Command { Examples: xurl auth apps update default --client-id abc --client-secret xyz - xurl auth apps update my-app --client-id newid`, + xurl auth apps update my-app --client-id newid + xurl auth apps update my-app --redirect-uri http://localhost:8080/callback`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] - if clientID == "" && clientSecret == "" { - fmt.Println("Nothing to update. Provide --client-id and/or --client-secret.") + if clientID == "" && clientSecret == "" && redirectURI == "" { + fmt.Println("Nothing to update. Provide --client-id, --client-secret, and/or --redirect-uri.") os.Exit(1) } - err := a.TokenStore.UpdateApp(name, clientID, clientSecret) - if err != nil { + if err := a.TokenStore.UpdateApp(name, clientID, clientSecret); err != nil { fmt.Printf("\033[31mError: %v\033[0m\n", err) os.Exit(1) } + if redirectURI != "" { + if err := a.TokenStore.SetAppRedirectURI(name, redirectURI); err != nil { + fmt.Printf("\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + } fmt.Printf("\033[32mApp %q updated.\033[0m\n", name) }, } cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 client ID") cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 client secret") + cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "Stored OAuth2 redirect URI for this app") return cmd } @@ -353,13 +380,83 @@ func createAppListCmd() *cobra.Command { if app.ClientID != "" { clientHint = fmt.Sprintf(" (client_id: %s…)", truncate(app.ClientID, 8)) } - fmt.Printf("%s%s%s\n", marker, name, clientHint) + redirectURI, fromEnv, source := config.ResolveRedirectURI(name) + redirectHint := fmt.Sprintf(" redirect_uri: %s [%s]", redirectURI, source) + if fromEnv && app.RedirectURI != "" { + redirectHint = fmt.Sprintf(" redirect_uri: %s [%s, stored: %s]", redirectURI, source, app.RedirectURI) + } + fmt.Printf("%s%s%s%s\n", marker, name, clientHint, redirectHint) } }, } return cmd } +func createAppRedirectURICmd(a *auth.Auth) *cobra.Command { + redirectCmd := &cobra.Command{ + Use: "redirect-uri", + Short: "Get or set the stored OAuth2 redirect URI for an app", + } + + redirectCmd.AddCommand(createAppRedirectURIGetCmd(a)) + redirectCmd.AddCommand(createAppRedirectURISetCmd(a)) + + return redirectCmd +} + +func createAppRedirectURIGetCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "get [NAME]", + Short: "Show the effective and stored redirect URI for an app", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ts := a.TokenStore + appName := resolveAppNameArg(ts, args) + if appName == "" { + fmt.Println("No apps registered. Use 'xurl auth apps add' to register one.") + os.Exit(1) + } + app := ts.GetApp(appName) + if app == nil { + fmt.Printf("\033[31mError: app %q not found\033[0m\n", appName) + os.Exit(1) + } + + effective, _, source := config.ResolveRedirectURI(appName) + stored := app.RedirectURI + if stored == "" { + stored = "(not set)" + } + + fmt.Printf("app: %s\n", appName) + fmt.Printf("effective_redirect_uri: %s\n", effective) + fmt.Printf("effective_source: %s\n", source) + fmt.Printf("stored_redirect_uri: %s\n", stored) + }, + } + + return cmd +} + +func createAppRedirectURISetCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "set NAME URI", + Short: "Set the stored OAuth2 redirect URI for an app", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + redirectURI := args[1] + if err := a.TokenStore.SetAppRedirectURI(name, redirectURI); err != nil { + fmt.Printf("\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + fmt.Printf("\033[32mRedirect URI set for app %q.\033[0m\n", name) + }, + } + + return cmd +} + // ─── auth default ─────────────────────────────────────────────────── func createDefaultCmd(a *auth.Auth) *cobra.Command { @@ -451,3 +548,17 @@ func truncate(s string, maxLen int) string { } return s[:maxLen] } + +func displayOAuth2Username(username string) string { + if username == "" { + return "(unknown user)" + } + return username +} + +func resolveAppNameArg(ts *store.TokenStore, args []string) string { + if len(args) > 0 { + return args[0] + } + return ts.GetDefaultApp() +} diff --git a/cli/shortcuts.go b/cli/shortcuts.go index 7741ee9..45a11f7 100644 --- a/cli/shortcuts.go +++ b/cli/shortcuts.go @@ -55,9 +55,17 @@ func printResult(resp json.RawMessage, err error) { // resolveMyUserID calls /2/users/me and returns the authenticated user's ID. func resolveMyUserID(client api.Client, opts api.RequestOptions) (string, error) { + if opts.Username != "" { + userID, err := resolveUserID(client, opts.Username, opts) + if err != nil { + return "", fmt.Errorf("could not resolve your user ID from --username %q: %w", opts.Username, err) + } + return userID, nil + } + resp, err := api.GetMe(client, opts) if err != nil { - return "", fmt.Errorf("could not resolve your user ID (are you authenticated?): %w", err) + return "", fmt.Errorf("could not resolve your user ID (are you authenticated? try --username if /2/users/me is unavailable): %w", err) } var me struct { Data struct { @@ -291,6 +299,10 @@ Examples: Run: func(cmd *cobra.Command, args []string) { client := newClient(a) opts := baseOpts(cmd) + if opts.Username != "" { + printResult(api.LookupUser(client, opts.Username, opts)) + return + } printResult(api.GetMe(client, opts)) }, } diff --git a/cli/shortcuts_test.go b/cli/shortcuts_test.go new file mode 100644 index 0000000..aabd6ff --- /dev/null +++ b/cli/shortcuts_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xdevplatform/xurl/api" +) + +type fakeClient struct { + sendRequest func(options api.RequestOptions) (json.RawMessage, error) +} + +func (f fakeClient) BuildRequest(options api.RequestOptions) (*http.Request, error) { + return nil, fmt.Errorf("not implemented") +} + +func (f fakeClient) BuildMultipartRequest(options api.MultipartOptions) (*http.Request, error) { + return nil, fmt.Errorf("not implemented") +} + +func (f fakeClient) SendRequest(options api.RequestOptions) (json.RawMessage, error) { + return f.sendRequest(options) +} + +func (f fakeClient) StreamRequest(options api.RequestOptions) error { + return fmt.Errorf("not implemented") +} + +func (f fakeClient) SendMultipartRequest(options api.MultipartOptions) (json.RawMessage, error) { + return nil, fmt.Errorf("not implemented") +} + +func TestResolveMyUserIDUsesUsernameFallback(t *testing.T) { + client := fakeClient{ + sendRequest: func(options api.RequestOptions) (json.RawMessage, error) { + require.Equal(t, "/2/users/by/username/alice?user.fields=created_at,description,public_metrics,verified,profile_image_url", options.Endpoint) + return json.RawMessage(`{"data":{"id":"42"}}`), nil + }, + } + + userID, err := resolveMyUserID(client, api.RequestOptions{Username: "alice"}) + require.NoError(t, err) + assert.Equal(t, "42", userID) +} + +func TestResolveMyUserIDReturnsHelpfulErrorWhenGetMeFails(t *testing.T) { + client := fakeClient{ + sendRequest: func(options api.RequestOptions) (json.RawMessage, error) { + require.Equal(t, "/2/users/me?user.fields=created_at,description,public_metrics,verified,profile_image_url", options.Endpoint) + return nil, fmt.Errorf("boom") + }, + } + + _, err := resolveMyUserID(client, api.RequestOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "try --username") +} diff --git a/config/config.go b/config/config.go index ade559e..215bccb 100644 --- a/config/config.go +++ b/config/config.go @@ -3,8 +3,12 @@ package config import ( "fmt" "os" + + "github.com/xdevplatform/xurl/store" ) +const DefaultRedirectURI = "http://localhost:8080/callback" + // Config holds the application configuration type Config struct { // OAuth2 client tokens (may come from env vars or the active app in .xurl) @@ -12,8 +16,10 @@ type Config struct { ClientSecret string // OAuth2 PKCE flow urls RedirectURI string - AuthURL string - TokenURL string + // RedirectURIFromEnv tracks whether REDIRECT_URI came from the environment. + RedirectURIFromEnv bool + AuthURL string + TokenURL string // API base url APIBaseURL string // API user info url @@ -24,23 +30,46 @@ type Config struct { // NewConfig creates a new Config from environment variables func NewConfig() *Config { + return NewConfigForApp("") +} + +// NewConfigForApp creates a Config for the given app name. +func NewConfigForApp(appName string) *Config { clientID := getEnvOrDefault("CLIENT_ID", "") clientSecret := getEnvOrDefault("CLIENT_SECRET", "") - redirectURI := getEnvOrDefault("REDIRECT_URI", "http://localhost:8080/callback") + redirectURI, redirectURIFromEnv, _ := ResolveRedirectURI(appName) authURL := getEnvOrDefault("AUTH_URL", "https://x.com/i/oauth2/authorize") tokenURL := getEnvOrDefault("TOKEN_URL", "https://api.x.com/2/oauth2/token") apiBaseURL := getEnvOrDefault("API_BASE_URL", "https://api.x.com") infoURL := getEnvOrDefault("INFO_URL", fmt.Sprintf("%s/2/users/me", apiBaseURL)) return &Config{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURI: redirectURI, - AuthURL: authURL, - TokenURL: tokenURL, - APIBaseURL: apiBaseURL, - InfoURL: infoURL, + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURI: redirectURI, + RedirectURIFromEnv: redirectURIFromEnv, + AuthURL: authURL, + TokenURL: tokenURL, + APIBaseURL: apiBaseURL, + InfoURL: infoURL, + AppName: appName, + } +} + +// ResolveRedirectURI resolves the effective redirect URI for an app. +// Precedence: REDIRECT_URI env var, then stored app config, then built-in default. +func ResolveRedirectURI(appName string) (value string, fromEnv bool, source string) { + if value, ok := os.LookupEnv("REDIRECT_URI"); ok { + return value, true, "REDIRECT_URI environment variable" } + + ts := store.NewTokenStore() + app := ts.ResolveApp(appName) + if app != nil && app.RedirectURI != "" { + return app.RedirectURI, false, "app config" + } + + return DefaultRedirectURI, false, "built-in default" } // Helper function to get environment variable with default value diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..5ba8a3f --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xdevplatform/xurl/store" +) + +func TestResolveRedirectURI(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xurl-config-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + t.Setenv("HOME", tempDir) + + ts := store.NewTokenStore() + err = ts.AddApp("my-app", "id", "secret") + require.NoError(t, err) + err = ts.SetDefaultApp("my-app") + require.NoError(t, err) + err = ts.SetAppRedirectURI("my-app", "http://localhost:9090/callback") + require.NoError(t, err) + + t.Run("store redirect uri is used when env is absent", func(t *testing.T) { + t.Setenv("REDIRECT_URI", "") + _ = os.Unsetenv("REDIRECT_URI") + redirectURI, fromEnv, source := ResolveRedirectURI("my-app") + assert.Equal(t, "http://localhost:9090/callback", redirectURI) + assert.False(t, fromEnv) + assert.Equal(t, "app config", source) + }) + + t.Run("env redirect uri overrides stored value", func(t *testing.T) { + t.Setenv("REDIRECT_URI", "http://127.0.0.1:8080/callback") + redirectURI, fromEnv, source := ResolveRedirectURI("my-app") + assert.Equal(t, "http://127.0.0.1:8080/callback", redirectURI) + assert.True(t, fromEnv) + assert.Equal(t, "REDIRECT_URI environment variable", source) + }) + + t.Run("default redirect uri is used when nothing is configured", func(t *testing.T) { + t.Setenv("HOME", filepath.Join(tempDir, "other-home")) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "other-home"), 0o755)) + _ = os.Unsetenv("REDIRECT_URI") + redirectURI, fromEnv, source := ResolveRedirectURI("") + assert.Equal(t, DefaultRedirectURI, redirectURI) + assert.False(t, fromEnv) + assert.Equal(t, "built-in default", source) + }) +} + +func TestNewConfigForAppUsesRedirectURIResolution(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xurl-config-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + t.Setenv("HOME", tempDir) + ts := store.NewTokenStore() + err = ts.AddApp("my-app", "id", "secret") + require.NoError(t, err) + err = ts.SetAppRedirectURI("my-app", "http://localhost:9090/callback") + require.NoError(t, err) + + cfg := NewConfigForApp("my-app") + assert.Equal(t, "http://localhost:9090/callback", cfg.RedirectURI) + assert.False(t, cfg.RedirectURIFromEnv) + + t.Setenv("REDIRECT_URI", "http://127.0.0.1:8080/callback") + cfg = NewConfigForApp("my-app") + assert.Equal(t, "http://127.0.0.1:8080/callback", cfg.RedirectURI) + assert.True(t, cfg.RedirectURIFromEnv) +} diff --git a/store/tokens.go b/store/tokens.go index 1420da1..535f764 100644 --- a/store/tokens.go +++ b/store/tokens.go @@ -52,6 +52,7 @@ type Token struct { type App struct { ClientID string `yaml:"client_id"` ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri,omitempty"` DefaultUser string `yaml:"default_user,omitempty"` OAuth2Tokens map[string]Token `yaml:"oauth2_tokens,omitempty"` OAuth1Token *Token `yaml:"oauth1_token,omitempty"` @@ -221,6 +222,25 @@ func (s *TokenStore) UpdateApp(name, clientID, clientSecret string) error { return s.saveToFile() } +// SetAppRedirectURI sets the redirect URI of an existing app. +func (s *TokenStore) SetAppRedirectURI(name, redirectURI string) error { + app, exists := s.Apps[name] + if !exists { + return errors.NewTokenStoreError(fmt.Sprintf("app %q not found", name)) + } + app.RedirectURI = redirectURI + return s.saveToFile() +} + +// GetAppRedirectURI gets the stored redirect URI for an existing app. +func (s *TokenStore) GetAppRedirectURI(name string) (string, error) { + app, exists := s.Apps[name] + if !exists { + return "", errors.NewTokenStoreError(fmt.Sprintf("app %q not found", name)) + } + return app.RedirectURI, nil +} + // RemoveApp removes a registered application and its tokens. func (s *TokenStore) RemoveApp(name string) error { if _, exists := s.Apps[name]; !exists { @@ -462,19 +482,36 @@ func (s *TokenStore) GetFirstOAuth2Token() *Token { return s.GetFirstOAuth2TokenForApp("") } -// GetFirstOAuth2TokenForApp gets the default user's token, or the first OAuth2 token from the named app. -func (s *TokenStore) GetFirstOAuth2TokenForApp(appName string) *Token { +// GetFirstOAuth2TokenRecordForApp gets the preferred OAuth2 token key and token from the named app. +func (s *TokenStore) GetFirstOAuth2TokenRecordForApp(appName string) (string, *Token) { app := s.ResolveApp(appName) - // Prefer the default user if one is set and still has a token if app.DefaultUser != "" { if token, ok := app.OAuth2Tokens[app.DefaultUser]; ok { - return &token + return app.DefaultUser, &token } } - for _, token := range app.OAuth2Tokens { - return &token + + usernames := s.GetOAuth2UsernamesForApp(appName) + for _, username := range usernames { + if username == "" { + continue + } + if token, ok := app.OAuth2Tokens[username]; ok { + return username, &token + } } - return nil + + if token, ok := app.OAuth2Tokens[""]; ok { + return "", &token + } + + return "", nil +} + +// GetFirstOAuth2TokenForApp gets the default user's token, or the first OAuth2 token from the named app. +func (s *TokenStore) GetFirstOAuth2TokenForApp(appName string) *Token { + _, token := s.GetFirstOAuth2TokenRecordForApp(appName) + return token } // GetOAuth1Tokens gets OAuth1 tokens from the resolved app. diff --git a/store/tokens_test.go b/store/tokens_test.go index 55aa8ee..4ed671e 100644 --- a/store/tokens_test.go +++ b/store/tokens_test.go @@ -166,6 +166,21 @@ func TestMultiApp(t *testing.T) { assert.Equal(t, []string{"bob"}, store.GetOAuth2UsernamesForApp("app2")) }) + t.Run("Per-app redirect uri is isolated", func(t *testing.T) { + err := store.SetAppRedirectURI("app1", "http://localhost:8080/callback") + require.NoError(t, err) + err = store.SetAppRedirectURI("app2", "http://localhost:9090/callback") + require.NoError(t, err) + + redirectURI1, err := store.GetAppRedirectURI("app1") + require.NoError(t, err) + redirectURI2, err := store.GetAppRedirectURI("app2") + require.NoError(t, err) + + assert.Equal(t, "http://localhost:8080/callback", redirectURI1) + assert.Equal(t, "http://localhost:9090/callback", redirectURI2) + }) + t.Run("Remove app", func(t *testing.T) { err := store.RemoveApp("app2") require.NoError(t, err) @@ -221,6 +236,17 @@ func TestMultiApp(t *testing.T) { require.NotNil(t, tok) assert.Equal(t, "z-tok", tok.OAuth2.AccessToken) }) + + t.Run("Unnamed tokens are lower priority than named tokens", func(t *testing.T) { + store.SetDefaultApp("app1") + err := store.SaveOAuth2TokenForApp("app1", "", "fallback-tok", "fallback-ref", 444) + require.NoError(t, err) + + username, tok := store.GetFirstOAuth2TokenRecordForApp("app1") + require.NotNil(t, tok) + assert.NotEmpty(t, username) + assert.NotEqual(t, "fallback-tok", tok.OAuth2.AccessToken) + }) } func TestUpdateApp(t *testing.T) { @@ -253,10 +279,23 @@ func TestUpdateApp(t *testing.T) { assert.Equal(t, "newer-secret", app.ClientSecret) }) + t.Run("Set and get redirect URI", func(t *testing.T) { + err := store.SetAppRedirectURI("myapp", "http://localhost:8080/callback") + require.NoError(t, err) + redirectURI, err := store.GetAppRedirectURI("myapp") + require.NoError(t, err) + assert.Equal(t, "http://localhost:8080/callback", redirectURI) + }) + t.Run("Update nonexistent app fails", func(t *testing.T) { err := store.UpdateApp("nope", "x", "y") assert.Error(t, err) }) + + t.Run("Get redirect URI for nonexistent app fails", func(t *testing.T) { + _, err := store.GetAppRedirectURI("nope") + assert.Error(t, err) + }) } func TestCredentialBackfill(t *testing.T) {