-
Notifications
You must be signed in to change notification settings - Fork 146
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
@W-17386338 - Secure SSR Endpoints by Verifying SLAS Callback Requests (
#2180) Validate the `x-slas-callback-token` header, which is a JWT provided by SLAS during passwordless login and reset password POST callbacks. Unauthorized requests are rejected with a 401 Unauthorized error.
- v3.10.0-nightly-20250310080204
- v3.10.0-nightly-20250307080214
- v3.10.0-nightly-20250306080205
- v3.10.0-nightly-20250305080208
- v3.10.0-nightly-20250304080210
- v3.10.0-nightly-20250303080215
- v3.10.0-nightly-20250228080210
- v3.10.0-nightly-20250227080208
- v3.10.0-nightly-20250226080211
- v3.10.0-nightly-20250225080210
- v3.10.0-nightly-20250224080219
- v3.10.0-nightly-20250221080213
- v3.10.0-nightly-20250220080209
- v3.10.0-nightly-20250219080205
- v3.9.0
- v3.9.0-nightly-20250218080208
- v3.9.0-nightly-20250217080218
- v3.9.0-nightly-20250214080213
- v3.9.0-nightly-20250213080206
- test-release-action
1 parent
1b9aeb5
commit b70a97c
Showing
7 changed files
with
264 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
/* | ||
* Copyright (c) 2021, salesforce.com, inc. | ||
* All rights reserved. | ||
* SPDX-License-Identifier: BSD-3-Clause | ||
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause | ||
*/ | ||
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' | ||
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' | ||
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' | ||
|
||
const CLAIM = { | ||
ISSUER: 'iss' | ||
} | ||
|
||
const DELIMITER = { | ||
ISSUER: '/' | ||
} | ||
|
||
const throwSlasTokenValidationError = (message, code) => { | ||
throw new Error(`SLAS Token Validation Error: ${message}`, code) | ||
} | ||
|
||
export const createRemoteJWKSet = (tenantId) => { | ||
const appOrigin = getAppOrigin() | ||
const {app: appConfig} = getConfig() | ||
const shortCode = appConfig.commerceAPI.parameters.shortCode | ||
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '') | ||
if (tenantId !== configTenantId) { | ||
throw new Error(`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`) | ||
} | ||
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks` | ||
return joseCreateRemoteJWKSet(new URL(JWKS_URI)) | ||
} | ||
|
||
export const validateSlasCallbackToken = async (token) => { | ||
const payload = decodeJwt(token) | ||
const subClaim = payload[CLAIM.ISSUER] | ||
const tokens = subClaim.split(DELIMITER.ISSUER) | ||
const tenantId = tokens[2] | ||
try { | ||
const jwks = createRemoteJWKSet(tenantId) | ||
const {payload} = await jwtVerify(token, jwks, {}) | ||
return payload | ||
} catch (error) { | ||
throwSlasTokenValidationError(error.message, 401) | ||
} | ||
} | ||
|
||
const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/ | ||
const shortCodeRegExp = /^[a-zA-Z0-9-]+$/ | ||
|
||
/** | ||
* Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks. | ||
* | ||
* @param {object} req Express request object. | ||
* @param {object} res Express response object. | ||
* @param {object} options Options for fetching B2C Commerce API JWKS. | ||
* @param {string} options.shortCode - The Short Code assigned to the realm. | ||
* @param {string} options.tenantId - The Tenant ID for the ECOM instance. | ||
* @returns {Promise<*>} Promise with the JWKS data. | ||
*/ | ||
export async function jwksCaching(req, res, options) { | ||
const {shortCode, tenantId} = options | ||
|
||
const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode) | ||
if (!isValidRequest) | ||
return res | ||
.status(400) | ||
.json({error: 'Bad request parameters: Tenant ID or short code is invalid.'}) | ||
try { | ||
const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks` | ||
const response = await fetch(JWKS_URI) | ||
|
||
if (!response.ok) { | ||
throw new Error('Request failed with status: ' + response.status) | ||
} | ||
|
||
// JWKS rotate every 30 days. For now, cache response for 14 days so that | ||
// fetches only need to happen twice a month | ||
res.set('Cache-Control', 'public, max-age=1209600') | ||
|
||
return res.json(await response.json()) | ||
} catch (error) { | ||
res.status(400).json({error: `Error while fetching data: ${error.message}`}) | ||
} | ||
} |
134 changes: 134 additions & 0 deletions
134
packages/template-retail-react-app/app/utils/jwt-utils.test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
/* | ||
* Copyright (c) 2025, Salesforce, Inc. | ||
* All rights reserved. | ||
* SPDX-License-Identifier: BSD-3-Clause | ||
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause | ||
*/ | ||
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' | ||
import { | ||
createRemoteJWKSet, | ||
validateSlasCallbackToken | ||
} from '@salesforce/retail-react-app/app/utils/jwt-utils' | ||
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' | ||
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' | ||
|
||
const MOCK_JWKS = { | ||
keys: [ | ||
{ | ||
kty: 'EC', | ||
crv: 'P-256', | ||
use: 'sig', | ||
kid: '8edb82b1-f6d5-49c1-bab2-c0d152ee3d0b', | ||
x: 'i8e53csluQiqwP6Af8KsKgnUceXUE8_goFcvLuSzG3I', | ||
y: 'yIH500tLKJtPhIl7MlMBOGvxQ_3U-VcrrXusr8bVr_0' | ||
}, | ||
{ | ||
kty: 'EC', | ||
crv: 'P-256', | ||
use: 'sig', | ||
kid: 'da9effc5-58cb-4a9c-9c9c-2919fb7d5e5e', | ||
x: '_tAU1QSvcEkslcrbNBwx5V20-sN87z0zR7gcSdBETDQ', | ||
y: 'ZJ7bgy7WrwJUGUtzcqm3MNyIfawI8F7fVawu5UwsN8E' | ||
}, | ||
{ | ||
kty: 'EC', | ||
crv: 'P-256', | ||
use: 'sig', | ||
kid: '5ccbbc6e-b234-4508-90f3-3b9b17efec16', | ||
x: '9ULO2Atj5XToeWWAT6e6OhSHQftta4A3-djgOzcg4-Q', | ||
y: 'JNuQSLMhakhLWN-c6Qi99tA5w-D7IFKf_apxVbVsK-g' | ||
} | ||
] | ||
} | ||
|
||
jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => ({ | ||
getAppOrigin: jest.fn() | ||
})) | ||
|
||
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ | ||
getConfig: jest.fn() | ||
})) | ||
|
||
jest.mock('jose', () => ({ | ||
createRemoteJWKSet: jest.fn(), | ||
jwtVerify: jest.fn(), | ||
decodeJwt: jest.fn() | ||
})) | ||
|
||
describe('createRemoteJWKSet', () => { | ||
afterEach(() => { | ||
jest.restoreAllMocks() | ||
}) | ||
|
||
it('constructs the correct JWKS URI and call joseCreateRemoteJWKSet', () => { | ||
const mockTenantId = 'aaaa_001' | ||
const mockAppOrigin = 'https://test-storefront.com' | ||
getAppOrigin.mockReturnValue(mockAppOrigin) | ||
getConfig.mockReturnValue({ | ||
app: { | ||
commerceAPI: { | ||
parameters: { | ||
shortCode: 'abc123', | ||
organizationId: 'f_ecom_aaaa_001' | ||
} | ||
} | ||
} | ||
}) | ||
joseCreateRemoteJWKSet.mockReturnValue('mockJWKSet') | ||
|
||
const expectedJWKS_URI = new URL(`${mockAppOrigin}/abc123/aaaa_001/oauth2/jwks`) | ||
|
||
const res = createRemoteJWKSet(mockTenantId) | ||
|
||
expect(getAppOrigin).toHaveBeenCalled() | ||
expect(getConfig).toHaveBeenCalled() | ||
expect(joseCreateRemoteJWKSet).toHaveBeenCalledWith(expectedJWKS_URI) | ||
expect(res).toBe('mockJWKSet') | ||
}) | ||
}) | ||
|
||
describe('validateSlasCallbackToken', () => { | ||
beforeEach(() => { | ||
jest.resetAllMocks() | ||
const mockAppOrigin = 'https://test-storefront.com' | ||
getAppOrigin.mockReturnValue(mockAppOrigin) | ||
getConfig.mockReturnValue({ | ||
app: { | ||
commerceAPI: { | ||
parameters: { | ||
shortCode: 'abc123', | ||
organizationId: 'f_ecom_aaaa_001' | ||
} | ||
} | ||
} | ||
}) | ||
joseCreateRemoteJWKSet.mockReturnValue(MOCK_JWKS) | ||
}) | ||
|
||
it('returns payload when callback token is valid', async () => { | ||
decodeJwt.mockReturnValue({iss: 'slas/dev/aaaa_001'}) | ||
const mockPayload = {sub: '123', role: 'admin'} | ||
jwtVerify.mockResolvedValue({payload: mockPayload}) | ||
|
||
const res = await validateSlasCallbackToken('mock.slas.token') | ||
|
||
expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {}) | ||
expect(res).toEqual(mockPayload) | ||
}) | ||
|
||
it('throws validation error when the token is invalid', async () => { | ||
decodeJwt.mockReturnValue({iss: 'slas/dev/aaaa_001'}) | ||
const mockError = new Error('Invalid token') | ||
jwtVerify.mockRejectedValue(mockError) | ||
|
||
await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow( | ||
mockError.message | ||
) | ||
expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {}) | ||
}) | ||
|
||
it('throws mismatch error when the config tenantId does not match the jwt tenantId', async () => { | ||
decodeJwt.mockReturnValue({iss: 'slas/dev/zzrf_001'}) | ||
await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters