diff --git a/CHANGELOG.md b/CHANGELOG.md index 06cf6da..3f91ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All user-visible bugs and enhancements should be recorded here. ## Unreleased +### Added + +- `xurl webhook start` answers CRC challenges using the selected app's OAuth2 client secret when available, with OAuth1 consumer-secret fallback for legacy integrations. Missing signing credentials now report both supported options. + ## v1.3.4 - 2026-09-29 ### Fixed diff --git a/README.md b/README.md index 92a78eb..d46dc37 100644 --- a/README.md +++ b/README.md @@ -310,12 +310,26 @@ All diagnostics are written to stderr so stdout stays a clean JSON-RPC channel. `xurl` can help you quickly set up a temporary webhook URL to receive events from the X API. This is useful for development and testing. +CRC checks use the selected app's stored **OAuth 2.0 client secret** (recommended), falling back to its **OAuth 1.0a consumer secret** for legacy integrations. Bearer tokens and user access tokens are not CRC signing secrets. An OAuth2-only app does not need OAuth1 credentials or user authorization to answer CRC challenges. + +Register your app's OAuth2 credentials before starting the receiver: + +```bash +xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET +# For an already registered app: +xurl auth apps update my-app --client-secret YOUR_CLIENT_SECRET +``` + +Use `--app my-app` to select that app; otherwise, the configured default app is used. See the [X webhook CRC instructions](https://docs.x.com/x-api/webhooks/quickstart#how-to-build-the-crc-response) for details. + 1. **Start the local webhook server with ngrok:** Run the `webhook start` command. This will start a local server and use ngrok to create a public URL that forwards to your local server. You will be prompted for your ngrok authtoken if it's not already configured via the `NGROK_AUTHTOKEN` environment variable. ```bash xurl webhook start + # Or select a registered app explicitly + xurl --app my-app webhook start # Or with a specific port and output file for POST bodies xurl webhook start -p 8081 -o webhook_events.log ``` diff --git a/cli/webhook.go b/cli/webhook.go index f222593..e57fdee 100644 --- a/cli/webhook.go +++ b/cli/webhook.go @@ -39,21 +39,15 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { webhookStartCmd := &cobra.Command{ Use: "start", Short: "Start a local webhook server with an ngrok tunnel", - Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks. POST request bodies can be saved to a file using the -o flag. Use -q for quieter console logging of POST events. Use -P to pretty-print JSON POST bodies in the console.`, + Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks. CRC checks use the selected app's OAuth 2.0 client secret, or its OAuth 1.0a consumer secret for legacy integrations. POST request bodies can be saved to a file using the -o flag. Use -q for quieter console logging of POST events. Use -P to pretty-print JSON POST bodies in the console.`, Run: func(cmd *cobra.Command, args []string) { color.Cyan("Starting webhook server with ngrok...") - if authInstance == nil || authInstance.TokenStore == nil { - color.Red("Error: Authentication module not initialized properly.") - os.Exit(1) - } - - 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'.") + signingSecret, err := webhookSigningSecret(authInstance) + if err != nil { + color.Red("Error: %v", err) os.Exit(1) } - consumerSecret := oauth1Token.OAuth1.ConsumerSecret // Handle output file if -o flag is used var outputFile *os.File @@ -105,26 +99,7 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { mux := http.NewServeMux() mux.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - crcToken := r.URL.Query().Get("crc_token") - if crcToken == "" { - http.Error(w, "Error: crc_token missing from request", http.StatusBadRequest) - log.Printf("[WARN] Received GET /webhook without crc_token") - return - } - log.Printf("[INFO] Received GET %s%s with crc_token: %s", color.BlueString(r.Host), color.BlueString(r.URL.Path), color.YellowString(crcToken)) - - mac := hmac.New(sha256.New, []byte(consumerSecret)) - mac.Write([]byte(crcToken)) - hashedToken := mac.Sum(nil) - encodedToken := base64.StdEncoding.EncodeToString(hashedToken) - - response := map[string]string{ - "response_token": "sha256=" + encodedToken, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - log.Printf("[INFO] Responded to CRC check with token: %s", color.GreenString(response["response_token"])) - + handleWebhookCRC(w, r, signingSecret) } else if r.Method == http.MethodPost { bodyBytes, err := io.ReadAll(r.Body) if err != nil { @@ -193,3 +168,44 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { webhookCmd.AddCommand(webhookStartCmd) return webhookCmd } + +func webhookSigningSecret(a *auth.Auth) (string, error) { + if a == nil || a.TokenStore == nil { + return "", fmt.Errorf("authentication module not initialized properly") + } + appName := a.TokenStore.GetActiveAppName(a.AppName()) + // Resolve only the selected app: ResolveApp would fall back to the default + // app for an unknown name, potentially signing with another app's secret. + app := a.TokenStore.GetApp(appName) + if app != nil { + if app.ClientSecret != "" { + return app.ClientSecret, nil + } + if token := app.OAuth1Token; token != nil && token.OAuth1 != nil && token.OAuth1.ConsumerSecret != "" { + return token.OAuth1.ConsumerSecret, nil + } + } + return "", fmt.Errorf("webhook signing secret not found for app %q: configure an OAuth 2.0 client secret with 'xurl auth apps add' or 'xurl auth apps update', or an OAuth 1.0a consumer secret for legacy integrations", appName) +} + +func handleWebhookCRC(w http.ResponseWriter, r *http.Request, signingSecret string) { + crcToken := r.URL.Query().Get("crc_token") + if crcToken == "" { + http.Error(w, "Error: crc_token missing from request", http.StatusBadRequest) + log.Printf("[WARN] Received GET /webhook without crc_token") + return + } + log.Printf("[INFO] Received GET %s%s with crc_token: %s", color.BlueString(r.Host), color.BlueString(r.URL.Path), color.YellowString(crcToken)) + + mac := hmac.New(sha256.New, []byte(signingSecret)) + mac.Write([]byte(crcToken)) + hashedToken := mac.Sum(nil) + encodedToken := base64.StdEncoding.EncodeToString(hashedToken) + + response := map[string]string{ + "response_token": "sha256=" + encodedToken, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + log.Printf("[INFO] Responded to CRC check with token: %s", color.GreenString(response["response_token"])) +} diff --git a/cli/webhook_test.go b/cli/webhook_test.go new file mode 100644 index 0000000..edfbd93 --- /dev/null +++ b/cli/webhook_test.go @@ -0,0 +1,178 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xdevplatform/xurl/auth" + "github.com/xdevplatform/xurl/store" +) + +func TestWebhookSigningSecret(t *testing.T) { + legacy := &store.Token{OAuth1: &store.OAuth1Token{ConsumerSecret: "oauth1-consumer-secret"}} + tests := []struct { + name string + apps map[string]*store.App + defaultApp string + appName string + want string + }{ + { + name: "OAuth2-only app needs no user tokens", + apps: map[string]*store.App{"selected": {ClientID: "client-id", ClientSecret: "oauth2-client-secret"}}, + defaultApp: "selected", + want: "oauth2-client-secret", + }, + { + name: "OAuth1-only app uses consumer secret without access tokens", + apps: map[string]*store.App{"selected": {OAuth1Token: legacy}}, + defaultApp: "selected", + want: "oauth1-consumer-secret", + }, + { + name: "OAuth2 takes precedence over OAuth1", + apps: map[string]*store.App{"selected": {ClientSecret: "oauth2-client-secret", OAuth1Token: legacy}}, + defaultApp: "selected", + want: "oauth2-client-secret", + }, + { + name: "explicit app overrides default", + apps: map[string]*store.App{ + "default": {ClientSecret: "another-app-secret"}, + "selected": {ClientSecret: "oauth2-client-secret"}, + }, + defaultApp: "default", + appName: "selected", + want: "oauth2-client-secret", + }, + { + name: "explicit OAuth1 app does not use default OAuth2 secret", + apps: map[string]*store.App{ + "default": {ClientSecret: "another-app-secret"}, + "selected": {OAuth1Token: legacy}, + }, + defaultApp: "default", + appName: "selected", + want: "oauth1-consumer-secret", + }, + { + name: "client ID alone is not a signing secret", + apps: map[string]*store.App{"selected": {ClientID: "client-id"}}, + defaultApp: "selected", + }, + { + name: "bearer and user tokens are not signing secrets", + apps: map[string]*store.App{"selected": { + BearerToken: &store.Token{Bearer: "bearer-token"}, + OAuth2Tokens: map[string]store.Token{ + "user": {OAuth2: &store.OAuth2Token{AccessToken: "access-token", RefreshToken: "refresh-token"}}, + }, + OAuth1Token: &store.Token{OAuth1: &store.OAuth1Token{AccessToken: "access-token", TokenSecret: "token-secret"}}, + }}, + defaultApp: "selected", + }, + { + name: "missing OAuth1 fields", + apps: map[string]*store.App{"selected": {OAuth1Token: &store.Token{}}}, + defaultApp: "selected", + }, + { + name: "secretless app does not borrow default credentials", + apps: map[string]*store.App{ + "default": {ClientSecret: "another-app-secret", OAuth1Token: legacy}, + "selected": {}, + }, + defaultApp: "default", + appName: "selected", + }, + { + name: "unknown app does not fall back to default", + apps: map[string]*store.App{"default": {ClientSecret: "another-app-secret", OAuth1Token: legacy}}, + defaultApp: "default", + appName: "unknown", + }, + { + name: "missing default does not pick another app", + apps: map[string]*store.App{"other": {ClientSecret: "another-app-secret"}}, + defaultApp: "missing", + }, + { + name: "empty store", + apps: map[string]*store.App{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &auth.Auth{TokenStore: &store.TokenStore{Apps: tt.apps, DefaultApp: tt.defaultApp}} + if tt.appName != "" { + a.WithAppName(tt.appName) + } + secret, err := webhookSigningSecret(a) + if tt.want == "" { + require.Error(t, err) + assert.Empty(t, secret) + assert.Contains(t, err.Error(), "OAuth 2.0 client secret") + assert.Contains(t, err.Error(), "OAuth 1.0a consumer secret") + assert.NotContains(t, err.Error(), "xurl auth oauth1") + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, secret) + }) + } +} + +func TestWebhookSigningSecretUninitializedAuth(t *testing.T) { + for _, a := range []*auth.Auth{nil, {}} { + secret, err := webhookSigningSecret(a) + require.Error(t, err) + assert.Empty(t, secret) + } +} + +func TestWebhookCRCUsesSelectedSigningSecret(t *testing.T) { + for _, tt := range []struct { + name string + app *store.App + want string + }{ + { + name: "OAuth2 preferred", + app: &store.App{ + ClientSecret: "oauth2-client-secret", + OAuth1Token: &store.Token{OAuth1: &store.OAuth1Token{ConsumerSecret: "oauth1-consumer-secret"}}, + }, + want: "sha256=EBdkssF58HZ4DsDnQ/U28weK23NCd7d+AfU7JT+eYUo=", + }, + { + name: "OAuth1 fallback", + app: &store.App{OAuth1Token: &store.Token{OAuth1: &store.OAuth1Token{ConsumerSecret: "oauth1-consumer-secret"}}}, + want: "sha256=qb6/EujLt/sq/1RIIK986dZulgi6Shq9ZyVXMWtGPMg=", + }, + } { + t.Run(tt.name, func(t *testing.T) { + a := &auth.Auth{TokenStore: &store.TokenStore{Apps: map[string]*store.App{"selected": tt.app}, DefaultApp: "selected"}} + secret, err := webhookSigningSecret(a) + require.NoError(t, err) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/webhook?crc_token=crc-test-token", nil) + handleWebhookCRC(recorder, request, secret) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, "application/json", recorder.Header().Get("Content-Type")) + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + assert.Equal(t, map[string]string{"response_token": tt.want}, response) + }) + } +} + +func TestWebhookCRCRejectsMissingToken(t *testing.T) { + recorder := httptest.NewRecorder() + handleWebhookCRC(recorder, httptest.NewRequest(http.MethodGet, "/webhook", nil), "synthetic-secret") + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.NotContains(t, recorder.Body.String(), "response_token") +}