Skip to content

Commit

Permalink
Merge pull request #172 from SalesforceCommerceCloud/social-login-helper
Browse files Browse the repository at this point in the history
Add SLAS social login helpers
  • Loading branch information
yunakim714 authored Oct 7, 2024
2 parents b587cbf + a299000 commit b4161fa
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 9 deletions.
2 changes: 1 addition & 1 deletion apis/shopper-login/shopper-login.raml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
133 changes: 133 additions & 0 deletions src/static/helpers/slasHelper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 = {
Expand Down
154 changes: 148 additions & 6 deletions src/static/helpers/slasHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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<BaseUriParameters> = {
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<TokenResponse> {
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});
}

/**
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions templates/client.ts.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ export class {{name.upperCamelCase}}<ConfigParameters extends {{{name.upperCamel

static readonly defaultBaseUri = "{{getBaseUriFromDocument model}}";

static readonly apiPaths = {
{{#each model.encodes.endPoints}}
{{#each operations}}
{{{name}}}: "{{{../path}}}",
{{/each}}
{{/each}}
};

constructor(config: ClientConfigInit<ConfigParameters>) {
const cfg = {...config}
if (!cfg.baseUri) cfg.baseUri = new.target.defaultBaseUri;
Expand Down

0 comments on commit b4161fa

Please sign in to comment.