Skip to content

Commit

Permalink
Merge pull request #173 from SalesforceCommerceCloud/W-16553557-passw…
Browse files Browse the repository at this point in the history
…ordless-helper

@W-16553557 Add passwordless login helpers
  • Loading branch information
jeremy-jung1 authored Sep 25, 2024
2 parents 2af2671 + 871346e commit b587cbf
Show file tree
Hide file tree
Showing 3 changed files with 293 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "error"
}
},
{
"files": ["./src/static/helpers/slasHelper.ts"],
"rules": {
"max-lines": "off"
}
}
],
"settings": {
Expand Down
148 changes: 148 additions & 0 deletions src/static/helpers/slasHelper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const authenticateCustomerMock = jest.fn(() => ({url}));
const getAccessTokenMock = jest.fn(() => expectedTokenResponse);
const logoutCustomerMock = jest.fn(() => expectedTokenResponse);
const generateCodeChallengeMock = jest.fn(() => 'code_challenge');
const authorizePasswordlessCustomerMock = jest.fn();
const getPasswordLessAccessTokenMock = jest.fn();

const createMockSlasClient = () =>
({
Expand All @@ -73,6 +75,8 @@ const createMockSlasClient = () =>
getAccessToken: getAccessTokenMock,
logoutCustomer: logoutCustomerMock,
generateCodeChallenge: generateCodeChallengeMock,
authorizePasswordlessCustomer: authorizePasswordlessCustomerMock,
getPasswordLessAccessToken: getPasswordLessAccessTokenMock,
} as unknown as ShopperLogin<{
shortCode: string;
organizationId: string;
Expand Down Expand Up @@ -435,6 +439,150 @@ describe('Registered B2C user flow', () => {
});
});

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 parametersAuthorizePasswordless = {
callbackURI: 'www.something.com/callback',
usid: 'a_usid',
userid: 'a_userid',
locale: 'a_locale',
mode: 'callback',
};
const authHeaderExpected = `Basic ${slasHelper.stringToBase64(
`${clientId}:${credentialsPrivate.clientSecret}`
)}`;
await slasHelper.authorizePasswordless(
mockSlasClient,
credentialsPrivate,
parametersAuthorizePasswordless
);
const expectedReqOptions = {
headers: {
Authorization: authHeaderExpected,
},
parameters: {
organizationId,
},
body: {
user_id: parametersAuthorizePasswordless.userid,
mode: parametersAuthorizePasswordless.mode,
locale: parametersAuthorizePasswordless.locale,
channel_id: siteId,
callback_uri: parametersAuthorizePasswordless.callbackURI,
usid: parametersAuthorizePasswordless.usid,
},
};
expect(authorizePasswordlessCustomerMock).toBeCalledWith(
expectedReqOptions,
true
);
});
test('Throw when required parameters missing', async () => {
const mockSlasClient = {
clientConfig: {
parameters: {
shortCode: 'short_code',
organizationId: 'organization_id',
clientId: 'client_id',
},
},
authorizePasswordlessCustomer: authorizePasswordlessCustomerMock,
getPasswordLessAccessToken: getPasswordLessAccessTokenMock,
} as unknown as ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>;
const parametersAuthorizePasswordless = {
callbackURI: 'www.something.com/callback',
usid: 'a_usid',
userid: 'a_userid',
locale: 'a_locale',
mode: 'callback',
};
await expect(
slasHelper.authorizePasswordless(
mockSlasClient,
credentialsPrivate,
parametersAuthorizePasswordless
)
).rejects.toThrow(
'Required argument channel_id is not provided through clientConfig.parameters.siteId'
);
});
});

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

const parametersPasswordlessToken = {
pwdlessLoginToken: '123456',
dnt: '1',
};
const authHeaderExpected = `Basic ${slasHelper.stringToBase64(
`${clientId}:${credentialsPrivate.clientSecret}`
)}`;
await slasHelper.getPasswordLessAccessToken(
mockSlasClient,
credentialsPrivate,
parametersPasswordlessToken
);
const expectedReqOptions = {
headers: {
Authorization: authHeaderExpected,
},
parameters: {
organizationId,
},
body: {
dnt: parametersPasswordlessToken.dnt,
code_verifier: expect.stringMatching(/./) as string,
grant_type: 'client_credentials',
hint: 'pwdless_login',
pwdless_login_token: parametersPasswordlessToken.pwdlessLoginToken,
},
};
expect(getPasswordLessAccessTokenMock).toBeCalledWith(expectedReqOptions);
});
test('Throw when required parameters missing', async () => {
const mockSlasClient = {
clientConfig: {
parameters: {
shortCode: 'short_code',
clientId: 'client_id',
},
},
authorizePasswordlessCustomer: authorizePasswordlessCustomerMock,
getPasswordLessAccessToken: getPasswordLessAccessTokenMock,
} as unknown as ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>;
const parametersPasswordlessToken = {
pwdlessLoginToken: '123456',
dnt: '1',
};
await expect(
slasHelper.getPasswordLessAccessToken(
mockSlasClient,
credentialsPrivate,
parametersPasswordlessToken
)
).rejects.toThrow(
'Required argument organizationId is not provided through clientConfig.parameters.organizationId'
);
});
});

describe('Refresh Token', () => {
test('refreshes the token with slas public client', () => {
const expectedBody = {
Expand Down
139 changes: 139 additions & 0 deletions src/static/helpers/slasHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,145 @@ export async function loginRegisteredUserB2C(
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.
* @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;
},
parameters: {
callbackURI?: string;
usid?: string;
userid: string;
locale?: string;
mode: string;
}
): Promise<Response> {
if (!credentials.clientSecret) {
throw new Error('Required argument client secret is not provided');
}
if (!slasClient.clientConfig.parameters.siteId) {
throw new Error(
'Required argument channel_id is not provided through clientConfig.parameters.siteId'
);
}
if (!parameters.mode) {
throw new Error(
'Required argument mode is not provided through parameters'
);
}
if (!parameters.userid) {
throw new Error(
'Required argument userid is not provided through parameters'
);
}
const authHeaderIdSecret = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;
const tokenBody = {
user_id: parameters.userid,
mode: parameters.mode,
...(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> {
if (!credentials.clientSecret) {
throw new Error('Required argument client secret is not provided');
}
if (!slasClient.clientConfig.parameters.organizationId) {
throw new Error(
'Required argument organizationId is not provided through clientConfig.parameters.organizationId'
);
}
if (!parameters.pwdlessLoginToken) {
throw new Error(
'Required argument pwdlessLoginToken is not provided through parameters'
);
}
const codeVerifier = createCodeVerifier();
const authHeaderIdSecret = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;

const tokenBody = {
grant_type: 'client_credentials',
hint: 'pwdless_login',
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,
});
}

/**
* 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.
Expand Down

0 comments on commit b587cbf

Please sign in to comment.