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

[commerce-sdk-react] Decode pre-fetched token and save auth data in storage #1052

Merged
merged 11 commits into from
Mar 13, 2023
74 changes: 55 additions & 19 deletions packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,52 @@ describe('Auth', () => {
expect(auth.ready()).resolves.toEqual(result)
})
test('ready - use `fetchedToken` and short circuit network request', async () => {
const auth = new Auth({...config, fetchedToken: 'fake-token'})
const fetchedToken = jwt.sign(
{
sub: `cc-slas::zzrf_001::scid:xxxxxx::usid:usid`,
isb: `uido:ecom::upn:[email protected]::uidn:firstname lastname::gcid:guestuserid::rcid:rcid::chid:siteId`
},
'secret'
)
const auth = new Auth({...config, fetchedToken})
jest.spyOn(auth, 'queueRequest')
await auth.ready().then(() => {
expect(auth.queueRequest).not.toHaveBeenCalled()
})
await auth.ready()
expect(auth.queueRequest).not.toHaveBeenCalled()
})
test('ready - use `fetchedToken` and auth data is populated for registered user', async () => {
const usid = 'usidddddd'
const customerId = 'customerIddddddd'
const fetchedToken = jwt.sign(
{
sub: `cc-slas::zzrf_001::scid:xxxxxx::usid:${usid}`,
isb: `uido:ecom::upn:[email protected]::uidn:firstname lastname::gcid:guestuserid::rcid:${customerId}::chid:siteId`
},
'secret'
)
const auth = new Auth({...config, fetchedToken})
await auth.ready()
expect(auth.get('access_token')).toBe(fetchedToken)
expect(auth.get('customer_id')).toBe(customerId)
expect(auth.get('usid')).toBe(usid)
expect(auth.get('customer_type')).toBe('registered')
})
test('ready - use `fetchedToken` and auth data is populated for guest user', async () => {
// isb: `uido:slas::upn:Guest::uidn:Guest User::gcid:bclrdGlbIZlHaRxHsZlWYYxHwZ::chid: `
const usid = 'usidddddd'
const customerId = 'customerIddddddd'
const fetchedToken = jwt.sign(
{
sub: `cc-slas::zzrf_001::scid:xxxxxx::usid:${usid}`,
isb: `uido:ecom::upn:Guest::uidn:firstname lastname::gcid:${customerId}::rcid:registeredCid::chid:siteId`
},
'secret'
)
const auth = new Auth({...config, fetchedToken})
await auth.ready()
expect(auth.get('access_token')).toBe(fetchedToken)
expect(auth.get('customer_id')).toBe(customerId)
expect(auth.get('usid')).toBe(usid)
expect(auth.get('customer_type')).toBe('guest')
})
test('ready - use refresh token when access token is expired', async () => {
const auth = new Auth(config)
Expand All @@ -220,34 +261,29 @@ describe('Auth', () => {
auth.set(key, data[key])
})

await auth.ready().then(() => {
expect(helpers.refreshAccessToken).toBeCalled()
})
await auth.ready()
expect(helpers.refreshAccessToken).toBeCalled()
})
test('ready - PKCE flow', async () => {
const auth = new Auth(config)

await auth.ready().then(() => {
expect(helpers.loginGuestUser).toBeCalled()
})
await auth.ready()
expect(helpers.loginGuestUser).toBeCalled()
})
test('loginGuestUser', async () => {
const auth = new Auth(config)
await auth.loginGuestUser().then(() => {
expect(helpers.loginGuestUser).toBeCalled()
})
await auth.loginGuestUser()
expect(helpers.loginGuestUser).toBeCalled()
})
test('loginRegisteredUserB2C', async () => {
const auth = new Auth(config)
await auth.loginRegisteredUserB2C({username: 'test', password: 'test'}).then(() => {
expect(helpers.loginRegisteredUserB2C).toBeCalled()
})
await auth.loginRegisteredUserB2C({username: 'test', password: 'test'})
expect(helpers.loginRegisteredUserB2C).toBeCalled()
})
test('logout', async () => {
const auth = new Auth(config)
await auth.logout().then(() => {
expect(helpers.loginGuestUser).toBeCalled()
})
await auth.logout()
expect(helpers.loginGuestUser).toBeCalled()
})
test('running on the server uses a shared context memory store', async () => {
const refreshTokenGuest = 'guest'
Expand Down
38 changes: 36 additions & 2 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
ShopperLoginTypes,
ShopperCustomersTypes
} from 'commerce-sdk-isomorphic'
import jwtDecode from 'jwt-decode'
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'
Expand All @@ -31,6 +31,11 @@ interface JWTHeaders {
iat: number
}

interface SlasJwtPayload extends JwtPayload {
sub: string
isb: string
}

/**
* 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 Down Expand Up @@ -288,7 +293,12 @@ class Auth {
*/
async ready() {
if (this.fetchedToken && this.fetchedToken !== '') {
Copy link
Contributor

Choose a reason for hiding this comment

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

If we have a fetchedToken, we will re-parse all the data every time we call .ready(). That doesn't seem like desired behavior. Not a big deal for our use case, testing, but we might want to avoid that if we want to make the feature usable for production use cases.

this.pendingToken = Promise.resolve({...this.data, access_token: this.fetchedToken})
const {isGuest, customerId, usid} = this.parseSlasJWT(this.fetchedToken)
this.set('access_token', this.fetchedToken)
this.set('customer_id', customerId)
this.set('usid', usid)
this.set('customer_type', isGuest ? 'guest' : 'registered')
this.pendingToken = Promise.resolve(this.data)
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the difference between fetchedToken and pendingToken and in what scenarios is each needed?

Copy link
Contributor

Choose a reason for hiding this comment

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

fetchedToken can be used if you have an existing token. pendingToken is the token that we request from the server, if fetchedToken is not provided. We primarily use fetchedToken to avoid needing to mock pendingToken in tests, but the property also enables users to have more control over auth flows, if that's something they want to do.

return this.pendingToken
}
if (this.pendingToken) {
Expand Down Expand Up @@ -405,6 +415,30 @@ class Auth {
this.clearStorage()
return this.loginGuestUser()
}

/**
* Decode SLAS JWT and extract information such as customer id, usid, etc.
*
*/
parseSlasJWT(jwt: string) {
const payload = jwtDecode(jwt) as SlasJwtPayload
const {sub, isb} = payload
// ISB format
// 'uido:ecom::upn:Guest||xxxEmailxxx::uidn:FirstName LastName::gcid:xxxGuestCustomerIdxxx::rcid:xxxRegisteredCustomerIdxxx::chid:xxxSiteIdxxx',
const isbParts = isb.split('::')
const isGuest = isbParts[1] === 'upn:Guest'
const customerId = isGuest
? isbParts[3].replace('gcid:', '')
: isbParts[4].replace('rcid:', '')
// SUB format
// cc-slas::zzrf_001::scid:c9c45bfd-0ed3-4aa2-xxxx-40f88962b836::usid:b4865233-de92-4039-xxxx-aa2dfc8c1ea5
const usid = sub.split('::')[3].replace('usid:', '')
return {
isGuest,
customerId,
usid
}
}
}

export default Auth