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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
76 changes: 46 additions & 30 deletions cli/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"]))
}
178 changes: 178 additions & 0 deletions cli/webhook_test.go
Original file line number Diff line number Diff line change
@@ -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")
}