From 41c41b930663ed18a1815c399a45c4d023a215a3 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 8 Sep 2026 22:29:09 +0300 Subject: [PATCH 1/5] feat(api): sign HTTP adapter notifications with phone-ID JWT Sign HTTPS adapter notification requests with a JWT the same way webhook requests are signed, using the phone ID (a UUID) as the HMAC secret instead of a per-webhook signing key. - FCMClient.Send now takes the sending phone's ID so HTTP-transport clients can generate a bearer token; Firebase/emulator clients ignore it. - HTTPNotificationSender signs a JWT (10 min validity, audience is the endpoint URL with any userinfo stripped) and sends it via the X-Httpsms-Signature header rather than Authorization, so adapters can still use HTTP basic auth embedded in the endpoint URL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/emulator_fcm_client.go | 3 +- api/pkg/services/fcm_client.go | 6 ++- api/pkg/services/http_notification_sender.go | 39 ++++++++++++++++++- .../services/http_notification_sender_test.go | 39 ++++++++++++++++++- .../services/phone_notification_service.go | 2 +- .../phone_notification_service_test.go | 4 ++ 6 files changed, 85 insertions(+), 8 deletions(-) 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..8e592de8 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -4,12 +4,14 @@ 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 sending phone 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 +25,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..40e35c23 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,11 @@ const ( notificationHTTPAttempts = 3 notificationHTTPTimeout = 5 * time.Second notificationHTTPRetryDelay = 250 * time.Millisecond + notificationJWTIssuer = "api.httpsms.com" + notificationJWTValidity = 10 * time.Minute + // notificationSignatureHeader carries the phone-signed JWT. It is not sent as Authorization so + // adapters can still use HTTP basic auth embedded in the endpoint URL (see [url.URL.User]). + notificationSignatureHeader = "X-Httpsms-Signature" ) // HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. @@ -62,6 +70,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 +87,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 +105,24 @@ 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. +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)), + Subject: phoneID.String(), + }) + 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(notificationSignatureHeader, 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..e77d1f86 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,19 @@ 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("X-Httpsms-Signature"), "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.Equal(t, testNotificationPhoneID.String(), claims["sub"]) + 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 +164,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 +194,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 +222,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 +244,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 +271,7 @@ func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { Priority: "high", }, }, + testNotificationPhoneID, ) require.NoError(t, err) @@ -280,6 +299,19 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { assert.True(t, ok) assert.Equal(t, "adapter-user", username) assert.Equal(t, "adapter-password", password) + + token, err := jwt.Parse(strings.TrimPrefix(request.Header.Get("X-Httpsms-Signature"), "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + 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 +324,7 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: endpoint.String()}, + testNotificationPhoneID, ) require.NoError(t, err) @@ -309,6 +342,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 +362,7 @@ func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testi _, err := sender.Send( ctx, &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.Error(t, err) @@ -339,7 +374,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) } From 2c930edf89e35902fc62773884e0630be139093f Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 8 Sep 2026 23:01:13 +0300 Subject: [PATCH 2/5] refactor(api): send adapter JWT via Authorization header Send the phone-signed notification JWT via the standard Authorization header, matching webhook requests exactly, instead of a dedicated X-Httpsms-Signature header. Adapter endpoint URLs are no longer expected to carry HTTP basic auth credentials, since Authorization is now always used for the bearer JWT; update the corresponding test to assert basic auth from the URL is ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/http_notification_sender.go | 5 +---- .../services/http_notification_sender_test.go | 18 +++++++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index 40e35c23..3869d208 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -26,9 +26,6 @@ const ( notificationHTTPRetryDelay = 250 * time.Millisecond notificationJWTIssuer = "api.httpsms.com" notificationJWTValidity = 10 * time.Minute - // notificationSignatureHeader carries the phone-signed JWT. It is not sent as Authorization so - // adapters can still use HTTP basic auth embedded in the endpoint URL (see [url.URL.User]). - notificationSignatureHeader = "X-Httpsms-Signature" ) // HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. @@ -170,7 +167,7 @@ func createHTTPNotificationRequest( return nil, err } request.Header.Set("Content-Type", "application/json") - request.Header.Set(notificationSignatureHeader, fmt.Sprintf("Bearer %s", authToken)) + 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 e77d1f86..952b8333 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -62,7 +62,7 @@ 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("X-Httpsms-Signature"), "Bearer "), func(*jwt.Token) (interface{}, error) { + 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) @@ -293,17 +293,21 @@ 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) - token, err := jwt.Parse(strings.TrimPrefix(request.Header.Get("X-Httpsms-Signature"), "Bearer "), func(*jwt.Token) (interface{}, error) { + 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() From 4e29d1fca87990ebbc9c5b9dd482e67881751fc0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 8 Sep 2026 23:14:58 +0300 Subject: [PATCH 3/5] test: validate adapter notification JWT auth in emulator The adapter emulator now requires and validates the phone-ID-signed JWT (Authorization: Bearer) that the API sends with every FCM-compatible notification, mirroring the webhook JWT validation already used in integration tests. - adapter-emulator: gateway registration now requires phone_id; notification_handler verifies the JWT (HS256, sub==phone_id, iss==api.httpsms.com) before recording/processing, rejecting invalid/missing tokens with 401. - emulator_test.go: updated existing tests to register phone_id and send valid tokens; added negative tests for missing auth and wrong signing secret. - helpers_test.go: setupAdapterPhone now upserts the phone before registering the gateway (so phone_id is known), and adds an assertAdapterNotificationJWT helper mirroring assertWebhookJWT. - adapter_integration_test.go: asserts the JWT on recorded message and heartbeat notifications. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45ed9de9-a3ad-41cf-ad32-ebec28d9771c --- tests/adapter-emulator/control_handler.go | 6 +- tests/adapter-emulator/emulator.go | 25 +++--- tests/adapter-emulator/emulator_test.go | 90 +++++++++++++++++++ tests/adapter-emulator/go.mod | 2 + tests/adapter-emulator/go.sum | 2 + .../adapter-emulator/notification_handler.go | 45 ++++++++++ tests/adapter_integration_test.go | 2 + tests/helpers_test.go | 77 ++++++++++++---- 8 files changed, 218 insertions(+), 31 deletions(-) create mode 100644 tests/adapter-emulator/go.sum 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..58c2afbb 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,76 @@ 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 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)), + Subject: phoneID, + }) + 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..e7b6ceab 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,34 @@ 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). +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 _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + 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.Subject != phoneID { + return fmt.Errorf("subject mismatch") + } + 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..d7a48a3f 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,40 @@ 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 adapter emulator itself +// rejects notifications with an invalid token (401), so a processed record with this header +// recorded is already proof the signature validated; this assertion additionally checks the +// claim shape from the test side. +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.Equal(t, phoneID, claims["sub"], "subject must be the receiving phone's ID") + + 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) From def44f19b68da07ff0a7d6e8cb1841087ba693a8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 8 Sep 2026 23:26:57 +0300 Subject: [PATCH 4/5] fix(api): stop embedding phone ID in adapter notification JWT claims Address PR review: the token used phoneID as both the readable sub claim and the HS256 signing secret, so anyone who saw one token could read the secret and forge further ones. The sub claim is unnecessary since the adapter already knows which phone ID to verify against from its own gateway registration, so it is removed; the phone ID remains the signing secret only. - http_notification_sender.go: getAuthToken no longer sets Subject. - adapter-emulator/notification_handler.go: verifyNotificationAuth no longer checks claims.Subject. - Updated tests in api/pkg/services and tests/ to assert sub is empty instead of equal to the phone ID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45ed9de9-a3ad-41cf-ad32-ebec28d9771c --- api/pkg/services/http_notification_sender.go | 7 +++++-- api/pkg/services/http_notification_sender_test.go | 3 ++- tests/adapter-emulator/emulator_test.go | 1 - tests/adapter-emulator/notification_handler.go | 7 +++---- tests/helpers_test.go | 9 ++++----- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index 3869d208..5fc11582 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -103,7 +103,11 @@ func (sender *HTTPNotificationSender) Send( } // 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 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 @@ -115,7 +119,6 @@ func (sender *HTTPNotificationSender) getAuthToken(endpoint *url.URL, phoneID uu IssuedAt: jwt.NewNumericDate(now), Issuer: notificationJWTIssuer, NotBefore: jwt.NewNumericDate(now.Add(-notificationJWTValidity)), - Subject: phoneID.String(), }) return token.SignedString([]byte(phoneID.String())) } diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 952b8333..018765cf 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -69,7 +69,8 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { assert.True(t, token.Valid) claims, ok := token.Claims.(jwt.MapClaims) require.True(t, ok) - assert.Equal(t, testNotificationPhoneID.String(), claims["sub"]) + 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 })) diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go index 58c2afbb..b78f31f3 100644 --- a/tests/adapter-emulator/emulator_test.go +++ b/tests/adapter-emulator/emulator_test.go @@ -487,7 +487,6 @@ func validNotificationToken(t *testing.T, phoneID string) string { IssuedAt: jwt.NewNumericDate(now), Issuer: notificationJWTIssuer, NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), - Subject: phoneID, }) signed, err := token.SignedString([]byte(phoneID)) if err != nil { diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go index e7b6ceab..6500d7ae 100644 --- a/tests/adapter-emulator/notification_handler.go +++ b/tests/adapter-emulator/notification_handler.go @@ -111,7 +111,9 @@ func notificationKind(data map[string]string) (kind string, messageID string, er // 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). +// 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) == "" { @@ -131,9 +133,6 @@ func verifyNotificationAuth(authorization string, phoneID string) error { if !token.Valid { return fmt.Errorf("token is not valid") } - if claims.Subject != phoneID { - return fmt.Errorf("subject mismatch") - } if claims.Issuer != notificationJWTIssuer { return fmt.Errorf("issuer mismatch") } diff --git a/tests/helpers_test.go b/tests/helpers_test.go index d7a48a3f..797b2f09 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -587,10 +587,9 @@ func assertWebhookJWT(t *testing.T, request wmJournal.Request, signingKey string // 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 adapter emulator itself -// rejects notifications with an invalid token (401), so a processed record with this header -// recorded is already proof the signature validated; this assertion additionally checks the -// claim shape from the test side. +// 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() @@ -608,7 +607,7 @@ func assertAdapterNotificationJWT(t *testing.T, record notificationRecord, phone claims, ok := token.Claims.(jwt.MapClaims) require.True(t, ok, "cannot parse claims") require.Equal(t, "api.httpsms.com", claims["iss"], "issuer mismatch") - require.Equal(t, phoneID, claims["sub"], "subject must be the receiving phone's ID") + 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) From 7896ab5f5f5e153b7e36221a35a316a5a1f02f2c Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 8 Sep 2026 23:31:55 +0300 Subject: [PATCH 5/5] fix(api): address remaining PR review comments - fcm_client.go: fix Send doc comment - phoneID identifies the receiving/target phone, not the sending phone. - adapter-emulator/notification_handler.go: verifyNotificationAuth now requires the HS256 signing method specifically instead of accepting any HMAC variant, matching what the API actually signs with. - emulator_test.go: added TestNotificationHandlerRejectsNonHS256SigningMethod covering the HS256-only check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45ed9de9-a3ad-41cf-ad32-ebec28d9771c --- api/pkg/services/fcm_client.go | 3 +- tests/adapter-emulator/emulator_test.go | 39 +++++++++++++++++++ .../adapter-emulator/notification_handler.go | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 8e592de8..8139c808 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -10,7 +10,8 @@ import ( // 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. - // phoneID identifies the sending phone and is used by HTTP adapter transports to sign the request. + // 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) } diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go index b78f31f3..28d5b1dd 100644 --- a/tests/adapter-emulator/emulator_test.go +++ b/tests/adapter-emulator/emulator_test.go @@ -477,6 +477,45 @@ func TestNotificationHandlerRejectsTokenSignedWithWrongSecret(t *testing.T) { } } +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() diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go index 6500d7ae..e17415f4 100644 --- a/tests/adapter-emulator/notification_handler.go +++ b/tests/adapter-emulator/notification_handler.go @@ -122,7 +122,7 @@ func verifyNotificationAuth(authorization string, phoneID string) error { claims := jwt.RegisteredClaims{} token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + if token.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(phoneID), nil