Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@W-16553557 Add passwordless login helpers #173

Merged
merged 5 commits into from
Sep 25, 2024
Merged
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/static/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -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 './passwordlessHelper';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: The passwordless login API's are part of SLAS right? And from the looks of it, the content of this file is somewhat small, is there a reason you didn't slap it in the slasHelpers, sounds like that is the right context for it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but it was moved to its own file because adding these functions fails the 500 line rule for slasHelpers in linting

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats super lame, seems a little arbitrary.

It definitely seems like the SLAS passwordless helper should be located along with all the other SLAS helpers. Can you propose an alternative (non-breaking) solution here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that it's pretty arbitrary, when the line limit was implemented (3 years ago) I don't think the team originally envisioned a helper file growing to this size. I think it'd be ok to update the line limit or ignore it for this file:

Ignore: /* eslint-disable max-lines */
Update max lines:

Copy link
Collaborator Author

@jeremy-jung1 jeremy-jung1 Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll override the max-lines for this file to make it as flexible as possible for new helpers as part of this epic

export * from './types';
export * from './customApi';
export * from './fetchHelper';
134 changes: 134 additions & 0 deletions src/static/helpers/passwordlessHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @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} from '../../lib/shopperLogin';
import * as slasHelper from './slasHelper';
import {
authorizePasswordless,
getPasswordLessAccessToken,
} from './passwordlessHelper';

type slasHelperType = typeof slasHelper;
// Mock the module
jest.mock('./slasHelper', () => {
const actualUtils = jest.requireActual<slasHelperType>('./slasHelper');
const createCodeVerifierMock = jest.fn(() => 'code_verifier');
return {
...actualUtils,
createCodeVerifier: createCodeVerifierMock, // Mock the specific function
};
});

const authorizePasswordlessCustomerMock = jest.fn();
const getPasswordLessAccessTokenMock = jest.fn();

const createMockSlasClient = () =>
({
clientConfig: {
parameters: {
shortCode: 'short_code',
organizationId: 'organization_id',
clientId: 'client_id',
siteId: 'site_id',
},
},
authorizePasswordlessCustomer: authorizePasswordlessCustomerMock,
getPasswordLessAccessToken: getPasswordLessAccessTokenMock,
} as unknown as ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>);

beforeEach(() => {
jest.clearAllMocks();
nock.cleanAll();
});

describe('authorizePasswordless is working', () => {
test('Correct parameters are used to call SLAS Client authorize', async () => {
const mockSlasClient = createMockSlasClient();
const {clientId, organizationId, siteId} =
mockSlasClient.clientConfig.parameters;

const credentials = {
clientSecret: 'slas_private_secret',
};
const parameters = {
callbackURI: 'www.something.com/callback',
usid: 'a_usid',
userid: 'a_userid',
locale: 'a_locale',
mode: 'callback',
};
const authHeaderExpected = `Basic ${slasHelper.stringToBase64(
`${clientId}:${credentials.clientSecret}`
)}`;
await authorizePasswordless(mockSlasClient, credentials, parameters);
const expectedReqOptions = {
headers: {
Authorization: authHeaderExpected,
},
parameters: {
organizationId,
},
body: {
user_id: parameters.userid,
mode: parameters.mode,
locale: parameters.locale,
channel_id: siteId,
callback_uri: parameters.callbackURI,
usid: parameters.usid,
},
};
expect(authorizePasswordlessCustomerMock).toBeCalledWith(
expectedReqOptions,

Check warning on line 95 in src/static/helpers/passwordlessHelper.test.ts

GitHub Actions / linux-tests (12)

Delete `·`

Check warning on line 95 in src/static/helpers/passwordlessHelper.test.ts

GitHub Actions / linux-tests (14)

Delete `·`

Check warning on line 95 in src/static/helpers/passwordlessHelper.test.ts

GitHub Actions / linux-tests (16)

Delete `·`

Check warning on line 95 in src/static/helpers/passwordlessHelper.test.ts

GitHub Actions / linux-tests (18)

Delete `·`
true
);
});
});

describe('getPasswordLessAccessToken is working', () => {
test('Correct parameters are used to call SLAS Client authorize', async () => {
const mockSlasClient = createMockSlasClient();
const {clientId, organizationId} = mockSlasClient.clientConfig.parameters;

const credentials = {
clientSecret: 'slas_private_secret',
};
const parameters = {
pwdlessLoginToken: '123456',
dnt: '1',
};
const authHeaderExpected = `Basic ${slasHelper.stringToBase64(
`${clientId}:${credentials.clientSecret}`
)}`;
await getPasswordLessAccessToken(mockSlasClient, credentials, parameters);
const expectedReqOptions = {
headers: {
Authorization: authHeaderExpected,
},
parameters: {
organizationId,
},
body: {
dnt: parameters.dnt,
code_verifier: 'code_verifier',
grant_type: 'client_credentials',
hint: 'pwdless_login',
pwdless_login_token: parameters.pwdlessLoginToken,
},
};
expect(getPasswordLessAccessTokenMock).toBeCalledWith(expectedReqOptions);
});
});
116 changes: 116 additions & 0 deletions src/static/helpers/passwordlessHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2024, 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 {ShopperLogin, TokenResponse} from '../../lib/shopperLogin';
import {createCodeVerifier, stringToBase64} from './slasHelper';

/**
* 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.
* @param credentials.clientSecret? - secret associated with client ID
* @param parameters - parameters to pass in the API calls.
* @param parameters.callbackURI? - URI to send the passwordless login token to. Must be listed in your SLAS configuration. Required when mode is callback
* @param parameters.usid? - Unique Shopper Identifier to enable personalization.
* @param parameters.userid - User Id for login
* @param parameters.locale - The locale of the template. Not needed for the callback mode
* @param parameters.mode - Medium of sending login token
* @returns Promise of Response
*/
export async function authorizePasswordless(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
credentials: {
clientSecret: string;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to throw if this information is not provided as we are doing here. This comment also applies to the getPasswordLessAccessToken function implementation below.

},
parameters: {
callbackURI?: string;
usid?: string;
userid: string;
locale?: string;
mode: string;
}
): Promise<Response> {
const authHeaderIdSecret = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;
const tokenBody = {
user_id: parameters.userid,
mode: parameters.mode,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: It doesn't look like we have have a pattern for throwing when required params are not included, so I don't expect you to add it now because I like consistency more. But I'll take note on that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also seems like typescript checks for the existence of these params that are directly required. But when it's parameters within an object like ShopperLogin, typescript doesn't automatically give an error that expected arguments don't exist

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't reply on TypeScript types for things like that because this lib is compiled into JS and it can be used by a project that isn't TS. Typescript won't bake in any kind of parameter checking so there is the possibility that you this code would make a server request that would for sure fail if that param is required.

But again, no need to change that now, it doesn't seem like it's a pattern that we have, and we should probably fix that in another PR in one big swoop.

...(parameters.locale && {locale: parameters.locale}),
...(parameters.usid && {usid: parameters.usid}),
channel_id: slasClient.clientConfig.parameters.siteId,
...(parameters.callbackURI && {callback_uri: parameters.callbackURI}),
};

return slasClient.authorizePasswordlessCustomer(
{
headers: {
Authorization: authHeaderIdSecret,
},
parameters: {
organizationId: slasClient.clientConfig.parameters.organizationId,
},
body: tokenBody,
},
true
);
}

/**
* Function to login user with 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.
* @param credentials.clientSecret? - secret associated with client ID
* @param parameters - parameters to pass in the API calls.
* @param parameters.callbackURI? - URI to send the passwordless login token to. Must be listed in your SLAS configuration. Required when mode is callback
* @param parameters.pwdlessLoginToken - Passwordless login token
* @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user.
* @returns Promise of Response or Object
*/
export async function getPasswordLessAccessToken(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
credentials: {
clientSecret: string;
},
parameters: {
pwdlessLoginToken: string;
dnt?: string;
}
): Promise<TokenResponse> {
const codeVerifier = createCodeVerifier();
const authHeaderIdSecret = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;

const tokenBody = {
grant_type: 'client_credentials',
hint: 'pwdless_login',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pwdless_login_token: parameters.pwdlessLoginToken,
code_verifier: codeVerifier,
...(parameters.dnt && {dnt: parameters.dnt}),
};
return slasClient.getPasswordLessAccessToken({
headers: {
Authorization: authHeaderIdSecret,
},
parameters: {
organizationId: slasClient.clientConfig.parameters.organizationId,
},
body: tokenBody,
});
}
Loading