diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go index 6b890a14..09dce8e5 100644 --- a/api/pkg/services/emulator_fcm_client.go +++ b/api/pkg/services/emulator_fcm_client.go @@ -11,6 +11,7 @@ import ( "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" ) // EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. @@ -50,7 +51,7 @@ type emulatorFCMResponse struct { } // Send sends a message to the emulator's FCM endpoint. -func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { payload := &emulatorFCMRequest{ Message: &emulatorFCMMessage{ Token: message.Token, diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 6b60b824..8139c808 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -4,12 +4,15 @@ import ( "context" "firebase.google.com/go/messaging" + "github.com/google/uuid" ) // FCMClient sends Firebase-compatible messages through a phone notification transport. type FCMClient interface { // Send sends a message and returns the transport's delivery identifier on success. - Send(ctx context.Context, message *messaging.Message) (string, error) + // phoneID identifies the receiving phone (the notification's target) and is used by HTTP + // adapter transports to sign the request. + Send(ctx context.Context, message *messaging.Message, phoneID uuid.UUID) (string, error) } // FirebaseFCMClient wraps the real Firebase messaging.Client. @@ -23,6 +26,6 @@ func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient { } // Send sends a message via the real Firebase SDK. -func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { return c.client.Send(ctx, message) } diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index 9477611a..5fc11582 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -14,6 +15,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" "github.com/avast/retry-go/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) const ( @@ -21,6 +24,8 @@ const ( notificationHTTPAttempts = 3 notificationHTTPTimeout = 5 * time.Second notificationHTTPRetryDelay = 250 * time.Millisecond + notificationJWTIssuer = "api.httpsms.com" + notificationJWTValidity = 10 * time.Minute ) // HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. @@ -62,6 +67,7 @@ func newHTTPNotificationSenderWithRetrier( func (sender *HTTPNotificationSender) Send( ctx context.Context, message *messaging.Message, + phoneID uuid.UUID, ) (string, error) { if message == nil { return "", sender.notificationError("", "notification message is nil") @@ -78,8 +84,13 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "cannot encode notification") } + authToken, err := sender.getAuthToken(endpoint, phoneID) + if err != nil { + return "", sender.notificationError(hostname, "cannot generate notification auth token") + } + err = sender.retrier.Do(func() error { - return sender.deliver(ctx, endpoint, body) + return sender.deliver(ctx, endpoint, body, authToken) }) if err == nil { return "http/success", nil @@ -91,6 +102,27 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "notification request failed") } +// getAuthToken generates a JWT bearer token for the HTTPS adapter, signed with the phone ID +// the same way webhook requests are signed with the webhook signing key. The phone ID is only +// used as the HMAC secret and is intentionally not embedded in any claim: the adapter already +// knows which phone ID to verify against from its own gateway registration, and putting the +// phone ID in a readable claim would let anyone who intercepts one token read the signing +// secret and forge further tokens. +func (sender *HTTPNotificationSender) getAuthToken(endpoint *url.URL, phoneID uuid.UUID) (string, error) { + audience := *endpoint + audience.User = nil + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{audience.String()}, + ExpiresAt: jwt.NewNumericDate(now.Add(notificationJWTValidity)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-notificationJWTValidity)), + }) + return token.SignedString([]byte(phoneID.String())) +} + func encodeHTTPNotificationPayload(message *messaging.Message) ([]byte, error) { return json.Marshal(map[string]any{ "message": message, @@ -101,6 +133,7 @@ func (sender *HTTPNotificationSender) deliver( ctx context.Context, endpoint *url.URL, body []byte, + authToken string, ) error { if err := ctx.Err(); err != nil { return terminalNotificationRequestError{cause: err} @@ -109,7 +142,7 @@ func (sender *HTTPNotificationSender) deliver( attemptCtx, cancel := context.WithTimeout(ctx, sender.timeout) defer cancel() - request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body) + request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body, authToken) if err != nil { return terminalNotificationRequestError{cause: err} } @@ -125,6 +158,7 @@ func createHTTPNotificationRequest( ctx context.Context, endpoint *url.URL, body []byte, + authToken string, ) (*http.Request, error) { request, err := http.NewRequestWithContext( ctx, @@ -136,6 +170,7 @@ func createHTTPNotificationRequest( return nil, err } request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) return request, nil } diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 075f99c8..018765cf 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -8,16 +8,21 @@ import ( "net/http" "net/url" "reflect" + "strings" "testing" "time" "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" ) +var testNotificationPhoneID = uuid.New() + type roundTripFunc func(*http.Request) (*http.Response, error) func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { @@ -57,10 +62,20 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { assert.Equal(t, "high", payload.Message.Android.Priority) assert.Equal(t, "600s", payload.Message.Android.TTL) + token, err := jwt.Parse(strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + assert.Equal(t, "api.httpsms.com", claims["iss"]) + return response(http.StatusNoContent, http.NoBody), nil })) - result, err := sender.Send(context.Background(), message) + result, err := sender.Send(context.Background(), message, testNotificationPhoneID) require.NoError(t, err) assert.Equal(t, "http/success", result) @@ -150,6 +165,7 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { result, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) if test.wantErr { @@ -179,6 +195,7 @@ func TestHTTPNotificationSenderReusesRetrierAcrossSends(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.NoError(t, err) } @@ -206,6 +223,7 @@ func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *test Token: "https://adapter.example.com/notify", Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, }, + testNotificationPhoneID, ) require.NoError(t, err) @@ -227,6 +245,7 @@ func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.NoError(t, err) @@ -253,6 +272,7 @@ func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { Priority: "high", }, }, + testNotificationPhoneID, ) require.NoError(t, err) @@ -274,12 +294,29 @@ func TestHTTPNotificationSenderUsesInjectedHTTPClientUnchanged(t *testing.T) { assert.Equal(t, time.Minute, sender.client.Timeout) } -func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { +func TestHTTPNotificationSenderIgnoresEndpointUserInformation(t *testing.T) { + // Adapter endpoints must not rely on HTTP basic auth embedded in the URL; the Authorization + // header always carries the phone-signed JWT instead. sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { - username, password, ok := request.BasicAuth() - assert.True(t, ok) - assert.Equal(t, "adapter-user", username) - assert.Equal(t, "adapter-password", password) + _, _, ok := request.BasicAuth() + assert.False(t, ok) + + authorization := request.Header.Get("Authorization") + assert.True(t, strings.HasPrefix(authorization, "Bearer ")) + + token, err := jwt.Parse(strings.TrimPrefix(authorization, "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + audience, err := claims.GetAudience() + require.NoError(t, err) + require.Len(t, audience, 1) + assert.NotContains(t, audience[0], "adapter-user") + assert.NotContains(t, audience[0], "adapter-password") + return response(http.StatusNoContent, http.NoBody), nil })) endpoint := &url.URL{ @@ -292,6 +329,7 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: endpoint.String()}, + testNotificationPhoneID, ) require.NoError(t, err) @@ -309,6 +347,7 @@ func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.Error(t, err) @@ -328,6 +367,7 @@ func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testi _, err := sender.Send( ctx, &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.Error(t, err) @@ -339,7 +379,7 @@ func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { return response(http.StatusNoContent, http.NoBody), nil })) - _, err := sender.Send(context.Background(), nil) + _, err := sender.Send(context.Background(), nil, testNotificationPhoneID) require.Error(t, err) assert.Contains(t, err.Error(), "notification message is nil") diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index d3166854..2c63fcd7 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -215,7 +215,7 @@ func (service *PhoneNotificationService) sendPhoneNotification( } message.Token = strings.TrimSpace(*phone.FcmToken) - result, err := client.Send(ctx, message) + result, err := client.Send(ctx, message, phone.ID) if err != nil { return "", transport, stacktrace.Propagatef( err, diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go index 9908f5d9..9f0c42a4 100644 --- a/api/pkg/services/phone_notification_service_test.go +++ b/api/pkg/services/phone_notification_service_test.go @@ -90,6 +90,7 @@ func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} type recordingPhoneNotificationClient struct { message *messaging.Message + phoneID uuid.UUID result string err error calls int @@ -98,9 +99,11 @@ type recordingPhoneNotificationClient struct { func (client *recordingPhoneNotificationClient) Send( _ context.Context, message *messaging.Message, + phoneID uuid.UUID, ) (string, error) { client.calls++ client.message = message + client.phoneID = phoneID return client.result, client.err } @@ -122,6 +125,7 @@ func TestPhoneNotificationServiceSendPhoneNotificationUsesMappedClient(t *testin assert.Equal(t, entities.NotificationTransportHTTP, transport) assert.Equal(t, "https://adapter.example.com/notify", message.Token) assert.Same(t, message, httpClient.message) + assert.Equal(t, phone.ID, httpClient.phoneID) assert.Equal(t, 1, httpClient.calls) } diff --git a/tests/adapter-emulator/control_handler.go b/tests/adapter-emulator/control_handler.go index cfe06689..7211a034 100644 --- a/tests/adapter-emulator/control_handler.go +++ b/tests/adapter-emulator/control_handler.go @@ -14,6 +14,7 @@ const maxControlBodyBytes = 1024 * 1024 type gatewayRegistration struct { PhoneNumber string `json:"phone_number"` PhoneAPIKey string `json:"phone_api_key"` + PhoneID string `json:"phone_id"` } type incomingMessageRequest struct { @@ -39,8 +40,9 @@ func (instance *emulator) handleGatewayRegistration(writer http.ResponseWriter, } registration.PhoneNumber = strings.TrimSpace(registration.PhoneNumber) registration.PhoneAPIKey = strings.TrimSpace(registration.PhoneAPIKey) - if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" { - writeControlError(writer, http.StatusBadRequest, errors.New("phone_number and phone_api_key are required")) + registration.PhoneID = strings.TrimSpace(registration.PhoneID) + if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" || registration.PhoneID == "" { + writeControlError(writer, http.StatusBadRequest, errors.New("phone_number, phone_api_key and phone_id are required")) return } diff --git a/tests/adapter-emulator/emulator.go b/tests/adapter-emulator/emulator.go index 13d0568d..bcc2fe70 100644 --- a/tests/adapter-emulator/emulator.go +++ b/tests/adapter-emulator/emulator.go @@ -9,15 +9,17 @@ import ( type gateway struct { PhoneNumber string PhoneAPIKey string + PhoneID string } type notificationRecord struct { - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` + Authorization string `json:"authorization,omitempty"` } type emulator struct { @@ -43,6 +45,7 @@ func (instance *emulator) registerGateway(gatewayID string, registration gateway instance.gateways[gatewayID] = gateway{ PhoneNumber: registration.PhoneNumber, PhoneAPIKey: registration.PhoneAPIKey, + PhoneID: registration.PhoneID, } } @@ -59,15 +62,17 @@ func (instance *emulator) recordNotification( data map[string]string, kind string, messageID string, + authorization string, ) *notificationRecord { instance.mu.Lock() defer instance.mu.Unlock() record := ¬ificationRecord{ - GatewayID: gatewayID, - Data: copyStringMap(data), - MessageID: messageID, - Kind: kind, + GatewayID: gatewayID, + Data: copyStringMap(data), + MessageID: messageID, + Kind: kind, + Authorization: authorization, } instance.records = append(instance.records, record) diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go index b8be3c2b..28d5b1dd 100644 --- a/tests/adapter-emulator/emulator_test.go +++ b/tests/adapter-emulator/emulator_test.go @@ -9,8 +9,13 @@ import ( "strings" "sync" "testing" + "time" + + "github.com/golang-jwt/jwt/v5" ) +const testGatewayPhoneID = "11111111-1111-1111-1111-111111111111" + func TestRecordNotificationCopiesRecords(t *testing.T) { t.Parallel() @@ -18,6 +23,7 @@ func TestRecordNotificationCopiesRecords(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) record := instance.recordNotification( @@ -25,6 +31,7 @@ func TestRecordNotificationCopiesRecords(t *testing.T) { map[string]string{"KEY_MESSAGE_ID": "message-1"}, "message", "message-1", + "Bearer test-token", ) instance.markNotificationProcessed(record) @@ -90,6 +97,7 @@ func TestNotificationHandlerProcessesMessage(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) @@ -98,6 +106,7 @@ func TestNotificationHandlerProcessesMessage(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(body), ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusNoContent { @@ -146,6 +155,7 @@ func TestNotificationHandlerStoresHeartbeat(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) request := httptest.NewRequest( @@ -153,6 +163,7 @@ func TestNotificationHandlerStoresHeartbeat(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(callbackBody(t, map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"})), ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusNoContent { @@ -184,6 +195,7 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) request := httptest.NewRequest( @@ -191,6 +203,7 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { @@ -250,17 +263,20 @@ func TestNotificationHandlerProcessesRetryAfterFailure(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) handler := instance.notificationHandler() body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) firstRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + firstRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) firstResponse := httptest.NewRecorder() handler.ServeHTTP(firstResponse, firstRequest) if firstResponse.Code != http.StatusInternalServerError { t.Fatalf("first callback status = %d, want 500: %s", firstResponse.Code, firstResponse.Body.String()) } secondRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + secondRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) secondResponse := httptest.NewRecorder() handler.ServeHTTP(secondResponse, secondRequest) if secondResponse.Code != http.StatusNoContent { @@ -315,6 +331,7 @@ func TestControlHandlerRegistersGatewayAndReceivesIncomingMessage(t *testing.T) registration := performJSONRequest(t, handler, http.MethodPut, "/test/gateways/gateway-1", map[string]any{ "phone_number": "+18005550199", "phone_api_key": "phone-key", + "phone_id": testGatewayPhoneID, }) if registration.Code != http.StatusNoContent { t.Fatalf("registration status = %d, want 204: %s", registration.Code, registration.Body.String()) @@ -368,18 +385,21 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-1"}, "message", "message-1", + "Bearer test-token", ) instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-2"}, "message", "message-2", + "Bearer test-token", ) response := httptest.NewRecorder() @@ -406,6 +426,114 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { } } +func TestNotificationHandlerRejectsMissingAuthorization(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for an unauthenticated request", len(records)) + } +} + +func TestNotificationHandlerRejectsTokenSignedWithWrongSecret(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", validNotificationToken(t, "some-other-phone-id")) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for a request signed with the wrong secret", len(records)) + } +} + +func TestNotificationHandlerRejectsNonHS256SigningMethod(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS384, jwt.RegisteredClaims{ + Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + }) + signed, err := token.SignedString([]byte(testGatewayPhoneID)) + if err != nil { + t.Fatalf("sign notification token: %v", err) + } + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", "Bearer "+signed) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for a request signed with a non-HS256 method", len(records)) + } +} + +func validNotificationToken(t *testing.T, phoneID string) string { + t.Helper() + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + }) + signed, err := token.SignedString([]byte(phoneID)) + if err != nil { + t.Fatalf("sign notification token: %v", err) + } + return "Bearer " + signed +} + func callbackBody(t *testing.T, data map[string]string) []byte { t.Helper() diff --git a/tests/adapter-emulator/go.mod b/tests/adapter-emulator/go.mod index 399833be..31faad6d 100644 --- a/tests/adapter-emulator/go.mod +++ b/tests/adapter-emulator/go.mod @@ -1,3 +1,5 @@ module github.com/NdoleStudio/httpsms/tests/adapter-emulator go 1.25.0 + +require github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/tests/adapter-emulator/go.sum b/tests/adapter-emulator/go.sum new file mode 100644 index 00000000..c0f72903 --- /dev/null +++ b/tests/adapter-emulator/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go index e4108eea..e17415f4 100644 --- a/tests/adapter-emulator/notification_handler.go +++ b/tests/adapter-emulator/notification_handler.go @@ -6,10 +6,16 @@ import ( "log" "net/http" "strings" + + "github.com/golang-jwt/jwt/v5" ) const maxCallbackBodyBytes = 1024 * 1024 +// notificationJWTIssuer must match the issuer the httpSMS API signs adapter notification +// tokens with (see api/pkg/services/http_notification_sender.go). +const notificationJWTIssuer = "api.httpsms.com" + type callbackEnvelope struct { Message struct { Token string `json:"token"` @@ -31,6 +37,13 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request return } + authorization := request.Header.Get("Authorization") + if err := verifyNotificationAuth(authorization, registeredGateway.PhoneID); err != nil { + log.Printf("[ADAPTER] rejected notification for gateway=%s: %v", gatewayID, err) + http.Error(writer, fmt.Sprintf("invalid notification token: %v", err), http.StatusUnauthorized) + return + } + request.Body = http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) var envelope callbackEnvelope if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil { @@ -44,6 +57,7 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request envelope.Message.Data, kind, messageID, + authorization, ) log.Printf( "[ADAPTER] callback gateway=%s data=%v", @@ -94,3 +108,33 @@ func notificationKind(data map[string]string) (kind string, messageID string, er return "", "", fmt.Errorf("unsupported notification data") } } + +// verifyNotificationAuth validates the JWT the httpSMS API signs notification requests with, +// using the gateway's phone ID as the HMAC-SHA256 secret (see +// api/pkg/services/http_notification_sender.go getAuthToken). The phone ID is never carried in +// a token claim, only used as the secret, so verification relies on the gateway's own +// registration to know which phone ID to check against rather than trusting a claim. +func verifyNotificationAuth(authorization string, phoneID string) error { + tokenString, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || strings.TrimSpace(tokenString) == "" { + return fmt.Errorf("missing bearer token") + } + + claims := jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(phoneID), nil + }) + if err != nil { + return fmt.Errorf("parse token: %w", err) + } + if !token.Valid { + return fmt.Errorf("token is not valid") + } + if claims.Issuer != notificationJWTIssuer { + return fmt.Errorf("issuer mismatch") + } + return nil +} diff --git a/tests/adapter_integration_test.go b/tests/adapter_integration_test.go index 6dea41f1..70dbbe89 100644 --- a/tests/adapter_integration_test.go +++ b/tests/adapter_integration_test.go @@ -37,6 +37,7 @@ func TestAdapterGatewayOutgoingMessage(t *testing.T) { assert.Equal(t, "message", records[0].Kind) assert.True(t, records[0].Processed) assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) + assertAdapterNotificationJWT(t, records[0], phone.PhoneID) } func TestAdapterGatewayIncomingMessage(t *testing.T) { @@ -79,6 +80,7 @@ func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) { record := waitForAdapterHeartbeatRecord(t, phone.GatewayID, 30*time.Second) assert.Equal(t, "heartbeat", record.Kind) assert.NotEmpty(t, record.Data["KEY_HEARTBEAT_ID"]) + assertAdapterNotificationJWT(t, record, phone.PhoneID) heartbeats, response, err := newAPIClient().Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{ Owner: phone.PhoneNumber, diff --git a/tests/helpers_test.go b/tests/helpers_test.go index a0c84c41..797b2f09 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -45,12 +45,13 @@ type adapterTestPhone struct { } type notificationRecord struct { - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` + Authorization string `json:"authorization,omitempty"` } func newAPIClient() *httpsms.Client { @@ -153,9 +154,26 @@ func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint phoneAPIKey := apiKeyResponse.Data.APIKey require.NotEmpty(t, phoneAPIKey) + // Upsert the phone first so its ID is known before the adapter emulator is registered: + // the API signs notification requests with a JWT keyed by the phone ID, and the emulator + // needs that ID up front to validate the JWT on every notification it receives. + callbackURL := fmt.Sprintf("https://adapter-emulator:9091/notifications/%s", gatewayID) + phoneResponse, response, err := client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ + PhoneNumber: phoneNumber, + FcmToken: callbackURL, + MessagesPerMinute: messagesPerMinute, + MaxSendAttempts: 2, + MessageExpirationSeconds: 600, + SIM: "SIM1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone upsert failed") + require.NotEmpty(t, phoneResponse.Data.ID) + registrationBody, err := json.Marshal(map[string]any{ "phone_number": phoneNumber, "phone_api_key": phoneAPIKey, + "phone_id": phoneResponse.Data.ID, }) require.NoError(t, err) registrationRequest, err := http.NewRequestWithContext( @@ -179,19 +197,6 @@ func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint string(registrationResponseBody), ) - callbackURL := fmt.Sprintf("https://adapter-emulator:9091/notifications/%s", gatewayID) - phoneResponse, response, err := client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ - PhoneNumber: phoneNumber, - FcmToken: callbackURL, - MessagesPerMinute: messagesPerMinute, - MaxSendAttempts: 2, - MessageExpirationSeconds: 600, - SIM: "SIM1", - }) - require.NoError(t, err) - require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone upsert failed") - require.NotEmpty(t, phoneResponse.Data.ID) - phoneClient := newPhoneClient(phoneAPIKey) _, response, err = phoneClient.Phones.UpsertFCMToken(ctx, &httpsms.PhoneFCMTokenParams{ PhoneNumber: phoneNumber, @@ -580,6 +585,39 @@ func assertWebhookJWT(t *testing.T, request wmJournal.Request, signingKey string require.True(t, nbf.Before(time.Now()), "token not yet valid") } +// assertAdapterNotificationJWT validates the JWT the API signs adapter notification requests +// with, using the receiving phone's ID as the HMAC-SHA256 secret (see +// api/pkg/services/http_notification_sender.go getAuthToken). The phone ID is only used as the +// secret and is never embedded in a claim, so this only checks the signature and issuer, not a +// subject; the adapter emulator itself rejects notifications with an invalid signature (401). +func assertAdapterNotificationJWT(t *testing.T, record notificationRecord, phoneID string) { + t.Helper() + + require.NotEmpty(t, record.Authorization, "adapter notification record missing Authorization header") + require.True(t, strings.HasPrefix(record.Authorization, "Bearer "), "Authorization header must start with Bearer") + + tokenString := strings.TrimPrefix(record.Authorization, "Bearer ") + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + require.Equal(t, jwt.SigningMethodHS256, token.Method, "unexpected signing method") + return []byte(phoneID), nil + }) + require.NoError(t, err, "JWT validation failed") + require.True(t, token.Valid, "JWT token is not valid") + + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok, "cannot parse claims") + require.Equal(t, "api.httpsms.com", claims["iss"], "issuer mismatch") + require.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + + exp, err := claims.GetExpirationTime() + require.NoError(t, err) + require.True(t, exp.After(time.Now()), "token is expired") + + nbf, err := claims.GetNotBefore() + require.NoError(t, err) + require.True(t, nbf.Before(time.Now()), "token not yet valid") +} + func waitForWebhookEvents(t *testing.T, webhookPath string, expectedCount int, timeout time.Duration) []wmJournal.GetRequestResponse { t.Helper() deadline := time.Now().Add(timeout)