diff --git a/.changeset/webhook-hmac-secret-fallback.md b/.changeset/webhook-hmac-secret-fallback.md new file mode 100644 index 0000000000..874fe6248c --- /dev/null +++ b/.changeset/webhook-hmac-secret-fallback.md @@ -0,0 +1,5 @@ +--- +'@shopify/shopify-api': minor +--- + +Added an optional `apiSecretKeyFallback` config option. When set, it is used as a secondary secret when validating **inbound** HMAC signatures (webhooks, Flow, and fulfillment-service requests), so requests are not dropped during a client-secret rotation while Shopify is still signing with the previous secret. Outbound signing continues to use `apiSecretKey` only. When a request validates against the fallback secret, a `Warning`-level log is emitted so you can tell when the old secret is no longer in use and the fallback can be removed. diff --git a/packages/apps/shopify-api/lib/base-types.ts b/packages/apps/shopify-api/lib/base-types.ts index 3f7b9ef43c..4b8b534cf4 100644 --- a/packages/apps/shopify-api/lib/base-types.ts +++ b/packages/apps/shopify-api/lib/base-types.ts @@ -26,6 +26,25 @@ export interface ConfigParams< * Also known as Client Secret in your Partner Dashboard. */ apiSecretKey: string; + /** + * An additional, secondary API secret key used only to validate **inbound** + * HMAC signatures (webhooks, Flow, and fulfillment-service requests). + * + * This exists to avoid dropping inbound requests during a client-secret + * rotation. Shopify signs outbound requests with the oldest non-revoked + * secret, so after you rotate `apiSecretKey` to the new secret there can be a + * window (observed up to ~30 minutes after revoking the old secret) where + * requests are still signed with the previous secret. Setting the previous + * secret here lets those requests continue to validate. + * + * When a request validates against this fallback (and not `apiSecretKey`), a + * `Warning`-level log is emitted so you can observe when Shopify has stopped + * using the old secret and it is safe to unset this value. + * + * Outbound signing (`generateLocalHmac`) always uses `apiSecretKey` and is + * unaffected by this option. + */ + apiSecretKeyFallback?: string; /** * The scopes your app needs to access the API. Not required if using Shopify managed installation. */ diff --git a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts index 7cccbeb11c..2fa3a2de2f 100644 --- a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts +++ b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts @@ -3,7 +3,14 @@ import crypto from 'crypto'; import {testConfig} from '../../__tests__/test-config'; import {AuthQuery} from '../../auth/oauth/types'; import * as ShopifyErrors from '../../error'; -import {HMACSignator, getCurrentTimeInSec} from '../hmac-validator'; +import { + HMACSignator, + getCurrentTimeInSec, + validateHmacFromRequestFactory, +} from '../hmac-validator'; +import {HmacValidationType} from '../types'; +import {ShopifyHeader, LogSeverity} from '../../types'; +import {type NormalizedRequest} from '../../../runtime'; import {shopifyApi} from '../..'; describe('validateHmac', () => { @@ -235,9 +242,146 @@ describe('validateHmac', () => { }); }); +describe('validateHmacFromRequest with apiSecretKeyFallback (secret rotation)', () => { + const primarySecret = 'new_secret_after_rotation'; + const oldSecret = 'old_secret_before_rotation'; + const rawBody = JSON.stringify({id: 12345, topic: 'orders/create'}); + + function buildWebhookRequest(signingSecret: string) { + const rawRequest: NormalizedRequest = { + method: 'POST', + url: 'https://my-app.example.com/webhooks', + headers: { + [ShopifyHeader.Hmac]: createBase64HmacSignature(rawBody, signingSecret), + [ShopifyHeader.Topic]: 'orders/create', + [ShopifyHeader.Domain]: 'test-shop.myshopify.com', + }, + }; + + return { + type: HmacValidationType.Webhook as const, + rawBody, + rawRequest, + }; + } + + test('accepts a request signed with the primary secret', async () => { + const shopify = shopifyApi(testConfig({apiSecretKey: primarySecret})); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(primarySecret), + ); + + expect(result.valid).toBe(true); + }); + + test('accepts a request signed with the fallback secret when configured', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + // Signed with the OLD secret — would be rejected without the fallback. + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(oldSecret), + ); + + expect(result.valid).toBe(true); + }); + + test('emits a Warning log when validation succeeds via the fallback secret', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(oldSecret), + ); + + expect(shopify.config.logger.log).toHaveBeenCalledWith( + LogSeverity.Warning, + expect.stringContaining('apiSecretKeyFallback'), + ); + }); + + test('does not emit the fallback Warning when the primary secret matches', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(primarySecret), + ); + + expect(result.valid).toBe(true); + expect(shopify.config.logger.log).not.toHaveBeenCalledWith( + LogSeverity.Warning, + expect.stringContaining('apiSecretKeyFallback'), + ); + }); + + test('rejects a request signed with neither the primary nor the fallback secret', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest('some_unrelated_secret'), + ); + + expect(result.valid).toBe(false); + }); + + test('rejects the fallback-signed request when no fallback is configured', async () => { + const shopify = shopifyApi(testConfig({apiSecretKey: primarySecret})); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(oldSecret), + ); + + expect(result.valid).toBe(false); + }); + + test('accepts a Flow request signed with the fallback secret when configured', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + // Flow and fulfillment-service share validateHmacString with webhooks, so + // the fallback must cover them too. Signed with the OLD secret. + const result = await validateHmacFromRequestFactory(shopify.config)({ + ...buildWebhookRequest(oldSecret), + type: HmacValidationType.Flow as const, + }); + + expect(result.valid).toBe(true); + }); +}); + function createHmacSignature(queryString: string, apiSecretKey: string) { return crypto .createHmac('sha256', apiSecretKey) .update(queryString) .digest('hex'); } + +function createBase64HmacSignature(data: string, apiSecretKey: string) { + return crypto + .createHmac('sha256', apiSecretKey) + .update(data, 'utf8') + .digest('base64'); +} diff --git a/packages/apps/shopify-api/lib/utils/hmac-validator.ts b/packages/apps/shopify-api/lib/utils/hmac-validator.ts index d973c0b375..fa348897d9 100644 --- a/packages/apps/shopify-api/lib/utils/hmac-validator.ts +++ b/packages/apps/shopify-api/lib/utils/hmac-validator.ts @@ -105,10 +105,35 @@ export async function validateHmacString( data: string, hmac: string, format: HashFormat, -) { +): Promise { const localHmac = await createSHA256HMAC(config.apiSecretKey, data, format); - return safeCompare(hmac, localHmac); + if (safeCompare(hmac, localHmac)) { + return true; + } + + // During a client-secret rotation Shopify can keep signing inbound requests + // with the previous secret for a short window. If a fallback secret is + // configured, accept requests signed with it too, and surface a warning so + // operators know the old secret is still in use. + if (config.apiSecretKeyFallback) { + const fallbackHmac = await createSHA256HMAC( + config.apiSecretKeyFallback, + data, + format, + ); + + if (safeCompare(hmac, fallbackHmac)) { + await logger(config).warning( + 'Inbound HMAC validated using apiSecretKeyFallback. Shopify is still ' + + 'signing requests with the previous secret; keep the fallback set ' + + 'until these warnings stop, then unset it.', + ); + return true; + } + } + + return false; } export function getCurrentTimeInSec() {