From be716eb21a7369d893a9b5fdc782b6f730071e48 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 10 Sep 2024 16:14:00 -0400 Subject: [PATCH 01/27] add social login helper --- src/static/helpers/slasHelper.test.ts | 4 ++ src/static/helpers/slasHelper.ts | 66 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index e84251b2..5cad49e8 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -435,6 +435,10 @@ describe('Registered B2C user flow', () => { }); }); +describe('Social login user flow', () => { + +}) + describe('Refresh Token', () => { test('refreshes the token with slas public client', () => { const expectedBody = { diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index fe12b1a2..dedb4d40 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -359,6 +359,72 @@ export async function loginRegisteredUserB2C( return slasClient.getAccessToken({body: tokenBody}); } +/** + * A single function to execute the ShopperLogin External IDP 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 clientSecret (if applicable) 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.hint - Name of an identity provider (IDP) to redirect to + * @param parameters.usid? - Unique Shopper Identifier to enable personalization. + * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. + * @returns TokenResponse + */ +export async function loginIDPUser( + slasClient: ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>, + credentials: { + clientSecret?: string; + }, + parameters: { + redirectURI: string; + hint: string; + usid?: string; + dnt?: boolean; + } +): Promise { + const codeVerifier = createCodeVerifier(); + + const authResponse = await authorize(slasClient, codeVerifier, { + redirectURI: parameters.redirectURI, + hint: parameters.hint, + ...(parameters.usid && {usid: parameters.usid}), + }); + + const tokenBody: TokenRequest = { + client_id: slasClient.clientConfig.parameters.clientId, + channel_id: slasClient.clientConfig.parameters.siteId, + code: authResponse.code, + code_verifier: codeVerifier, + grant_type: 'authorization_code_pkce', + redirect_uri: parameters.redirectURI, + usid: authResponse.usid, + ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), + }; + + // 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); + } + 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. From bbfc077e165733409b33ee39725fcbff3a10eba7 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 11 Sep 2024 14:55:55 -0400 Subject: [PATCH 02/27] idplogin for public client --- src/static/helpers/slasHelper.test.ts | 18 ++++++++++++++++-- src/static/helpers/slasHelper.ts | 19 +------------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index 5cad49e8..7e9128b4 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -436,8 +436,22 @@ describe('Registered B2C user flow', () => { }); describe('Social login user flow', () => { - -}) + test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { + // slasClient is copied and tries to make an actual API call + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {message: 'Oh yes!'}); + + await expect( + slasHelper.loginIDPUser(mockSlasClient, parameters) + ).resolves.not.toThrow(ResponseError); + }); +}); describe('Refresh Token', () => { test('refreshes the token with slas public client', () => { diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index dedb4d40..a5fdeb91 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -379,9 +379,6 @@ export async function loginIDPUser( clientId: string; siteId: string; }>, - credentials: { - clientSecret?: string; - }, parameters: { redirectURI: string; hint: string; @@ -408,21 +405,7 @@ export async function loginIDPUser( ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), }; - // 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); - } - return slasClient.getAccessToken({body: tokenBody}) + return slasClient.getAccessToken({body: tokenBody}); } /** From 9189eb0e22723026366963dfcb8f05baa0abe34c Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 12 Sep 2024 15:48:57 -0400 Subject: [PATCH 03/27] add private client, mark code_challenge as not required in raml --- apis/shopper-login/shopper-login.raml | 2 +- src/static/helpers/slasHelper.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apis/shopper-login/shopper-login.raml b/apis/shopper-login/shopper-login.raml index 3b22d364..0b3f7b3a 100644 --- a/apis/shopper-login/shopper-login.raml +++ b/apis/shopper-login/shopper-login.raml @@ -1002,7 +1002,7 @@ types: The `code_challenge` is created by SHA256 hashing the `code_verifier` and Base64 encoding the resulting hash. The `code_verifier` should be a high entropy cryptographically random string with a minimum of 43 characters and a maximum of 128 characters. - required: true + required: false type: string minLength: 43 maxLength: 128 diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index a5fdeb91..13e600c4 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -379,6 +379,9 @@ export async function loginIDPUser( clientId: string; siteId: string; }>, + credentials: { + clientSecret?: string; + }, parameters: { redirectURI: string; hint: string; @@ -405,6 +408,21 @@ export async function loginIDPUser( ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), }; + // Using 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); + } + return slasClient.getAccessToken({body: tokenBody}); } From 31dd0a6082abf003d427ac1affc2107565308444 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 12 Sep 2024 16:12:48 -0400 Subject: [PATCH 04/27] separate idplogin helper into separate file --- src/static/helpers/IDPLoginHelper.test.ts | 87 +++++++++++++++++++++++ src/static/helpers/IDPLoginHelper.ts | 81 +++++++++++++++++++++ src/static/helpers/slasHelper.test.ts | 18 ----- src/static/helpers/slasHelper.ts | 67 ----------------- 4 files changed, 168 insertions(+), 85 deletions(-) create mode 100644 src/static/helpers/IDPLoginHelper.test.ts create mode 100644 src/static/helpers/IDPLoginHelper.ts diff --git a/src/static/helpers/IDPLoginHelper.test.ts b/src/static/helpers/IDPLoginHelper.test.ts new file mode 100644 index 00000000..8b1e6e7b --- /dev/null +++ b/src/static/helpers/IDPLoginHelper.test.ts @@ -0,0 +1,87 @@ +/** + * @jest-environment node + */ +/* eslint header/header: "off", max-lines:"off" */ +/* + * Copyright (c) 2022, 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 nock from 'nock'; +import {ShopperLogin, TokenResponse} from '../../lib/shopperLogin'; +import loginIDPUser from './IDPLoginHelper'; +import ResponseError from '../responseError'; + +const credentialsPublic = {}; + +const expectedTokenResponse: TokenResponse = { + access_token: 'access_token', + id_token: 'id_token', + refresh_token: 'refresh_token', + expires_in: 0, + refresh_token_expires_in: 0, + token_type: 'token_type', + usid: 'usid', + customer_id: 'customer_id', + enc_user_id: 'enc_user_id', + idp_access_token: 'idp', +}; + +const parameters = { + accessToken: 'access_token', + redirectURI: 'redirect_uri', + refreshToken: 'refresh_token', + usid: 'usid', + hint: 'hint', + dnt: false, +}; + +const url = + 'https://localhost:3000/callback?usid=048adcfb-aa93-4978-be9e-09cb569fdcb9&code=J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o'; + +const authenticateCustomerMock = jest.fn(() => ({url})); + +const getAccessTokenMock = jest.fn(() => expectedTokenResponse); +const logoutCustomerMock = jest.fn(() => expectedTokenResponse); +const generateCodeChallengeMock = jest.fn(() => 'code_challenge'); + +const createMockSlasClient = () => + ({ + clientConfig: { + parameters: { + shortCode: 'short_code', + organizationId: 'organization_id', + clientId: 'client_id', + siteId: 'site_id', + }, + }, + authenticateCustomer: authenticateCustomerMock, + getAccessToken: getAccessTokenMock, + logoutCustomer: logoutCustomerMock, + generateCodeChallenge: generateCodeChallengeMock, + } as unknown as ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>); + +describe('Social login user flow', () => { + test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { + // slasClient is copied and tries to make an actual API call + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {message: 'Oh yes!'}); + + await expect( + loginIDPUser(mockSlasClient, credentialsPublic, parameters) + ).resolves.not.toThrow(ResponseError); + }); +}); diff --git a/src/static/helpers/IDPLoginHelper.ts b/src/static/helpers/IDPLoginHelper.ts new file mode 100644 index 00000000..7d88b88e --- /dev/null +++ b/src/static/helpers/IDPLoginHelper.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022, 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 {stringToBase64, createCodeVerifier, authorize} from './slasHelper'; +import { + ShopperLogin, + TokenRequest, + TokenResponse, +} from '../../lib/shopperLogin'; + +/** + * A single function to execute the ShopperLogin External IDP 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 clientSecret (if applicable) 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.hint - Name of an identity provider (IDP) to redirect to + * @param parameters.usid? - Unique Shopper Identifier to enable personalization. + * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. + * @returns TokenResponse + */ +async function loginIDPUser( + slasClient: ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>, + credentials: { + clientSecret?: string; + }, + parameters: { + redirectURI: string; + hint: string; + usid?: string; + dnt?: boolean; + } +): Promise { + const codeVerifier = createCodeVerifier(); + + const authResponse = await authorize(slasClient, codeVerifier, { + redirectURI: parameters.redirectURI, + hint: parameters.hint, + ...(parameters.usid && {usid: parameters.usid}), + }); + + const tokenBody: TokenRequest = { + client_id: slasClient.clientConfig.parameters.clientId, + channel_id: slasClient.clientConfig.parameters.siteId, + code: authResponse.code, + code_verifier: codeVerifier, + grant_type: 'authorization_code_pkce', + redirect_uri: parameters.redirectURI, + usid: authResponse.usid, + ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), + }; + + // Using 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); + } + + return slasClient.getAccessToken({body: tokenBody}); +} + +export default loginIDPUser; diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index 7e9128b4..e84251b2 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -435,24 +435,6 @@ describe('Registered B2C user flow', () => { }); }); -describe('Social login user flow', () => { - test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { - // slasClient is copied and tries to make an actual API call - const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - - // Mock authorizeCustomer - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {message: 'Oh yes!'}); - - await expect( - slasHelper.loginIDPUser(mockSlasClient, parameters) - ).resolves.not.toThrow(ResponseError); - }); -}); - describe('Refresh Token', () => { test('refreshes the token with slas public client', () => { const expectedBody = { diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 13e600c4..fe12b1a2 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -359,73 +359,6 @@ export async function loginRegisteredUserB2C( return slasClient.getAccessToken({body: tokenBody}); } -/** - * A single function to execute the ShopperLogin External IDP 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 clientSecret (if applicable) 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.hint - Name of an identity provider (IDP) to redirect to - * @param parameters.usid? - Unique Shopper Identifier to enable personalization. - * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. - * @returns TokenResponse - */ -export async function loginIDPUser( - slasClient: ShopperLogin<{ - shortCode: string; - organizationId: string; - clientId: string; - siteId: string; - }>, - credentials: { - clientSecret?: string; - }, - parameters: { - redirectURI: string; - hint: string; - usid?: string; - dnt?: boolean; - } -): Promise { - const codeVerifier = createCodeVerifier(); - - const authResponse = await authorize(slasClient, codeVerifier, { - redirectURI: parameters.redirectURI, - hint: parameters.hint, - ...(parameters.usid && {usid: parameters.usid}), - }); - - const tokenBody: TokenRequest = { - client_id: slasClient.clientConfig.parameters.clientId, - channel_id: slasClient.clientConfig.parameters.siteId, - code: authResponse.code, - code_verifier: codeVerifier, - grant_type: 'authorization_code_pkce', - redirect_uri: parameters.redirectURI, - usid: authResponse.usid, - ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), - }; - - // Using 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); - } - - 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. From 6eb91757f6a88085fdd06053ad6cfda60cffe6fd Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 16 Sep 2024 12:13:03 -0400 Subject: [PATCH 05/27] omit code_challenge if using privateclient --- src/static/helpers/slasHelper.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index fe12b1a2..400ad62e 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -114,7 +114,8 @@ export async function authorize( redirectURI: string; hint?: string; usid?: string; - } + }, + privateClient = false ): Promise<{code: string; url: string; usid: string}> { const codeChallenge = await generateCodeChallenge(codeVerifier); @@ -134,7 +135,7 @@ export async function authorize( parameters: { client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, - code_challenge: codeChallenge, + ...(!privateClient && {code_challenge: codeChallenge}), ...(parameters.hint && {hint: parameters.hint}), organizationId: slasClient.clientConfig.parameters.organizationId, redirect_uri: parameters.redirectURI, From 9b73db49b6da4cf1121df468254d21920ae98d23 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 16 Sep 2024 12:29:49 -0400 Subject: [PATCH 06/27] if client secret is provided, do not pass in code challenge --- src/static/helpers/IDPLoginHelper.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/static/helpers/IDPLoginHelper.ts b/src/static/helpers/IDPLoginHelper.ts index 7d88b88e..98590990 100644 --- a/src/static/helpers/IDPLoginHelper.ts +++ b/src/static/helpers/IDPLoginHelper.ts @@ -43,11 +43,18 @@ async function loginIDPUser( ): Promise { const codeVerifier = createCodeVerifier(); - const authResponse = await authorize(slasClient, codeVerifier, { - redirectURI: parameters.redirectURI, - hint: parameters.hint, - ...(parameters.usid && {usid: parameters.usid}), - }); + const privateClient = !!credentials.clientSecret; + + const authResponse = await authorize( + slasClient, + codeVerifier, + { + redirectURI: parameters.redirectURI, + hint: parameters.hint, + ...(parameters.usid && {usid: parameters.usid}), + }, + privateClient + ); const tokenBody: TokenRequest = { client_id: slasClient.clientConfig.parameters.clientId, From c51075882a83fea0aabcade9162b05c584f865d0 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 16 Sep 2024 16:14:46 -0400 Subject: [PATCH 07/27] add test for private client --- src/static/helpers/IDPLoginHelper.test.ts | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/static/helpers/IDPLoginHelper.test.ts b/src/static/helpers/IDPLoginHelper.test.ts index 8b1e6e7b..622e699d 100644 --- a/src/static/helpers/IDPLoginHelper.test.ts +++ b/src/static/helpers/IDPLoginHelper.test.ts @@ -12,10 +12,17 @@ import nock from 'nock'; import {ShopperLogin, TokenResponse} from '../../lib/shopperLogin'; import loginIDPUser from './IDPLoginHelper'; +import {stringToBase64} from './slasHelper'; import ResponseError from '../responseError'; const credentialsPublic = {}; +const credentialsPrivate = { + username: 'shopper_user_id', + password: 'shopper_password', + clientSecret: 'slas_private_secret', +}; + const expectedTokenResponse: TokenResponse = { access_token: 'access_token', id_token: 'id_token', @@ -68,6 +75,11 @@ const createMockSlasClient = () => siteId: string; }>); +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); +}); + describe('Social login user flow', () => { test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { // slasClient is copied and tries to make an actual API call @@ -84,4 +96,41 @@ describe('Social login user flow', () => { loginIDPUser(mockSlasClient, credentialsPublic, parameters) ).resolves.not.toThrow(ResponseError); }); + + test('generates an access token using slas private client', async () => { + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {message: 'Oh yes!'}); + + const accessToken = await loginIDPUser( + mockSlasClient, + credentialsPrivate, + parameters + ); + + const expectedReqOptions = { + headers: { + Authorization: `Basic ${stringToBase64( + `client_id:${credentialsPrivate.clientSecret}` + )}`, + }, + body: { + grant_type: 'authorization_code_pkce', + redirect_uri: 'redirect_uri', + client_id: 'client_id', + channel_id: 'site_id', + usid: 'usid', + code_verifier: expect.stringMatching(/./) as string, + code: expect.any(String) as string, + dnt: 'false', + }, + }; + expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); + expect(accessToken).toBe(expectedTokenResponse); + }) }); From 863f59bf1fdd2162b63da23d9e2db32592fcc85d Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 17 Sep 2024 11:37:30 -0400 Subject: [PATCH 08/27] use type authorization_code for private client --- src/static/helpers/IDPLoginHelper.test.ts | 2 +- src/static/helpers/IDPLoginHelper.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/static/helpers/IDPLoginHelper.test.ts b/src/static/helpers/IDPLoginHelper.test.ts index 622e699d..c771803e 100644 --- a/src/static/helpers/IDPLoginHelper.test.ts +++ b/src/static/helpers/IDPLoginHelper.test.ts @@ -120,7 +120,7 @@ describe('Social login user flow', () => { )}`, }, body: { - grant_type: 'authorization_code_pkce', + grant_type: 'authorization_code', redirect_uri: 'redirect_uri', client_id: 'client_id', channel_id: 'site_id', diff --git a/src/static/helpers/IDPLoginHelper.ts b/src/static/helpers/IDPLoginHelper.ts index 98590990..4479de89 100644 --- a/src/static/helpers/IDPLoginHelper.ts +++ b/src/static/helpers/IDPLoginHelper.ts @@ -61,7 +61,9 @@ async function loginIDPUser( channel_id: slasClient.clientConfig.parameters.siteId, code: authResponse.code, code_verifier: codeVerifier, - grant_type: 'authorization_code_pkce', + grant_type: privateClient + ? 'authorization_code' + : 'authorization_code_pkce', redirect_uri: parameters.redirectURI, usid: authResponse.usid, ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), From b985f9dbfc5ba6a744196a84083de6e3135d0295 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 19 Sep 2024 09:59:29 -0400 Subject: [PATCH 09/27] add export --- src/static/helpers/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/static/helpers/index.ts b/src/static/helpers/index.ts index f72986bb..5b65e933 100644 --- a/src/static/helpers/index.ts +++ b/src/static/helpers/index.ts @@ -8,6 +8,7 @@ // Doing so may lead to circular dependencies or duplicate exports (due to rollup mangling the types) export * from './environment'; export * from './slasHelper'; +export * from './IDPLoginHelper'; export * from './types'; export * from './customApi'; export * from './fetchHelper'; From fc6305413291b0be19903fffd41f3a981de63a09 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 19 Sep 2024 10:43:36 -0400 Subject: [PATCH 10/27] verify that code is being added to getaccesstoken call --- src/static/helpers/IDPLoginHelper.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/static/helpers/IDPLoginHelper.test.ts b/src/static/helpers/IDPLoginHelper.test.ts index c771803e..f4ccd171 100644 --- a/src/static/helpers/IDPLoginHelper.test.ts +++ b/src/static/helpers/IDPLoginHelper.test.ts @@ -105,7 +105,7 @@ describe('Social login user flow', () => { nock(`https://${shortCode}.api.commercecloud.salesforce.com`) .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) .query(true) - .reply(303, {message: 'Oh yes!'}); + .reply(303, {response_body: 'response_body'}, {location: url}); const accessToken = await loginIDPUser( mockSlasClient, @@ -124,13 +124,13 @@ describe('Social login user flow', () => { redirect_uri: 'redirect_uri', client_id: 'client_id', channel_id: 'site_id', - usid: 'usid', + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', code_verifier: expect.stringMatching(/./) as string, - code: expect.any(String) as string, + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', dnt: 'false', }, }; expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); expect(accessToken).toBe(expectedTokenResponse); - }) + }); }); From 83224737beb42e50aba4b6e6e5157e41757d8908 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 24 Sep 2024 20:12:41 -0400 Subject: [PATCH 11/27] move helpers toslashelper file --- .eslintrc.json | 6 + src/static/helpers/IDPLoginHelper.test.ts | 136 ---------------------- src/static/helpers/IDPLoginHelper.ts | 90 -------------- src/static/helpers/index.ts | 1 - src/static/helpers/slasHelper.test.ts | 55 +++++++++ src/static/helpers/slasHelper.ts | 76 ++++++++++++ 6 files changed, 137 insertions(+), 227 deletions(-) delete mode 100644 src/static/helpers/IDPLoginHelper.test.ts delete mode 100644 src/static/helpers/IDPLoginHelper.ts diff --git a/.eslintrc.json b/.eslintrc.json index 1ebee1b6..ef6c9f4c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -91,6 +91,12 @@ "rules": { "@typescript-eslint/explicit-module-boundary-types": "error" } + }, + { + "files": ["./src/static/helpers/slasHelper.ts"], + "rules": { + "max-lines": "off" + } } ], "settings": { diff --git a/src/static/helpers/IDPLoginHelper.test.ts b/src/static/helpers/IDPLoginHelper.test.ts deleted file mode 100644 index f4ccd171..00000000 --- a/src/static/helpers/IDPLoginHelper.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @jest-environment node - */ -/* eslint header/header: "off", max-lines:"off" */ -/* - * Copyright (c) 2022, 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 nock from 'nock'; -import {ShopperLogin, TokenResponse} from '../../lib/shopperLogin'; -import loginIDPUser from './IDPLoginHelper'; -import {stringToBase64} from './slasHelper'; -import ResponseError from '../responseError'; - -const credentialsPublic = {}; - -const credentialsPrivate = { - username: 'shopper_user_id', - password: 'shopper_password', - clientSecret: 'slas_private_secret', -}; - -const expectedTokenResponse: TokenResponse = { - access_token: 'access_token', - id_token: 'id_token', - refresh_token: 'refresh_token', - expires_in: 0, - refresh_token_expires_in: 0, - token_type: 'token_type', - usid: 'usid', - customer_id: 'customer_id', - enc_user_id: 'enc_user_id', - idp_access_token: 'idp', -}; - -const parameters = { - accessToken: 'access_token', - redirectURI: 'redirect_uri', - refreshToken: 'refresh_token', - usid: 'usid', - hint: 'hint', - dnt: false, -}; - -const url = - 'https://localhost:3000/callback?usid=048adcfb-aa93-4978-be9e-09cb569fdcb9&code=J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o'; - -const authenticateCustomerMock = jest.fn(() => ({url})); - -const getAccessTokenMock = jest.fn(() => expectedTokenResponse); -const logoutCustomerMock = jest.fn(() => expectedTokenResponse); -const generateCodeChallengeMock = jest.fn(() => 'code_challenge'); - -const createMockSlasClient = () => - ({ - clientConfig: { - parameters: { - shortCode: 'short_code', - organizationId: 'organization_id', - clientId: 'client_id', - siteId: 'site_id', - }, - }, - authenticateCustomer: authenticateCustomerMock, - getAccessToken: getAccessTokenMock, - logoutCustomer: logoutCustomerMock, - generateCodeChallenge: generateCodeChallengeMock, - } as unknown as ShopperLogin<{ - shortCode: string; - organizationId: string; - clientId: string; - siteId: string; - }>); - -beforeEach(() => { - jest.clearAllMocks(); - nock.cleanAll(); -}); - -describe('Social login user flow', () => { - test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { - // slasClient is copied and tries to make an actual API call - const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - - // Mock authorizeCustomer - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {message: 'Oh yes!'}); - - await expect( - loginIDPUser(mockSlasClient, credentialsPublic, parameters) - ).resolves.not.toThrow(ResponseError); - }); - - test('generates an access token using slas private client', async () => { - const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - - // Mock authorizeCustomer - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {response_body: 'response_body'}, {location: url}); - - const accessToken = await loginIDPUser( - mockSlasClient, - credentialsPrivate, - parameters - ); - - const expectedReqOptions = { - headers: { - Authorization: `Basic ${stringToBase64( - `client_id:${credentialsPrivate.clientSecret}` - )}`, - }, - body: { - grant_type: 'authorization_code', - redirect_uri: 'redirect_uri', - client_id: 'client_id', - channel_id: 'site_id', - usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', - code_verifier: expect.stringMatching(/./) as string, - code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', - dnt: 'false', - }, - }; - expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); - expect(accessToken).toBe(expectedTokenResponse); - }); -}); diff --git a/src/static/helpers/IDPLoginHelper.ts b/src/static/helpers/IDPLoginHelper.ts deleted file mode 100644 index 4479de89..00000000 --- a/src/static/helpers/IDPLoginHelper.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2022, 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 {stringToBase64, createCodeVerifier, authorize} from './slasHelper'; -import { - ShopperLogin, - TokenRequest, - TokenResponse, -} from '../../lib/shopperLogin'; - -/** - * A single function to execute the ShopperLogin External IDP 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 clientSecret (if applicable) 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.hint - Name of an identity provider (IDP) to redirect to - * @param parameters.usid? - Unique Shopper Identifier to enable personalization. - * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. - * @returns TokenResponse - */ -async function loginIDPUser( - slasClient: ShopperLogin<{ - shortCode: string; - organizationId: string; - clientId: string; - siteId: string; - }>, - credentials: { - clientSecret?: string; - }, - parameters: { - redirectURI: string; - hint: string; - usid?: string; - dnt?: boolean; - } -): Promise { - const codeVerifier = createCodeVerifier(); - - const privateClient = !!credentials.clientSecret; - - const authResponse = await authorize( - slasClient, - codeVerifier, - { - redirectURI: parameters.redirectURI, - hint: parameters.hint, - ...(parameters.usid && {usid: parameters.usid}), - }, - privateClient - ); - - const tokenBody: TokenRequest = { - client_id: slasClient.clientConfig.parameters.clientId, - channel_id: slasClient.clientConfig.parameters.siteId, - code: authResponse.code, - code_verifier: codeVerifier, - grant_type: privateClient - ? 'authorization_code' - : 'authorization_code_pkce', - redirect_uri: parameters.redirectURI, - usid: authResponse.usid, - ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), - }; - - // Using 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); - } - - return slasClient.getAccessToken({body: tokenBody}); -} - -export default loginIDPUser; diff --git a/src/static/helpers/index.ts b/src/static/helpers/index.ts index 5b65e933..f72986bb 100644 --- a/src/static/helpers/index.ts +++ b/src/static/helpers/index.ts @@ -8,7 +8,6 @@ // Doing so may lead to circular dependencies or duplicate exports (due to rollup mangling the types) export * from './environment'; export * from './slasHelper'; -export * from './IDPLoginHelper'; export * from './types'; export * from './customApi'; export * from './fetchHelper'; diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index e84251b2..990702fc 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -435,6 +435,61 @@ describe('Registered B2C user flow', () => { }); }); +describe('Social login user flow', () => { + test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { + // slasClient is copied and tries to make an actual API call + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {message: 'Oh yes!'}); + + await expect( + slasHelper.loginIDPUser(mockSlasClient, {}, parameters) + ).resolves.not.toThrow(ResponseError); + }); + + test('generates an access token using slas private client', async () => { + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {response_body: 'response_body'}, {location: url}); + + const accessToken = await slasHelper.loginIDPUser( + mockSlasClient, + credentialsPrivate, + parameters + ); + + const expectedReqOptions = { + headers: { + Authorization: `Basic ${stringToBase64( + `client_id:${credentialsPrivate.clientSecret}` + )}`, + }, + body: { + grant_type: 'authorization_code', + redirect_uri: 'redirect_uri', + client_id: 'client_id', + channel_id: 'site_id', + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + code_verifier: expect.stringMatching(/./) as string, + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + dnt: 'false', + }, + }; + expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); + expect(accessToken).toBe(expectedTokenResponse); + }); +}); + describe('Refresh Token', () => { test('refreshes the token with slas public client', () => { const expectedBody = { diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 400ad62e..246c5f3f 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -360,6 +360,82 @@ export async function loginRegisteredUserB2C( return slasClient.getAccessToken({body: tokenBody}); } +/** + * A single function to execute the ShopperLogin External IDP 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 clientSecret (if applicable) 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.hint - Name of an identity provider (IDP) to redirect to + * @param parameters.usid? - Unique Shopper Identifier to enable personalization. + * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. + * @returns TokenResponse + */ +export async function loginIDPUser( + slasClient: ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>, + credentials: { + clientSecret?: string; + }, + parameters: { + redirectURI: string; + hint: string; + usid?: string; + dnt?: boolean; + } +): Promise { + const codeVerifier = createCodeVerifier(); + + const privateClient = !!credentials.clientSecret; + + const authResponse = await authorize( + slasClient, + codeVerifier, + { + redirectURI: parameters.redirectURI, + hint: parameters.hint, + ...(parameters.usid && {usid: parameters.usid}), + }, + privateClient + ); + + const tokenBody: TokenRequest = { + client_id: slasClient.clientConfig.parameters.clientId, + channel_id: slasClient.clientConfig.parameters.siteId, + code: authResponse.code, + code_verifier: codeVerifier, + grant_type: privateClient + ? 'authorization_code' + : 'authorization_code_pkce', + redirect_uri: parameters.redirectURI, + usid: authResponse.usid, + ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), + }; + + // Using 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); + } + + 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. From 0ba4da255259303b34ce9e1faeefc292dd1529b5 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 25 Sep 2024 17:30:07 -0400 Subject: [PATCH 12/27] add nocors --- src/static/helpers/slasHelper.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 246c5f3f..44a9e9a0 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -124,11 +124,13 @@ export async function authorize( // set manual redirect on server since node allows access to the location // header and it skips the extra call. In the browser, only the default + // set manual redirect for external idp login so shoppers can grab the authorization url // follow setting allows us to get the url. /* istanbul ignore next */ slasClientCopy.clientConfig.fetchOptions = { ...slasClient.clientConfig.fetchOptions, redirect: isBrowser ? 'follow' : 'manual', + mode: 'no-cors', }; const options = { @@ -148,6 +150,7 @@ export async function authorize( const redirectUrlString = response.headers?.get('location') || response.url; const redirectUrl = new URL(redirectUrlString); const searchParams = Object.fromEntries(redirectUrl.searchParams.entries()); + const {headers} = response; // url is a read only property we unfortunately cannot mock out using nock // meaning redirectUrl will not have a falsy value for unit tests @@ -156,7 +159,10 @@ export async function authorize( throw new ResponseError(response); } - return {url: redirectUrlString, ...getCodeAndUsidFromUrl(redirectUrlString)}; + return { + url: redirectUrlString, + ...getCodeAndUsidFromUrl(redirectUrlString) + }; } /** From 171cff11db63a185d1c7f42cb1edcc7504e59423 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 26 Sep 2024 17:08:58 -0400 Subject: [PATCH 13/27] lint --- src/static/helpers/slasHelper.test.ts | 55 --------------------------- src/static/helpers/slasHelper.ts | 55 +++++++++++++++++++++++++-- templates/operations.ts.hbs | 14 +++++-- 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index 3b2f643f..d2048da8 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -439,61 +439,6 @@ describe('Registered B2C user flow', () => { }); }); -describe('Social login user flow', () => { - test('loginIDPUser does not stop when authorizeCustomer returns 303', async () => { - // slasClient is copied and tries to make an actual API call - const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - - // Mock authorizeCustomer - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {message: 'Oh yes!'}); - - await expect( - slasHelper.loginIDPUser(mockSlasClient, {}, parameters) - ).resolves.not.toThrow(ResponseError); - }); - - test('generates an access token using slas private client', async () => { - const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - - // Mock authorizeCustomer - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {response_body: 'response_body'}, {location: url}); - - const accessToken = await slasHelper.loginIDPUser( - mockSlasClient, - credentialsPrivate, - parameters - ); - - const expectedReqOptions = { - headers: { - Authorization: `Basic ${stringToBase64( - `client_id:${credentialsPrivate.clientSecret}` - )}`, - }, - body: { - grant_type: 'authorization_code', - redirect_uri: 'redirect_uri', - client_id: 'client_id', - channel_id: 'site_id', - usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', - code_verifier: expect.stringMatching(/./) as string, - code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', - dnt: 'false', - }, - }; - expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); - expect(accessToken).toBe(expectedTokenResponse); - }); -}); - describe('authorizePasswordless is working', () => { test('Correct parameters are used to call SLAS Client authorize', async () => { const mockSlasClient = createMockSlasClient(); diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index bb6de645..37b0b5ff 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -130,7 +130,6 @@ export async function authorize( slasClientCopy.clientConfig.fetchOptions = { ...slasClient.clientConfig.fetchOptions, redirect: isBrowser ? 'follow' : 'manual', - mode: 'no-cors', }; const options = { @@ -150,7 +149,6 @@ export async function authorize( const redirectUrlString = response.headers?.get('location') || response.url; const redirectUrl = new URL(redirectUrlString); const searchParams = Object.fromEntries(redirectUrl.searchParams.entries()); - const {headers} = response; // url is a read only property we unfortunately cannot mock out using nock // meaning redirectUrl will not have a falsy value for unit tests @@ -161,10 +159,59 @@ export async function authorize( return { url: redirectUrlString, - ...getCodeAndUsidFromUrl(redirectUrlString) + ...getCodeAndUsidFromUrl(redirectUrlString), }; } +/** + * Wrapper for the authorization endpoint. For federated login (3rd party IDP non-guest), the caller should redirect the user to the url in the url field of the returned object. The url will be the login page for the 3rd party IDP and the user will be sent to the redirectURI on success. Guest sessions return the code and usid directly with no need to redirect. + * @param slasClient a configured instance of the ShopperLogin SDK client + * @param codeVerifier - random string created by client app to use as a secret in the request + * @param parameters - Request parameters used by the `authorizeCustomer` endpoint. + * @param parameters.redirectURI - the location the client will be returned to after successful login with 3rd party IDP. Must be registered in SLAS. + * @param parameters.hint? - optional string to hint at a particular IDP. Guest sessions are created by setting this to 'guest' + * @param parameters.usid? - optional saved SLAS user id to link the new session to a previous session + * @returns login url, user id and authorization code if available + */ +export async function authorizeIDP( + slasClient: ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>, + credentials: { + clientSecret?: string; + }, + parameters: { + redirectURI: string; + hint: string; + usid?: string; + } +): Promise { + const codeVerifier = createCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + + const privateClient = !!credentials.clientSecret; + + const options = { + parameters: { + client_id: slasClient.clientConfig.parameters.clientId, + channel_id: slasClient.clientConfig.parameters.siteId, + ...(!privateClient && {code_challenge: codeChallenge}), + hint: parameters.hint, + organizationId: slasClient.clientConfig.parameters.organizationId, + redirect_uri: parameters.redirectURI, + response_type: 'code', + ...(parameters.usid && {usid: parameters.usid}), + }, + }; + + const url = await slasClient.authorizeCustomer(options, true, true); + + return url.toString(); +} + /** * 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. @@ -657,4 +704,4 @@ export function logout( channel_id: slasClient.clientConfig.parameters.siteId, }, }); -} \ No newline at end of file +} diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 93f8fb1e..cd8843c8 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -59,6 +59,7 @@ * @param body - The data to send as the request body. {{/if}} * @param rawResponse - Set to true to return entire Response object instead of DTO. + * @param rawURL - Set to true to return the Request URL * @returns A promise of type Response if rawResponse is true, a promise of type {{getReturnTypeFromOperation this}} otherwise. * {{#if (eq (lowercase @root.metadata.categories.[CC Version Status].[0]) "beta")}} * @beta @@ -79,7 +80,8 @@ body: {{{getPayloadTypeFromRequest request}}} {{/if}} }>, - rawResponse?: T + rawResponse?: T, + rawURL?: T ): Promise; /** @@ -99,6 +101,7 @@ * @param body - The data to send as the request body. {{/if}} * @param rawResponse - Set to true to return entire Response object instead of DTO. + * @param rawURL - Set to true to return the Request URL * * @returns A promise of type Response if rawResponse is true, a promise of type {{getReturnTypeFromOperation this}} otherwise. * {{#if (eq (lowercase @root.metadata.categories.[CC Version Status].[0]) "beta")}} @@ -120,8 +123,9 @@ body: {{{getPayloadTypeFromRequest request}}} {{/if}} }>, - rawResponse?: boolean - ): Promise { + rawResponse?: boolean, + rawURL?: boolean + ): Promise { const optionParams = options?.parameters || ({} as Partial["parameters"]>>); const configParams = this.clientConfig.parameters; @@ -175,6 +179,10 @@ } ); + if (rawURL) { + return url + } + const headers: Record = { {{#if (isRequestWithPayload request)}} "Content-Type": "{{{getMediaTypeFromRequest request}}}", From 27bf96d98ec8ed237ef79319452720e618677825 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Fri, 27 Sep 2024 11:19:56 -0400 Subject: [PATCH 14/27] add loginidpuser helper --- src/static/helpers/slasHelper.ts | 145 ++++++++++++++----------------- 1 file changed, 66 insertions(+), 79 deletions(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 37b0b5ff..b850adfa 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -164,7 +164,7 @@ export async function authorize( } /** - * Wrapper for the authorization endpoint. For federated login (3rd party IDP non-guest), the caller should redirect the user to the url in the url field of the returned object. The url will be the login page for the 3rd party IDP and the user will be sent to the redirectURI on success. Guest sessions return the code and usid directly with no need to redirect. + * Function to return the URL of the authorization endpoint. The url will redirect to the login page for the 3rd party IDP and the user will be sent to the redirectURI on success. Guest sessions return the code and usid directly with no need to redirect. * @param slasClient a configured instance of the ShopperLogin SDK client * @param codeVerifier - random string created by client app to use as a secret in the request * @param parameters - Request parameters used by the `authorizeCustomer` endpoint. @@ -188,7 +188,7 @@ export async function authorizeIDP( hint: string; usid?: string; } -): Promise { +): Promise<{url: string; codeVerifier: string}> { const codeVerifier = createCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); @@ -209,7 +209,70 @@ export async function authorizeIDP( const url = await slasClient.authorizeCustomer(options, true, true); - return url.toString(); + return {url: url.toString(), codeVerifier}; +} + +/** + * Function to execute the ShopperLogin External IDP 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 and clientSecret (if applicable) to login with. + * @param credentials.clientSecret? - secret associated with client ID + * @param credentials.codeVerifier? - random string created by client app to use as a secret in the request + * @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. + * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. + * @returns TokenResponse + */ +export async function loginIDPUser( + slasClient: ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>, + credentials: { + clientSecret?: string; + codeVerifier?: string; + }, + parameters: { + redirectURI: string; + code: string; + usid?: string; + dnt?: boolean; + } +): Promise { + const privateClient = !!credentials.clientSecret; + + const tokenBody: TokenRequest = { + client_id: slasClient.clientConfig.parameters.clientId, + channel_id: slasClient.clientConfig.parameters.siteId, + code: parameters.code, + ...(credentials.codeVerifier && {code_verifier: credentials.codeVerifier}), + grant_type: privateClient + ? 'authorization_code' + : 'authorization_code_pkce', + redirect_uri: parameters.redirectURI, + ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), + ...(parameters.usid && {usid: parameters.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}); } /** @@ -413,82 +476,6 @@ export async function loginRegisteredUserB2C( return slasClient.getAccessToken({body: tokenBody}); } -/** - * A single function to execute the ShopperLogin External IDP 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 clientSecret (if applicable) 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.hint - Name of an identity provider (IDP) to redirect to - * @param parameters.usid? - Unique Shopper Identifier to enable personalization. - * @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user. - * @returns TokenResponse - */ -export async function loginIDPUser( - slasClient: ShopperLogin<{ - shortCode: string; - organizationId: string; - clientId: string; - siteId: string; - }>, - credentials: { - clientSecret?: string; - }, - parameters: { - redirectURI: string; - hint: string; - usid?: string; - dnt?: boolean; - } -): Promise { - const codeVerifier = createCodeVerifier(); - - const privateClient = !!credentials.clientSecret; - - const authResponse = await authorize( - slasClient, - codeVerifier, - { - redirectURI: parameters.redirectURI, - hint: parameters.hint, - ...(parameters.usid && {usid: parameters.usid}), - }, - privateClient - ); - - const tokenBody: TokenRequest = { - client_id: slasClient.clientConfig.parameters.clientId, - channel_id: slasClient.clientConfig.parameters.siteId, - code: authResponse.code, - code_verifier: codeVerifier, - grant_type: privateClient - ? 'authorization_code' - : 'authorization_code_pkce', - redirect_uri: parameters.redirectURI, - usid: authResponse.usid, - ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), - }; - - // Using 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); - } - - return slasClient.getAccessToken({body: tokenBody}); -} - /* Function to send passwordless login token * **Note** At the moment, passwordless is only supported on private client * @param slasClient a configured instance of the ShopperLogin SDK client. From 0043994b973417bf02531ebf9806b9f1dae755ca Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Fri, 27 Sep 2024 12:00:09 -0400 Subject: [PATCH 15/27] add org id to tokenrequest --- src/static/helpers/slasHelper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index b850adfa..de6f7273 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -253,6 +253,7 @@ export async function loginIDPUser( grant_type: privateClient ? 'authorization_code' : 'authorization_code_pkce', + organizationId: slasClient.clientConfig.parameters.organizationId, redirect_uri: parameters.redirectURI, ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), ...(parameters.usid && {usid: parameters.usid}), From 8df5137dcbf231e0d64824c22178da9653347a85 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Fri, 27 Sep 2024 13:33:59 -0400 Subject: [PATCH 16/27] add tests --- src/static/helpers/slasHelper.test.ts | 65 +++++++++++++++++++++++++++ src/static/helpers/slasHelper.ts | 8 +++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index d2048da8..701e0cee 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -212,6 +212,71 @@ test('throws error on 400 response', async () => { ).rejects.toThrow(ResponseError); }); +describe('Authorize IDP User', () => { + test('returns authorization url for 3rd party idp login', async () => { + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // slasClient is copied and tries to make an actual API call + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {response_body: 'response_body'}, {location: url}); + + const authResponse = await slasHelper.authorizeIDP( + mockSlasClient, + {}, + parameters + ); + const expectedAuthURL = + 'https://short_code.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/organization_id/oauth2/authorize?redirect_uri=redirect_uri&response_type=code&client_id=client_id&usid=usid&hint=hint&channel_id=site_id&code_challenge='; + expect(authResponse.url.replace(/code_challenge=[^&]*/, '')).toBe( + expectedAuthURL.replace(/code_challenge=[^&]*/, '') + ); + }); +}); + +describe('IDP Login flow', () => { + test('retrieves usid and code and generates an access token', async () => { + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {response_body: 'response_body'}, {location: url}); + + const loginParams = { + ...parameters, + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + }; + + const accessToken = await slasHelper.loginIDPUser( + mockSlasClient, + {codeVerifier: 'code_verifier'}, + loginParams + ); + + const expectedReqOptions = { + body: { + grant_type: 'authorization_code_pkce', + redirect_uri: 'redirect_uri', + client_id: 'client_id', + channel_id: 'site_id', + organizationId: 'organization_id', + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + code_verifier: expect.stringMatching(/./) as string, + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + dnt: 'false', + }, + }; + expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); + expect(accessToken).toBe(expectedTokenResponse); + }); +}); + describe('Guest user flow', () => { test('retrieves usid and code from location header and generates an access token', async () => { const expectedTokenBody = { diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index de6f7273..db7cce2c 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -192,6 +192,9 @@ export async function authorizeIDP( const codeVerifier = createCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); + // Create a copy to override specific fetchOptions + const slasClientCopy = new ShopperLogin(slasClient.clientConfig); + const privateClient = !!credentials.clientSecret; const options = { @@ -207,7 +210,7 @@ export async function authorizeIDP( }, }; - const url = await slasClient.authorizeCustomer(options, true, true); + const url = await slasClientCopy.authorizeCustomer(options, true, true); return {url: url.toString(), codeVerifier}; } @@ -249,7 +252,8 @@ export async function loginIDPUser( client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, code: parameters.code, - ...(credentials.codeVerifier && {code_verifier: credentials.codeVerifier}), + ...(!privateClient && + credentials.codeVerifier && {code_verifier: credentials.codeVerifier}), grant_type: privateClient ? 'authorization_code' : 'authorization_code_pkce', From ac79df72b9d9fd3e6450013c986127f7aad45c8b Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 30 Sep 2024 11:09:29 -0400 Subject: [PATCH 17/27] only generate code challenge for public client --- src/static/helpers/slasHelper.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index db7cce2c..381b290e 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -117,7 +117,13 @@ export async function authorize( }, privateClient = false ): Promise<{code: string; url: string; usid: string}> { - const codeChallenge = await generateCodeChallenge(codeVerifier); + interface ClientOptions { + codeChallenge?: string; + } + const clientOptions: ClientOptions = {}; + if (!privateClient) { + clientOptions.codeChallenge = await generateCodeChallenge(codeVerifier); + } // Create a copy to override specific fetchOptions const slasClientCopy = new ShopperLogin(slasClient.clientConfig); @@ -136,7 +142,9 @@ export async function authorize( parameters: { client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, - ...(!privateClient && {code_challenge: codeChallenge}), + ...(clientOptions.codeChallenge && { + code_challenge: clientOptions.codeChallenge, + }), ...(parameters.hint && {hint: parameters.hint}), organizationId: slasClient.clientConfig.parameters.organizationId, redirect_uri: parameters.redirectURI, @@ -190,18 +198,27 @@ export async function authorizeIDP( } ): Promise<{url: string; codeVerifier: string}> { const codeVerifier = createCodeVerifier(); - const codeChallenge = await generateCodeChallenge(codeVerifier); // Create a copy to override specific fetchOptions const slasClientCopy = new ShopperLogin(slasClient.clientConfig); const privateClient = !!credentials.clientSecret; + interface ClientOptions { + codeChallenge?: string; + } + const clientOptions: ClientOptions = {}; + if (!privateClient) { + clientOptions.codeChallenge = await generateCodeChallenge(codeVerifier); + } + const options = { parameters: { client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, - ...(!privateClient && {code_challenge: codeChallenge}), + ...(clientOptions.codeChallenge && { + code_challenge: clientOptions.codeChallenge, + }), hint: parameters.hint, organizationId: slasClient.clientConfig.parameters.organizationId, redirect_uri: parameters.redirectURI, From 9bce651f5b1b7e3c5cd9dcb35bcfd354ca32436b Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 30 Sep 2024 11:53:19 -0400 Subject: [PATCH 18/27] add test for both public and private client --- src/static/helpers/slasHelper.test.ts | 99 +++++++++++++++++++++++---- src/static/helpers/slasHelper.ts | 17 ++--- 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index 701e0cee..5bce755f 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -196,6 +196,52 @@ describe('Authorize user', () => { slasHelper.authorize(mockSlasClient, codeVerifier, parameters) ).rejects.toThrow(ResponseError); }); + + test('generate code challenge for public client only', async () => { + const authorizeCustomerMock = jest.fn(); + const mockSlasClient = { + clientConfig: { + parameters: { + shortCode: 'short_code', + organizationId: 'organization_id', + clientId: 'client_id', + siteId: 'site_id', + }, + }, + authorizeCustomer: authorizeCustomerMock, + } as unknown as ShopperLogin<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + }>; + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + + let capturedQueryParams; + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply((uri) => { + const urlObject = new URL( + `https://${shortCode}.api.commercecloud.salesforce.com${uri}` + ); + capturedQueryParams = Object.fromEntries(urlObject.searchParams); // Capture the query params + return [303, {response_body: 'response_body'}, {location: url}]; + }); + + await slasHelper.authorize(mockSlasClient, codeVerifier, parameters, true); + + // There should be no code_challenge for private client + const expectedReqOptions = { + client_id: 'client_id', + channel_id: 'site_id', + hint: 'hint', + redirect_uri: 'redirect_uri', + response_type: 'code', + usid: 'usid', + }; + expect(capturedQueryParams).toEqual(expectedReqOptions); + }); }); test('throws error on 400 response', async () => { @@ -225,7 +271,6 @@ describe('Authorize IDP User', () => { const authResponse = await slasHelper.authorizeIDP( mockSlasClient, - {}, parameters ); const expectedAuthURL = @@ -237,22 +282,50 @@ describe('Authorize IDP User', () => { }); describe('IDP Login flow', () => { - test('retrieves usid and code and generates an access token', async () => { - const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; + const loginParams = { + ...parameters, + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + }; - // Mock authorizeCustomer - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {response_body: 'response_body'}, {location: url}); + const mockSlasClient = createMockSlasClient(); + const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - const loginParams = { - ...parameters, - usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', - code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + // Mock authorizeCustomer + nock(`https://${shortCode}.api.commercecloud.salesforce.com`) + .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) + .query(true) + .reply(303, {response_body: 'response_body'}, {location: url}); + + test('retrieves usid and code and generates an access token for private client', async () => { + const accessToken = await slasHelper.loginIDPUser( + mockSlasClient, + {clientSecret: credentialsPrivate.clientSecret}, + loginParams + ); + + const expectedReqOptions = { + headers: { + Authorization: `Basic ${stringToBase64( + `client_id:${credentialsPrivate.clientSecret}` + )}`, + }, + body: { + grant_type: 'authorization_code', + redirect_uri: 'redirect_uri', + client_id: 'client_id', + channel_id: 'site_id', + organizationId: 'organization_id', + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + dnt: 'false', + }, }; + expect(getAccessTokenMock).toBeCalledWith(expectedReqOptions); + expect(accessToken).toBe(expectedTokenResponse); + }); + test('retrieves usid and code and generates an access token for public client', async () => { const accessToken = await slasHelper.loginIDPUser( mockSlasClient, {codeVerifier: 'code_verifier'}, diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 381b290e..7e25a15e 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -188,22 +188,14 @@ export async function authorizeIDP( clientId: string; siteId: string; }>, - credentials: { - clientSecret?: string; - }, parameters: { redirectURI: string; hint: string; usid?: string; - } + }, + privateClient = false ): Promise<{url: string; codeVerifier: string}> { const codeVerifier = createCodeVerifier(); - - // Create a copy to override specific fetchOptions - const slasClientCopy = new ShopperLogin(slasClient.clientConfig); - - const privateClient = !!credentials.clientSecret; - interface ClientOptions { codeChallenge?: string; } @@ -212,6 +204,9 @@ export async function authorizeIDP( clientOptions.codeChallenge = await generateCodeChallenge(codeVerifier); } + // Create a copy to override specific fetchOptions + const slasClientCopy = new ShopperLogin(slasClient.clientConfig); + const options = { parameters: { client_id: slasClient.clientConfig.parameters.clientId, @@ -269,12 +264,12 @@ export async function loginIDPUser( client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, code: parameters.code, + organizationId: slasClient.clientConfig.parameters.organizationId, ...(!privateClient && credentials.codeVerifier && {code_verifier: credentials.codeVerifier}), grant_type: privateClient ? 'authorization_code' : 'authorization_code_pkce', - organizationId: slasClient.clientConfig.parameters.organizationId, redirect_uri: parameters.redirectURI, ...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}), ...(parameters.usid && {usid: parameters.usid}), From 2bd3fdb8c839625de22d4c539042eb6de9058d26 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 30 Sep 2024 12:01:27 -0400 Subject: [PATCH 19/27] cleanup --- src/static/helpers/slasHelper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 7e25a15e..bfb65449 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -130,7 +130,6 @@ export async function authorize( // set manual redirect on server since node allows access to the location // header and it skips the extra call. In the browser, only the default - // set manual redirect for external idp login so shoppers can grab the authorization url // follow setting allows us to get the url. /* istanbul ignore next */ slasClientCopy.clientConfig.fetchOptions = { From 36fa4b13cd0250333b4cd2a7ffdb7ee3d5125203 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 30 Sep 2024 12:35:58 -0400 Subject: [PATCH 20/27] increase bundle size --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7cd36a58..838f09bc 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "bundlesize": [ { "path": "lib/**/*.js", - "maxSize": "48 kB" + "maxSize": "49 kB" }, { "path": "commerce-sdk-isomorphic-with-deps.tgz", From 0bd1ac3ed25c9b5f41021f8513074bf3864afd8e Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 1 Oct 2024 12:19:53 -0400 Subject: [PATCH 21/27] add apipaths map to clients --- src/static/helpers/slasHelper.ts | 40 +++++++++++++++++++------------- templates/client.ts.hbs | 8 +++++++ templates/operations.ts.hbs | 8 ------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index bfb65449..f844664f 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -11,10 +11,13 @@ import {isBrowser} from './environment'; import { ShopperLogin, + ShopperLoginPathParameters, + ShopperLoginQueryParameters, TokenRequest, TokenResponse, } from '../../lib/shopperLogin'; import ResponseError from '../responseError'; +import TemplateURL from '../templateUrl'; export const stringToBase64 = isBrowser ? btoa @@ -203,25 +206,30 @@ export async function authorizeIDP( clientOptions.codeChallenge = await generateCodeChallenge(codeVerifier); } - // Create a copy to override specific fetchOptions - const slasClientCopy = new ShopperLogin(slasClient.clientConfig); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const apiPath = ShopperLogin.apiPaths.authorizeCustomer; - const options = { - parameters: { - client_id: slasClient.clientConfig.parameters.clientId, - channel_id: slasClient.clientConfig.parameters.siteId, - ...(clientOptions.codeChallenge && { - code_challenge: clientOptions.codeChallenge, - }), - hint: parameters.hint, - organizationId: slasClient.clientConfig.parameters.organizationId, - redirect_uri: parameters.redirectURI, - response_type: 'code', - ...(parameters.usid && {usid: parameters.usid}), - }, + const pathParams: ShopperLoginPathParameters = { + organizationId: slasClient.clientConfig.parameters.organizationId, }; - const url = await slasClientCopy.authorizeCustomer(options, true, true); + const queryParams: ShopperLoginQueryParameters = { + client_id: slasClient.clientConfig.parameters.clientId, + channel_id: slasClient.clientConfig.parameters.siteId, + ...(clientOptions.codeChallenge && { + code_challenge: clientOptions.codeChallenge, + }), + hint: parameters.hint, + redirect_uri: parameters.redirectURI, + response_type: 'code', + ...(parameters.usid && {usid: parameters.usid}), + }; + + const url = new TemplateURL(apiPath, slasClient.clientConfig.baseUri, { + pathParams, + queryParams, + origin: slasClient.clientConfig.proxy, + }); return {url: url.toString(), codeVerifier}; } diff --git a/templates/client.ts.hbs b/templates/client.ts.hbs index b452c97d..bae9b318 100644 --- a/templates/client.ts.hbs +++ b/templates/client.ts.hbs @@ -93,6 +93,14 @@ export class {{name.upperCamelCase}}) { const cfg = {...config} if (!cfg.baseUri) cfg.baseUri = new.target.defaultBaseUri; diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index cd8843c8..d50bacb0 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -59,7 +59,6 @@ * @param body - The data to send as the request body. {{/if}} * @param rawResponse - Set to true to return entire Response object instead of DTO. - * @param rawURL - Set to true to return the Request URL * @returns A promise of type Response if rawResponse is true, a promise of type {{getReturnTypeFromOperation this}} otherwise. * {{#if (eq (lowercase @root.metadata.categories.[CC Version Status].[0]) "beta")}} * @beta @@ -81,7 +80,6 @@ {{/if}} }>, rawResponse?: T, - rawURL?: T ): Promise; /** @@ -101,7 +99,6 @@ * @param body - The data to send as the request body. {{/if}} * @param rawResponse - Set to true to return entire Response object instead of DTO. - * @param rawURL - Set to true to return the Request URL * * @returns A promise of type Response if rawResponse is true, a promise of type {{getReturnTypeFromOperation this}} otherwise. * {{#if (eq (lowercase @root.metadata.categories.[CC Version Status].[0]) "beta")}} @@ -124,7 +121,6 @@ {{/if}} }>, rawResponse?: boolean, - rawURL?: boolean ): Promise { const optionParams = options?.parameters || ({} as Partial["parameters"]>>); const configParams = this.clientConfig.parameters; @@ -179,10 +175,6 @@ } ); - if (rawURL) { - return url - } - const headers: Record = { {{#if (isRequestWithPayload request)}} "Content-Type": "{{{getMediaTypeFromRequest request}}}", From 7dc6a3139947958e3bebe93a596990053074cdfc Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 1 Oct 2024 12:31:09 -0400 Subject: [PATCH 22/27] increase bundle size --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 838f09bc..3dd96875 100644 --- a/package.json +++ b/package.json @@ -177,11 +177,11 @@ "bundlesize": [ { "path": "lib/**/*.js", - "maxSize": "49 kB" + "maxSize": "53 kB" }, { "path": "commerce-sdk-isomorphic-with-deps.tgz", - "maxSize": "430 kB" + "maxSize": "450 kB" } ], "proxy": "https://SHORTCODE.api.commercecloud.salesforce.com" From fc34ee3d826777ab487b8e58ca28ec712fc8c222 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 1 Oct 2024 14:34:46 -0400 Subject: [PATCH 23/27] update test client --- package.json | 4 ++-- src/static/helpers/slasHelper.test.ts | 13 +++---------- src/static/helpers/slasHelper.ts | 10 ++++++---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 3dd96875..71d5ec78 100644 --- a/package.json +++ b/package.json @@ -177,11 +177,11 @@ "bundlesize": [ { "path": "lib/**/*.js", - "maxSize": "53 kB" + "maxSize": "50 kB" }, { "path": "commerce-sdk-isomorphic-with-deps.tgz", - "maxSize": "450 kB" + "maxSize": "430 kB" } ], "proxy": "https://SHORTCODE.api.commercecloud.salesforce.com" diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index 5bce755f..f1b2be23 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -261,22 +261,15 @@ test('throws error on 400 response', async () => { describe('Authorize IDP User', () => { test('returns authorization url for 3rd party idp login', async () => { const mockSlasClient = createMockSlasClient(); - const {shortCode, organizationId} = mockSlasClient.clientConfig.parameters; - - // slasClient is copied and tries to make an actual API call - nock(`https://${shortCode}.api.commercecloud.salesforce.com`) - .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) - .query(true) - .reply(303, {response_body: 'response_body'}, {location: url}); + mockSlasClient.clientConfig.baseUri = 'https://{shortCode}.api.commercecloud.salesforce.com/shopper/auth/{version}'; const authResponse = await slasHelper.authorizeIDP( mockSlasClient, parameters ); const expectedAuthURL = - 'https://short_code.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/organization_id/oauth2/authorize?redirect_uri=redirect_uri&response_type=code&client_id=client_id&usid=usid&hint=hint&channel_id=site_id&code_challenge='; - expect(authResponse.url.replace(/code_challenge=[^&]*/, '')).toBe( - expectedAuthURL.replace(/code_challenge=[^&]*/, '') + 'https://short_code.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/organization_id/oauth2/authorize?client_id=client_id&channel_id=site_id&hint=hint&redirect_uri=redirect_uri&response_type=code&usid=usid'; + expect(authResponse.url.replace(/[&?]code_challenge=[^&]*/, '')).toBe( expectedAuthURL ); }); }); diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index f844664f..651f16ef 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -18,6 +18,7 @@ import { } from '../../lib/shopperLogin'; import ResponseError from '../responseError'; import TemplateURL from '../templateUrl'; +import {BaseUriParameters} from './types'; export const stringToBase64 = isBrowser ? btoa @@ -189,6 +190,7 @@ export async function authorizeIDP( organizationId: string; clientId: string; siteId: string; + version?: string; }>, parameters: { redirectURI: string; @@ -206,13 +208,13 @@ export async function authorizeIDP( clientOptions.codeChallenge = await generateCodeChallenge(codeVerifier); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line const apiPath = ShopperLogin.apiPaths.authorizeCustomer; - - const pathParams: ShopperLoginPathParameters = { + const pathParams: ShopperLoginPathParameters & Required = { organizationId: slasClient.clientConfig.parameters.organizationId, + shortCode: slasClient.clientConfig.parameters.shortCode, + version: slasClient.clientConfig.parameters.version || 'v1', }; - const queryParams: ShopperLoginQueryParameters = { client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, From abf83f713f2c0273910307e143610490c43b3bea Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 1 Oct 2024 14:36:34 -0400 Subject: [PATCH 24/27] cleanup --- templates/operations.ts.hbs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index d50bacb0..6577121a 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -79,7 +79,7 @@ body: {{{getPayloadTypeFromRequest request}}} {{/if}} }>, - rawResponse?: T, + rawResponse?: T ): Promise; /** @@ -120,8 +120,8 @@ body: {{{getPayloadTypeFromRequest request}}} {{/if}} }>, - rawResponse?: boolean, - ): Promise { + rawResponse?: boolean + ): Promise { const optionParams = options?.parameters || ({} as Partial["parameters"]>>); const configParams = this.clientConfig.parameters; From fbabeb9d81065dd50261824c182316077080eb0a Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 1 Oct 2024 14:37:33 -0400 Subject: [PATCH 25/27] cleanup --- templates/operations.ts.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 6577121a..93f8fb1e 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -121,7 +121,7 @@ {{/if}} }>, rawResponse?: boolean - ): Promise { + ): Promise { const optionParams = options?.parameters || ({} as Partial["parameters"]>>); const configParams = this.clientConfig.parameters; From ca3e1639ec4b1dca43ba9730b93a55a8fff7ddba Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 2 Oct 2024 10:42:03 -0400 Subject: [PATCH 26/27] increase bundle size --- package.json | 2 +- src/static/helpers/slasHelper.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 71d5ec78..1db6b9b2 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ }, { "path": "commerce-sdk-isomorphic-with-deps.tgz", - "maxSize": "430 kB" + "maxSize": "440 kB" } ], "proxy": "https://SHORTCODE.api.commercecloud.salesforce.com" diff --git a/src/static/helpers/slasHelper.test.ts b/src/static/helpers/slasHelper.test.ts index f1b2be23..a24faf38 100644 --- a/src/static/helpers/slasHelper.test.ts +++ b/src/static/helpers/slasHelper.test.ts @@ -221,7 +221,7 @@ describe('Authorize user', () => { nock(`https://${shortCode}.api.commercecloud.salesforce.com`) .get(`/shopper/auth/v1/organizations/${organizationId}/oauth2/authorize`) .query(true) - .reply((uri) => { + .reply(uri => { const urlObject = new URL( `https://${shortCode}.api.commercecloud.salesforce.com${uri}` ); @@ -261,7 +261,8 @@ test('throws error on 400 response', async () => { describe('Authorize IDP User', () => { test('returns authorization url for 3rd party idp login', async () => { const mockSlasClient = createMockSlasClient(); - mockSlasClient.clientConfig.baseUri = 'https://{shortCode}.api.commercecloud.salesforce.com/shopper/auth/{version}'; + mockSlasClient.clientConfig.baseUri = + 'https://{shortCode}.api.commercecloud.salesforce.com/shopper/auth/{version}'; const authResponse = await slasHelper.authorizeIDP( mockSlasClient, @@ -269,7 +270,8 @@ describe('Authorize IDP User', () => { ); const expectedAuthURL = 'https://short_code.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/organization_id/oauth2/authorize?client_id=client_id&channel_id=site_id&hint=hint&redirect_uri=redirect_uri&response_type=code&usid=usid'; - expect(authResponse.url.replace(/[&?]code_challenge=[^&]*/, '')).toBe( expectedAuthURL + expect(authResponse.url.replace(/[&?]code_challenge=[^&]*/, '')).toBe( + expectedAuthURL ); }); }); From a2990001a3bab5be3cdf4d7b99f4c1f9639d39b6 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 2 Oct 2024 12:03:50 -0400 Subject: [PATCH 27/27] update function description --- src/static/helpers/slasHelper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/static/helpers/slasHelper.ts b/src/static/helpers/slasHelper.ts index 651f16ef..fbafdea0 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -177,12 +177,12 @@ export async function authorize( /** * Function to return the URL of the authorization endpoint. The url will redirect to the login page for the 3rd party IDP and the user will be sent to the redirectURI on success. Guest sessions return the code and usid directly with no need to redirect. * @param slasClient a configured instance of the ShopperLogin SDK client - * @param codeVerifier - random string created by client app to use as a secret in the request * @param parameters - Request parameters used by the `authorizeCustomer` endpoint. * @param parameters.redirectURI - the location the client will be returned to after successful login with 3rd party IDP. Must be registered in SLAS. - * @param parameters.hint? - optional string to hint at a particular IDP. Guest sessions are created by setting this to 'guest' + * @param parameters.hint - string to hint at a particular IDP. Required for 3rd party IDP login. * @param parameters.usid? - optional saved SLAS user id to link the new session to a previous session - * @returns login url, user id and authorization code if available + * @param privateClient - boolean indicating whether the client is private or not. Defaults to false. + * @returns authorization url and code verifier */ export async function authorizeIDP( slasClient: ShopperLogin<{