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/package.json b/package.json index 7cd36a58..1db6b9b2 100644 --- a/package.json +++ b/package.json @@ -177,11 +177,11 @@ "bundlesize": [ { "path": "lib/**/*.js", - "maxSize": "48 kB" + "maxSize": "50 kB" }, { "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 d2048da8..a24faf38 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 () => { @@ -212,6 +258,93 @@ 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(); + 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?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 + ); + }); +}); + +describe('IDP Login flow', () => { + const loginParams = { + ...parameters, + usid: '048adcfb-aa93-4978-be9e-09cb569fdcb9', + code: 'J2lHm0cgXmnXpwDhjhLoyLJBoUAlBfxDY-AhjqGMC-o', + }; + + 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}); + + 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'}, + 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 931b0807..fbafdea0 100644 --- a/src/static/helpers/slasHelper.ts +++ b/src/static/helpers/slasHelper.ts @@ -11,10 +11,14 @@ import {isBrowser} from './environment'; import { ShopperLogin, + ShopperLoginPathParameters, + ShopperLoginQueryParameters, TokenRequest, TokenResponse, } from '../../lib/shopperLogin'; import ResponseError from '../responseError'; +import TemplateURL from '../templateUrl'; +import {BaseUriParameters} from './types'; export const stringToBase64 = isBrowser ? btoa @@ -114,9 +118,16 @@ export async function authorize( redirectURI: string; hint?: string; usid?: string; - } + }, + 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); @@ -134,7 +145,9 @@ export async function authorize( parameters: { client_id: slasClient.clientConfig.parameters.clientId, channel_id: slasClient.clientConfig.parameters.siteId, - code_challenge: codeChallenge, + ...(clientOptions.codeChallenge && { + code_challenge: clientOptions.codeChallenge, + }), ...(parameters.hint && {hint: parameters.hint}), organizationId: slasClient.clientConfig.parameters.organizationId, redirect_uri: parameters.redirectURI, @@ -155,7 +168,137 @@ export async function authorize( throw new ResponseError(response); } - return {url: redirectUrlString, ...getCodeAndUsidFromUrl(redirectUrlString)}; + return { + url: redirectUrlString, + ...getCodeAndUsidFromUrl(redirectUrlString), + }; +} + +/** + * 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 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 - 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 + * @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<{ + shortCode: string; + organizationId: string; + clientId: string; + siteId: string; + version?: string; + }>, + parameters: { + redirectURI: string; + hint: string; + usid?: string; + }, + privateClient = false +): Promise<{url: string; codeVerifier: string}> { + const codeVerifier = createCodeVerifier(); + interface ClientOptions { + codeChallenge?: string; + } + const clientOptions: ClientOptions = {}; + if (!privateClient) { + clientOptions.codeChallenge = await generateCodeChallenge(codeVerifier); + } + + // eslint-disable-next-line + const apiPath = ShopperLogin.apiPaths.authorizeCustomer; + 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, + ...(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}; +} + +/** + * 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, + organizationId: slasClient.clientConfig.parameters.organizationId, + ...(!privateClient && + 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}); } /** @@ -359,8 +502,7 @@ export async function loginRegisteredUserB2C( return slasClient.getAccessToken({body: tokenBody}); } -/** - * Function to send passwordless login token +/* 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. * @param credentials - the id and password and clientSecret (if applicable) to login with. 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;