diff --git a/README.md b/README.md index dd704c3..415032f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ npm install commerce-sdk-isomorphic ### Configure the Isomorphic SDK + ```javascript /** * Configure required parameters @@ -59,6 +60,13 @@ const {access_token, refresh_token} = await helpers.loginGuestUser( {redirectURI: `${config.proxy}/callback`} // Callback URL must be configured in SLAS Admin ); +// Execute Private Client OAuth with PKCE to acquire guest tokens +// ***WARNING*** Be cautious about using this function in the browser as you may end up exposing your client secret, only use it when you know your slas client secret is secured +// const {access_token, refresh_token} = await helpers.loginGuestUserPrivate( +// shopperLogin, +// {}, {clientSecret: 'slas-client-secret'} +// ); + const shopperSearch = new ShopperSearch({ ...config, headers: {authorization: `Bearer ${access_token}`}, @@ -112,12 +120,15 @@ _headers:_ A collection of key/value string pairs representing additional header _throwOnBadResponse:_ Default value is false. When set to true, the SDK throws an Error on responses with statuses that are not 2xx or 304. -### Public Client Shopper Login helpers +### Client Shopper Login helpers A collection of helper functions are available in this SDK to simplify [Public Client Shopper Login OAuth flows](https://developer.salesforce.com/docs/commerce/commerce-api/references?meta=shopper-login:Summary). See sample code above for guest login. +**Note** +If you use the SLAS private client helper functions in the browser, making sure that your slas client secret are secured since funcs can run in client-side. + ## License Information The Commerce SDK Isomorphic is licensed under BSD-3-Clause license. See the [license](./LICENSE.txt) for details. diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index c630e34..4027166 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -22,6 +22,12 @@ const credentials = { password: 'shopper_password', }; +const credentialsPrivate = { + username: 'shopper_user_id', + password: 'shopper_password', + clientSecret: 'slas_private_secret', +}; + const expectedTokenResponse: TokenResponse = { access_token: 'access_token', id_token: 'id_token', @@ -204,18 +210,18 @@ test('throws error on 400 response', async () => { }); describe('Guest user flow', () => { - const expectedTokenBody = { - body: { - client_id: 'client_id', - channel_id: 'site_id', - code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', - code_verifier: expect.stringMatching(/./) as string, - grant_type: 'authorization_code_pkce', - redirect_uri: 'redirect_uri', - usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', - }, - }; test('retrieves usid and code from location header and generates an access token', async () => { + const expectedTokenBody = { + body: { + client_id: 'client_id', + channel_id: 'site_id', + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + code_verifier: expect.stringMatching(/./) as string, + grant_type: 'authorization_code_pkce', + redirect_uri: 'redirect_uri', + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + }, + }; const mockSlasClient = createMockSlasClient(); const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; @@ -231,6 +237,30 @@ describe('Guest user flow', () => { expect(getAccessTokenMock).toBeCalledWith(expectedTokenBody); expect(accessToken).toBe(expectedTokenResponse); }); + + test('generates an access token using slas private client', async () => { + const mockSlasClient = createMockSlasClient(); + + const accessToken = await slasHelper.loginGuestUserPrivate( + mockSlasClient, + parameters, + credentialsPrivate + ); + + const expectedReqOptions = { + headers: { + Authorization: `Basic ${stringToBase64( + `client_id:${credentialsPrivate.clientSecret}` + )}`, + }, + body: { + grant_type: 'client_credentials', + usid: 'usid', + }, + }; + expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); + expect(accessToken).toBe(expectedTokenResponse); + }); }); describe('Registered B2C user flow', () => { const expectedTokenBody = { @@ -246,7 +276,7 @@ describe('Registered B2C user flow', () => { }, }; - test('uses code challenge and authorization header to generate auth code', async () => { + test('uses code challenge and authorization header to generate auth code with slas public client', async () => { // slasClient is copied and tries to make an actual API call const mockSlasClient = createMockSlasClient(); const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; @@ -265,6 +295,42 @@ describe('Registered B2C user flow', () => { expect(getAccessTokenMock).toBeCalledWith(expectedTokenBody); }); + test('uses code challenge and authorization header to generate auth code with slas private client', async () => { + const expectedReqOptions = { + headers: { + Authorization: `Basic ${stringToBase64( + `client_id:${credentialsPrivate.clientSecret}` + )}`, + }, + body: { + client_id: 'client_id', + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + code_verifier: expect.stringMatching(/./) as string, + grant_type: 'authorization_code_pkce', + redirect_uri: 'redirect_uri', + channel_id: 'site_id', + organizationId: 'organization_id', + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + }, + }; + // slasClient is copied and tries to make an actual API call + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mocking slasCopy.authenticateCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .post(`/shopper/auth/v1/organizations/${organizationId}/oauth2/login`) + .reply(303, {response_body: 'response_body'}, {location: url}); + + await slasHelper.loginRegisteredUserB2C( + mockSlasClient, + credentialsPrivate, + parameters + ); + + expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); + }); + test('loginRegisteredUserB2C stops when authenticateCustomer returns 400', async () => { // slasClient is copied and tries to make an actual API call const mockSlasClient = createMockSlasClient(); @@ -321,7 +387,7 @@ describe('Registered B2C user flow', () => { ).resolves.not.toThrow(ResponseError); }); - test('uses auth code and code verifier to generate JWT', async () => { + test('uses auth code and code verifier to generate JWT with public client', async () => { // slasClient is copied and tries to make an actual API call const mockSlasClient = createMockSlasClient(); const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; @@ -340,16 +406,15 @@ describe('Registered B2C user flow', () => { }); describe('Refresh Token', () => { - const expectedBody = { - body: { - client_id: 'client_id', - channel_id: 'site_id', - grant_type: 'refresh_token', - refresh_token: 'refresh_token', - }, - }; - - test('refreshes the token', () => { + test('refreshes the token with slas public client', () => { + const expectedBody = { + body: { + client_id: 'client_id', + channel_id: 'site_id', + grant_type: 'refresh_token', + refresh_token: 'refresh_token', + }, + }; const token = slasHelper.refreshAccessToken( createMockSlasClient(), parameters @@ -357,6 +422,31 @@ describe('Refresh Token', () => { expect(getAccessTokenMock).toBeCalledWith(expectedBody); expect(token).toStrictEqual(expectedTokenResponse); }); + + test('refreshes the token with slas private client', () => { + const expectedReqOpts = { + headers: { + Authorization: `Basic ${stringToBase64( + `client_id:${credentialsPrivate.clientSecret}` + )}`, + }, + body: { + grant_type: 'refresh_token', + client_id: 'client_id', + channel_id: 'site_id', + refresh_token: parameters.refreshToken, + }, + }; + const token = slasHelper.refreshAccessToken( + createMockSlasClient(), + parameters, + { + clientSecret: credentialsPrivate.clientSecret, + } + ); + expect(getAccessTokenMock).toBeCalledWith(expectedReqOpts); + expect(token).toStrictEqual(expectedTokenResponse); + }); }); describe('Logout', () => { diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 6ca7d19..3b78924 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -147,6 +147,47 @@ export async function authorize( return {url: redirectUrlString, ...getCodeAndUsidFromUrl(redirectUrlString)}; } +/** + * A single function to execute the ShopperLogin Private Client Guest Login as described in the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-private-client.html). + * **Note**: this func can run on client side. Only use this one when the slas client secret is secured. + * @param slasClient - a configured instance of the ShopperLogin SDK client + * @param credentials - client secret used for authentication + * @param credentials.clientSecret - secret associated with client ID + * @param parameters - parameters to pass in the API calls. + * @param parameters.usid? - Unique Shopper Identifier to enable personalization. + * @returns TokenResponse + */ +export async function loginGuestUserPrivate( + slasClient: ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>, + parameters: { + usid?: string; + }, + credentials: { + clientSecret: string; + } +): Promise { + const authorization = `Basic ${stringToBase64( + `${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}` + )}`; + + const options = { + headers: { + Authorization: authorization, + }, + body: { + grant_type: 'client_credentials', + ...(parameters.usid && {usid: parameters.usid}), + }, + }; + + return slasClient.getAccessToken(options); +} + /** * A single function to execute the ShopperLogin Public Client Guest Login with proof key for code exchange flow as described in the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/references?meta=shopper-login:Summary). * @param slasClient a configured instance of the ShopperLogin SDK client. @@ -190,10 +231,12 @@ export async function loginGuestUser( /** * A single function to execute the ShopperLogin Public Client Registered User B2C Login with proof key for code exchange flow as described in the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/references?meta=shopper-login:Summary). + * **Note**: this func can run on client side. Only use private slas when the slas client secret is secured. * @param slasClient a configured instance of the ShopperLogin SDK client. - * @param credentials - the id and password to login with. + * @param credentials - the id and password and clientSecret (if applicable) to login with. * @param credentials.username - the id of the user to login with. * @param credentials.password - the password of the user to login with. + * @param credentials.clientSecret? - secret associated with client ID * @param parameters - parameters to pass in the API calls. * @param parameters.redirectURI - Per OAuth standard, a valid app route. Must be listed in your SLAS configuration. On server, this will not be actually called. On browser, this will be called, but ignored. * @param parameters.usid? - Unique Shopper Identifier to enable personalization. @@ -209,6 +252,7 @@ export async function loginRegisteredUserB2C( credentials: { username: string; password: string; + clientSecret?: string; }, parameters: { redirectURI: string; @@ -260,7 +304,6 @@ export async function loginRegisteredUserB2C( } const authResponse = getCodeAndUsidFromUrl(redirectUrlString); - const tokenBody = { client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, @@ -271,15 +314,32 @@ export async function loginRegisteredUserB2C( redirect_uri: parameters.redirectURI, usid: authResponse.usid, }; - + // using slas private client + if (credentials.clientSecret) { + const authHeaderIdSecret = `Basic ${stringToBase64( + `${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}` + )}`; + + const optionsToken = { + headers: { + Authorization: authHeaderIdSecret, + }, + body: tokenBody, + }; + return slasClient.getAccessToken(optionsToken); + } + // default is to use slas public client return slasClient.getAccessToken({body: tokenBody}); } /** * Exchange a refresh token for a new access token. + * **Note**: this func can run on client side. Only use private slas when the slas client secret is secured. * @param slasClient a configured instance of the ShopperLogin SDK client. * @param parameters - parameters to pass in the API calls. * @param parameters.refreshToken - a valid refresh token to exchange for a new access token (and refresh token). + * @param credentials - the clientSecret (if applicable) to login with. + * @param credentials.clientSecret - secret associated with client ID * @returns TokenResponse */ export function refreshAccessToken( @@ -289,7 +349,8 @@ export function refreshAccessToken( clientId: string; siteId: string; }>, - parameters: {refreshToken: string} + parameters: {refreshToken: string}, + credentials?: {clientSecret?: string} ): Promise { const body = { grant_type: 'refresh_token', @@ -298,6 +359,19 @@ export function refreshAccessToken( channel_id: slasClient.clientConfig.parameters.siteId, }; + if (credentials && credentials.clientSecret) { + const authorization = `Basic ${stringToBase64( + `${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}` + )}`; + const options = { + headers: { + Authorization: authorization, + }, + body, + }; + return slasClient.getAccessToken(options); + } + return slasClient.getAccessToken({body}); }