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

Feature passwordless social login #2079

Draft
wants to merge 24 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fee6914
Add social login
bredmond-sf Sep 25, 2024
0a93e1b
Merge branch 'develop' of github.com:SalesforceCommerceCloud/pwa-kit …
bredmond-sf Sep 25, 2024
2e4bede
Remove new default.js key
bredmond-sf Sep 25, 2024
a383f52
Tweak test
bredmond-sf Sep 26, 2024
114363e
Merge branch 'feature-passwordless-social-login' of github.com:Salesf…
bredmond-sf Sep 26, 2024
76b9cd0
Fix bordercolor
bredmond-sf Sep 26, 2024
da6ac02
Tweak icon
bredmond-sf Sep 26, 2024
f44e927
Merge pull request #2027 from SalesforceCommerceCloud/W-16544327-soci…
bredmond-sf Sep 26, 2024
cf49a18
Merge branch 'feature-passwordless-social-login' of github.com:Salesf…
bredmond-sf Sep 27, 2024
89f9a30
Merge branch 'develop' of github.com:SalesforceCommerceCloud/pwa-kit …
bredmond-sf Oct 1, 2024
82be946
Merge branch 'develop' of github.com:SalesforceCommerceCloud/pwa-kit …
bredmond-sf Oct 2, 2024
7a2dedd
Merge branch 'develop' of github.com:SalesforceCommerceCloud/pwa-kit …
bredmond-sf Oct 3, 2024
7f01ff7
Add wrappers for social login helpers in `commerce-sdk-react` (#2049)
yunakim714 Oct 9, 2024
59a1ddc
Merge branch 'develop' into feature-passwordless-social-login
hajinsuha1 Oct 15, 2024
34c450c
Merge branch 'develop' into feature-passwordless-social-login
hajinsuha1 Oct 15, 2024
27ec267
Merge branch 'develop' into feature-passwordless-social-login
hajinsuha1 Oct 22, 2024
f116aca
Add wrappers for passwordless login helpers in `commerce-sdk-react` (…
yunakim714 Oct 24, 2024
8a3798e
Social Login Redirect Page (#2068)
yunakim714 Oct 24, 2024
415b673
Merge branch 'develop' into feature-passwordless-social-login
hajinsuha1 Oct 29, 2024
7d80a01
Merge branch 'develop' into feature-passwordless-social-login
hajinsuha1 Oct 31, 2024
ef0fbe2
@W-16617186 Passwordless Login UI Buttons (#2032)
hajinsuha1 Oct 31, 2024
372abcf
@W-16795956 - Implement "Check Email" page (#2110)
yunakim714 Nov 11, 2024
8bc9f69
@W-16909794 - Add passwordless/social login UI buttons to Checkout pa…
yunakim714 Nov 13, 2024
c02cf88
Merge branch 'develop' into feature-passwordless-social-login
hajinsuha1 Nov 21, 2024
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 packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## v3.2.0-dev (Oct 29, 2024)
- Clear auth state if session has been invalidated by a password change [#2092](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2092)
- Add wrappers for social login helpers: `authorizeIDP` and `loginIDPUser` [#2049](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2049)

## v3.1.0 (Oct 28, 2024)

Expand Down
89 changes: 86 additions & 3 deletions packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ jest.mock('commerce-sdk-isomorphic', () => {
loginGuestUserPrivate: jest.fn().mockResolvedValue(''),
loginRegisteredUserB2C: jest.fn().mockResolvedValue(''),
logout: jest.fn().mockResolvedValue(''),
handleTokenResponse: jest.fn().mockResolvedValue('')
handleTokenResponse: jest.fn().mockResolvedValue(''),
loginIDPUser: jest.fn().mockResolvedValue(''),
authorizeIDP: jest.fn().mockResolvedValue(''),
authorizePasswordless: jest.fn().mockResolvedValue(''),
getPasswordLessAccessToken: jest.fn().mockResolvedValue('')
},
ShopperCustomers: jest.fn().mockImplementation(() => {
return {
Expand All @@ -59,7 +63,8 @@ jest.mock('../utils', () => ({
onClient: () => true,
getParentOrigin: jest.fn().mockResolvedValue(''),
isOriginTrusted: () => false,
getDefaultCookieAttributes: () => {}
getDefaultCookieAttributes: () => {},
isAbsoluteUrl: () => true
}))

/** The auth data we store has a slightly different shape than what we use. */
Expand All @@ -72,14 +77,25 @@ const config = {
siteId: 'siteId',
proxy: 'proxy',
redirectURI: 'redirectURI',
logger: console
logger: console,
callbackURI: 'callbackURI'
}

const configSLASPrivate = {
...config,
enablePWAKitPrivateClient: true
}

const configPasswordlessSms = {
clientId: 'clientId',
organizationId: 'organizationId',
shortCode: 'shortCode',
siteId: 'siteId',
proxy: 'proxy',
redirectURI: 'redirectURI',
logger: console
}

const FAKE_SLAS_EXPIRY = DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL - 1

const TOKEN_RESPONSE: ShopperLoginTypes.TokenResponse = {
Expand Down Expand Up @@ -584,6 +600,73 @@ describe('Auth', () => {
clientSecret: SLAS_SECRET_PLACEHOLDER
})
})

test('loginIDPUser calls isomorphic loginIDPUser', async () => {
const auth = new Auth(config)
await auth.loginIDPUser({redirectURI: 'redirectURI', code: 'test'})
expect(helpers.loginIDPUser).toHaveBeenCalled()
const functionArg = (helpers.loginIDPUser as jest.Mock).mock.calls[0][2]
expect(functionArg).toMatchObject({redirectURI: 'redirectURI', code: 'test'})
})

test('loginIDPUser adds clientSecret to parameters when using private client', async () => {
const auth = new Auth(configSLASPrivate)
await auth.loginIDPUser({redirectURI: 'test', code: 'test'})
expect(helpers.loginIDPUser).toHaveBeenCalled()
const functionArg = (helpers.loginIDPUser as jest.Mock).mock.calls[0][1]
expect(functionArg).toMatchObject({
clientSecret: SLAS_SECRET_PLACEHOLDER
})
})

test('authorizeIDP calls isomorphic authorizeIDP', async () => {
const auth = new Auth(config)
await auth.authorizeIDP({redirectURI: 'redirectURI', hint: 'test'})
expect(helpers.authorizeIDP).toHaveBeenCalled()
const functionArg = (helpers.authorizeIDP as jest.Mock).mock.calls[0][1]
expect(functionArg).toMatchObject({redirectURI: 'redirectURI', hint: 'test'})
})

test('authorizeIDP adds clientSecret to parameters when using private client', async () => {
const auth = new Auth(configSLASPrivate)
await auth.authorizeIDP({redirectURI: 'test', hint: 'test'})
expect(helpers.authorizeIDP).toHaveBeenCalled()
const privateClient = (helpers.authorizeIDP as jest.Mock).mock.calls[0][2]
expect(privateClient).toBe(true)
})

test('authorizePasswordless calls isomorphic authorizePasswordless', async () => {
const auth = new Auth(config)
await auth.authorizePasswordless({
callbackURI: 'callbackURI',
userid: 'userid',
mode: 'callback'
})
expect(helpers.authorizePasswordless).toHaveBeenCalled()
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][2]
expect(functionArg).toMatchObject({
callbackURI: 'callbackURI',
userid: 'userid',
mode: 'callback'
})
})

test('authorizePasswordless sets mode to sms as configured', async () => {
const auth = new Auth(configPasswordlessSms)
await auth.authorizePasswordless({userid: 'userid', mode: 'sms'})
expect(helpers.authorizePasswordless).toHaveBeenCalled()
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][2]
expect(functionArg).toMatchObject({userid: 'userid', mode: 'sms'})
})

test('getPasswordLessAccessToken calls isomorphic getPasswordLessAccessToken', async () => {
const auth = new Auth(config)
await auth.getPasswordLessAccessToken({pwdlessLoginToken: '12345678'})
expect(helpers.getPasswordLessAccessToken).toHaveBeenCalled()
const functionArg = (helpers.getPasswordLessAccessToken as jest.Mock).mock.calls[0][2]
expect(functionArg).toMatchObject({pwdlessLoginToken: '12345678'})
})

test('logout as registered user calls isomorphic logout', async () => {
const auth = new Auth(config)

Expand Down
132 changes: 130 additions & 2 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import {jwtDecode, JwtPayload} from 'jwt-decode'
import {ApiClientConfigParams, Prettify, RemoveStringIndex} from '../hooks/types'
import {BaseStorage, LocalStorage, CookieStorage, MemoryStorage, StorageType} from './storage'
import {CustomerType} from '../hooks/useCustomerType'
import {getParentOrigin, isOriginTrusted, onClient, getDefaultCookieAttributes} from '../utils'
import {
getParentOrigin,
isOriginTrusted,
onClient,
getDefaultCookieAttributes,
isAbsoluteUrl
} from '../utils'
import {
MOBIFY_PATH,
SLAS_PRIVATE_PROXY_PATH,
Expand All @@ -42,6 +48,7 @@ interface AuthConfig extends ApiClientConfigParams {
silenceWarnings?: boolean
logger: Logger
defaultDnt?: boolean
callbackURI?: string
refreshTokenRegisteredCookieTTL?: number
refreshTokenGuestCookieTTL?: number
}
Expand All @@ -57,6 +64,12 @@ interface SlasJwtPayload extends JwtPayload {
dnt: string
}

type AuthorizeIDPParams = Parameters<Helpers['authorizeIDP']>[1]
type LoginIDPUserParams = Parameters<Helpers['loginIDPUser']>[2]
type AuthorizePasswordlessParams = Parameters<Helpers['authorizePasswordless']>[2]
type LoginPasswordlessParams = Parameters<Helpers['getPasswordLessAccessToken']>[2]
type LoginRegisteredUserB2CCredentials = Parameters<Helpers['loginRegisteredUserB2C']>[1]

/**
* The extended field is not from api response, we manually store the auth type,
* so we don't need to make another API call when we already have the data.
Expand All @@ -78,6 +91,7 @@ type AuthDataKeys =
| 'access_token_sfra'
| typeof DNT_COOKIE_NAME
| typeof DWSID_COOKIE_NAME
| 'code_verifier'

type AuthDataMap = Record<
AuthDataKeys,
Expand Down Expand Up @@ -173,6 +187,10 @@ const DATA_MAP: AuthDataMap = {
dwsid: {
storageType: 'cookie',
key: DWSID_COOKIE_NAME
},
code_verifier: {
storageType: 'local',
key: 'code_verifier'
}
}

Expand All @@ -198,6 +216,8 @@ class Auth {
private silenceWarnings: boolean
private logger: Logger
private defaultDnt: boolean | undefined
private isPrivate: boolean
private callbackURI: string
private refreshTokenRegisteredCookieTTL: number | undefined
private refreshTokenGuestCookieTTL: number | undefined
private refreshTrustedAgentHandler:
Expand All @@ -208,6 +228,7 @@ class Auth {
// Special endpoint for injecting SLAS private client secret.
const baseUrl = config.proxy.split(MOBIFY_PATH)[0]
const privateClientEndpoint = `${baseUrl}${SLAS_PRIVATE_PROXY_PATH}`
const callbackURI = config.callbackURI

this.client = new ShopperLogin({
proxy: config.enablePWAKitPrivateClient ? privateClientEndpoint : config.proxy,
Expand Down Expand Up @@ -285,6 +306,14 @@ class Auth {
config.clientSecret || ''

this.silenceWarnings = config.silenceWarnings || false

this.isPrivate = !!this.clientSecret

this.callbackURI = callbackURI
? isAbsoluteUrl(callbackURI)
? callbackURI
: `${baseUrl}${callbackURI}`
: ''
}

get(name: AuthDataKeys) {
Expand Down Expand Up @@ -803,7 +832,7 @@ class Auth {
* A wrapper method for commerce-sdk-isomorphic helper: loginRegisteredUserB2C.
*
*/
async loginRegisteredUserB2C(credentials: Parameters<Helpers['loginRegisteredUserB2C']>[1]) {
async loginRegisteredUserB2C(credentials: LoginRegisteredUserB2CCredentials) {
if (this.clientSecret && onClient() && this.clientSecret !== SLAS_SECRET_PLACEHOLDER) {
this.logWarning(SLAS_SECRET_WARNING_MSG)
}
Expand Down Expand Up @@ -996,6 +1025,105 @@ class Auth {
return res
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: authorizeIDP.
*
*/
async authorizeIDP(parameters: AuthorizeIDPParams) {
const redirectURI = this.redirectURI
const usid = this.get('usid')
const {url, codeVerifier} = await helpers.authorizeIDP(
this.client,
{
redirectURI,
hint: parameters.hint,
...(usid && {usid})
},
this.isPrivate
)
if (onClient()) {
window.location.assign(url)
} else {
console.warn('Something went wrong, this client side method is invoked on the server.')
}
this.set('code_verifier', codeVerifier)
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: loginIDPUser.
*
*/
async loginIDPUser(parameters: LoginIDPUserParams) {
const codeVerifier = this.get('code_verifier')
const code = parameters.code
const usid = parameters.usid
const redirectURI = parameters.redirectURI || this.redirectURI

const token = await helpers.loginIDPUser(
this.client,
{
codeVerifier,
clientSecret: this.clientSecret
},
{
redirectURI,
code,
...(usid && {usid})
}
)
const isGuest = false
this.handleTokenResponse(token, isGuest)
// Delete the code verifier once the user has logged in
this.delete('code_verifier')
if (onClient()) {
void this.clearECOMSession()
}
return token
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: authorizePasswordless.
*/
async authorizePasswordless(parameters: AuthorizePasswordlessParams) {
const userid = parameters.userid
const callbackURI = this.callbackURI
const mode = callbackURI ? 'callback' : 'sms'

await helpers.authorizePasswordless(
this.client,
{
clientSecret: this.clientSecret
},
{
...(callbackURI && {callbackURI: callbackURI}),
userid,
mode: mode
}
)
}

/**
* A wrapper method for commerce-sdk-isomorphic helper: getPasswordLessAccessToken.
*/
async getPasswordLessAccessToken(parameters: LoginPasswordlessParams) {
const pwdlessLoginToken = parameters.pwdlessLoginToken
const token = await helpers.getPasswordLessAccessToken(
this.client,
{
clientSecret: this.clientSecret
},
{
pwdlessLoginToken
}
)
const isGuest = false
this.handleTokenResponse(token, isGuest)
if (onClient()) {
void this.clearECOMSession()
}
return token
}

/**
* Decode SLAS JWT and extract information such as customer id, usid, etc.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/commerce-sdk-react/src/hooks/useAuthHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {updateCache} from './utils'
* @enum
*/
export const AuthHelpers = {
AuthorizePasswordless: 'authorizePasswordless',
LoginPasswordlessUser: 'getPasswordLessAccessToken',
AuthorizeIDP: 'authorizeIDP',
LoginIDPUser: 'loginIDPUser',
LoginGuestUser: 'loginGuestUser',
LoginRegisteredUserB2C: 'loginRegisteredUserB2C',
Logout: 'logout',
Expand Down Expand Up @@ -53,6 +57,8 @@ type CacheUpdateMatrix = {
* For more, see https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/#public-client-shopper-login-helpers
*
* Avaliable helpers:
* - authorizeIDP
* - loginIDPUser
* - loginRegisteredUserB2C
* - loginGuestUser
* - logout
Expand Down
3 changes: 3 additions & 0 deletions packages/commerce-sdk-react/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
silenceWarnings?: boolean
logger?: Logger
defaultDnt?: boolean
callbackURI?: string
refreshTokenRegisteredCookieTTL?: number
refreshTokenGuestCookieTTL?: number
}
Expand Down Expand Up @@ -123,6 +124,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
silenceWarnings,
logger,
defaultDnt,
callbackURI,
refreshTokenRegisteredCookieTTL,
refreshTokenGuestCookieTTL
} = props
Expand Down Expand Up @@ -241,6 +243,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
silenceWarnings,
logger: configLogger,
defaultDnt,
callbackURI,
refreshTokenRegisteredCookieTTL,
refreshTokenGuestCookieTTL
}}
Expand Down
18 changes: 18 additions & 0 deletions packages/commerce-sdk-react/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* 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 * as utils from './utils'

describe('Utils', () => {
test.each([
['/callback', false],
['https://pwa-kit.mobify-storefront.com/callback', true],
['/social-login/callback', false]
])('isAbsoluteUrl', (url, expected) => {
const isURL = utils.isAbsoluteUrl(url)
expect(isURL).toBe(expected)
})
})
Loading
Loading