From ad98ce29654d997b08b6c910a5ac4215b274ce7e Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Thu, 21 Mar 2024 15:02:07 +0100 Subject: [PATCH] feat: add an helper to decode the JWT --- examples/bun/index.ts | 8 +- examples/esm/index.js | 20 +- .../js-auth/{specs/types => }/global.d.ts | 0 packages/js-auth/specs/provisioning.spec.ts | 24 -- .../core.spec.ts => src/authenticate.spec.ts} | 37 ++- packages/js-auth/src/index.ts | 12 +- packages/js-auth/src/jwtDecode.spec.ts | 254 ++++++++++++++++++ packages/js-auth/src/jwtDecode.ts | 166 ++++++++++++ 8 files changed, 483 insertions(+), 38 deletions(-) rename packages/js-auth/{specs/types => }/global.d.ts (100%) delete mode 100644 packages/js-auth/specs/provisioning.spec.ts rename packages/js-auth/{specs/core.spec.ts => src/authenticate.spec.ts} (83%) create mode 100644 packages/js-auth/src/jwtDecode.spec.ts create mode 100644 packages/js-auth/src/jwtDecode.ts diff --git a/examples/bun/index.ts b/examples/bun/index.ts index 40a72b6..bcaf0fb 100644 --- a/examples/bun/index.ts +++ b/examples/bun/index.ts @@ -1,4 +1,4 @@ -import { authenticate, AuthenticateOptions, GrantType } from '@commercelayer/js-auth' +import { authenticate, AuthenticateOptions, GrantType, jwtDecode, jwtIsSalesChannel } from '@commercelayer/js-auth' const grantType: GrantType = 'client_credentials' @@ -10,3 +10,9 @@ const options: AuthenticateOptions<'client_credentials'> = { const auth = await authenticate(grantType, options) console.log(auth) + +const parsedJWT = jwtDecode(auth.accessToken) + +if (jwtIsSalesChannel(parsedJWT.payload)) { + console.log(parsedJWT.payload) +} diff --git a/examples/esm/index.js b/examples/esm/index.js index 501c008..79138b7 100644 --- a/examples/esm/index.js +++ b/examples/esm/index.js @@ -1,14 +1,16 @@ // @ts-check -import { authenticate } from '@commercelayer/js-auth' +import { authenticate, jwtDecode, jwtIsSalesChannel } from '@commercelayer/js-auth' -async function run() { - const auth = await authenticate('client_credentials', { - clientId: 'BISG8bb3GWpC8_D7Nt1SuWWdieS5bJq831A50LgB_Ig', - scope: 'market:id:KoaJYhMVVj' - }) +const auth = await authenticate('client_credentials', { + clientId: 'BISG8bb3GWpC8_D7Nt1SuWWdieS5bJq831A50LgB_Ig', + scope: 'stock_location:id:DGzAouppwn' +}) - console.log(auth) -} +console.log(auth) + +const parsedJWT = jwtDecode(auth.accessToken) -run() +if (jwtIsSalesChannel(parsedJWT.payload)) { + console.log(parsedJWT.payload) +} diff --git a/packages/js-auth/specs/types/global.d.ts b/packages/js-auth/global.d.ts similarity index 100% rename from packages/js-auth/specs/types/global.d.ts rename to packages/js-auth/global.d.ts diff --git a/packages/js-auth/specs/provisioning.spec.ts b/packages/js-auth/specs/provisioning.spec.ts deleted file mode 100644 index 99ac103..0000000 --- a/packages/js-auth/specs/provisioning.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { authenticate } from '../src/index.js' - -const clientId = process.env.VITE_TEST_PROVISIONING_CLIENT_ID -const clientSecret = process.env.VITE_TEST_PROVISIONING_CLIENT_SECRET -const domain = process.env.VITE_TEST_PROVISIONING_DOMAIN - -describe('Provisioning', () => { - it('Get a provisioning token', async () => { - const res = await authenticate('client_credentials', { - domain, - clientId, - clientSecret - }) - - expect(res).toHaveProperty('accessToken') - expect(res).toHaveProperty('tokenType') - expect(res).toHaveProperty('expiresIn') - expect(res).toHaveProperty('scope') - expect(res).toHaveProperty('createdAt') - expect(res).toHaveProperty('expires') - expect(res.expires).toBeInstanceOf(Date) - expect(res.expires.getTime()).toBeGreaterThan(Date.now()) - }) -}) diff --git a/packages/js-auth/specs/core.spec.ts b/packages/js-auth/src/authenticate.spec.ts similarity index 83% rename from packages/js-auth/specs/core.spec.ts rename to packages/js-auth/src/authenticate.spec.ts index 2dd85d3..8e3d328 100644 --- a/packages/js-auth/specs/core.spec.ts +++ b/packages/js-auth/src/authenticate.spec.ts @@ -1,4 +1,4 @@ -import { authenticate } from '../src/index.js' +import { authenticate } from './index.js' const clientId = process.env.VITE_TEST_CLIENT_ID const integrationClientId = process.env.VITE_TEST_INTEGRATION_CLIENT_ID @@ -8,12 +8,13 @@ const scope = process.env.VITE_TEST_SCOPE const username = process.env.VITE_TEST_USERNAME const password = process.env.VITE_TEST_PASSWORD -describe('Authentication', () => { +describe('Organization auth', () => { it('Get a sales channel token', async () => { const res = await authenticate('client_credentials', { clientId, domain }) + expect(res).toHaveProperty('accessToken') expect(res).toHaveProperty('tokenType') expect(res).toHaveProperty('expiresIn') @@ -23,12 +24,14 @@ describe('Authentication', () => { expect(res.expires).toBeInstanceOf(Date) expect(res.expires.getTime()).toBeGreaterThan(Date.now()) }) + it('Get an error requesting a sales channel token', async () => { const res = await authenticate('client_credentials', { clientId: 'wrong-client-id', domain, scope }) + expect(res).toHaveProperty('errors') expect(res.errors).toBeInstanceOf(Array) expect(res.errors?.[0]).toMatchObject({ @@ -38,24 +41,28 @@ describe('Authentication', () => { status: 401, title: 'invalid_client' }) + expect(res).not.toHaveProperty('accessToken') expect(res).not.toHaveProperty('tokenType') expect(res).not.toHaveProperty('expiresIn') expect(res).not.toHaveProperty('scope') expect(res).not.toHaveProperty('createdAt') }) + it('Get a integration token', async () => { const res = await authenticate('client_credentials', { clientId: integrationClientId, clientSecret, domain }) + expect(res).toHaveProperty('accessToken') expect(res).toHaveProperty('tokenType') expect(res).toHaveProperty('expiresIn') expect(res).toHaveProperty('scope') expect(res).toHaveProperty('createdAt') }) + it('Get a customer token', async () => { const res = await authenticate('password', { clientId, @@ -64,6 +71,7 @@ describe('Authentication', () => { password, scope }) + expect(res).toHaveProperty('accessToken') expect(res).toHaveProperty('tokenType') expect(res).toHaveProperty('expiresIn') @@ -73,6 +81,7 @@ describe('Authentication', () => { expect(res).toHaveProperty('ownerType') expect(res).toHaveProperty('refreshToken') }) + it('Refresh a customer token', async () => { const res = await authenticate('password', { clientId, @@ -81,6 +90,7 @@ describe('Authentication', () => { password, scope }) + expect(res).toHaveProperty('accessToken') expect(res).toHaveProperty('tokenType') expect(res).toHaveProperty('expiresIn') @@ -89,12 +99,14 @@ describe('Authentication', () => { expect(res).toHaveProperty('ownerId') expect(res).toHaveProperty('ownerType') expect(res).toHaveProperty('refreshToken') + const res2 = await authenticate('refresh_token', { clientId, domain, refreshToken: res.refreshToken, scope }) + expect(res2).toHaveProperty('accessToken') expect(res2).toHaveProperty('tokenType') expect(res2).toHaveProperty('expiresIn') @@ -104,6 +116,7 @@ describe('Authentication', () => { expect(res2).toHaveProperty('ownerType') expect(res2).toHaveProperty('refreshToken') }) + it('Set a custom header', async () => { const res = await authenticate('password', { clientId, @@ -115,6 +128,7 @@ describe('Authentication', () => { 'X-My-Header': 'My-Value' } }) + expect(res).toHaveProperty('accessToken') expect(res).toHaveProperty('tokenType') expect(res).toHaveProperty('expiresIn') @@ -125,3 +139,22 @@ describe('Authentication', () => { expect(res).toHaveProperty('refreshToken') }) }) + +describe('Provisioning auth', () => { + it('Get a provisioning token', async () => { + const res = await authenticate('client_credentials', { + domain: process.env.VITE_TEST_PROVISIONING_DOMAIN, + clientId: process.env.VITE_TEST_PROVISIONING_CLIENT_ID, + clientSecret: process.env.VITE_TEST_PROVISIONING_CLIENT_SECRET + }) + + expect(res).toHaveProperty('accessToken') + expect(res).toHaveProperty('tokenType') + expect(res).toHaveProperty('expiresIn') + expect(res).toHaveProperty('scope') + expect(res).toHaveProperty('createdAt') + expect(res).toHaveProperty('expires') + expect(res.expires).toBeInstanceOf(Date) + expect(res.expires.getTime()).toBeGreaterThan(Date.now()) + }) +}) diff --git a/packages/js-auth/src/index.ts b/packages/js-auth/src/index.ts index 85e58a3..aeb3958 100644 --- a/packages/js-auth/src/index.ts +++ b/packages/js-auth/src/index.ts @@ -1,7 +1,15 @@ export { authenticate } from './authenticate.js' +export { + jwtDecode, + jwtIsDashboard, + jwtIsIntegration, + jwtIsProvisioning, + jwtIsSalesChannel, + jwtIsWebApp +} from './jwtDecode.js' export type { - GrantType, AuthenticateOptions, - AuthenticateReturn + AuthenticateReturn, + GrantType } from './types/index.js' diff --git a/packages/js-auth/src/jwtDecode.spec.ts b/packages/js-auth/src/jwtDecode.spec.ts new file mode 100644 index 0000000..202a64f --- /dev/null +++ b/packages/js-auth/src/jwtDecode.spec.ts @@ -0,0 +1,254 @@ +import { jwtDecode } from './jwtDecode.js' + +describe('jwtDecode', () => { + it('should be able to parse a "dashboard" access token.', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJ1c2VyIjp7ImlkIjoiZ2Jsb3dTeVZlcSJ9LCJhcHBsaWNhdGlvbiI6eyJpZCI6Im5HVnFhaWxWeU4iLCJraW5kIjoiZGFzaGJvYXJkIiwicHVibGljIjpmYWxzZX0sInNjb3BlIjoicHJvdmlzaW9uaW5nLWFwaSBtZXRyaWNzLWFwaSIsImV4cCI6MTcxMDk3NjY5NSwidGVzdCI6ZmFsc2UsInJhbmQiOjAuNzY1MjgwMDc2MDY1MjMwNywiaWF0IjoxNzEwOTY5NDk1LCJpc3MiOiJodHRwczovL2NvbW1lcmNlbGF5ZXIuaW8ifQ.YYk1PRFa8zcAlus8uaDFcJF7FRBtXYz-h--OYyuxJ0pc_qG0jdZ7lNgKxZC0Xnb4f9QmO3nHC4b4leGm6aAw8Yw4atZZaEDEkPrlG-ZegtdM4_X2Wbeul_Swkxo91PCIkYRMue0tl-zwl3dH_bS48IGOgOCbNWIcuHFvILaN_oXOHaeGfbVY5zXFfMK8P77TWZEoK0BYvmXIv2o_x_uYQZVcev7sSy1aX2zkikMFu54PIDl-II94ETT2g51QgNglDVh64qIFRvb24uPZo3woEBtd4ogupMRY5c3BvbxtfKHeASjT2NMxSkg-J55V7L4Wv5Q3Oh5p7ePz-95n7lG7uQ' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + user: { + id: 'gblowSyVeq' + }, + application: { + id: 'nGVqailVyN', + kind: 'dashboard', + public: false + }, + scope: 'provisioning-api metrics-api', + exp: 1710976695, + test: false, + rand: 0.7652800760652307, + iat: 1710969495, + iss: 'https://commercelayer.io' + } + }) + }) + + it('should be able to parse a "provisioning" access token (`client_credentials` grant type).', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJ1c2VyIjp7ImlkIjoiZ2Jsb3dTeVZlcSJ9LCJhcHBsaWNhdGlvbiI6eyJpZCI6Im5HVnFhaWxWeU4iLCJraW5kIjoidXNlciIsInB1YmxpYyI6ZmFsc2V9LCJzY29wZSI6InByb3Zpc2lvbmluZy1hcGkiLCJleHAiOjE3MTA5NTA0MzAsInRlc3QiOmZhbHNlLCJyYW5kIjowLjY2OTAzODQ1MzY1NjE5MzMsImlhdCI6MTcxMDk0MzIzMCwiaXNzIjoiaHR0cHM6Ly9jb21tZXJjZWxheWVyLmlvIn0.NB1PVDXU-CbatAkOKUyKDVo4e1YrracajM1JXUZCnDqGPyD2oMzCQC9ztqtOXrbvlV3FHIWm0yzd8yQvKokFjvPDDH9TvfuWgi_hFN-Dh_7IZBj0tUBfUmF694QfrUOoRfX5OX-jBkRk0IrlYUi2WleiilkSbTV9YdAiLNDWFA1MjeK7YS-QLzrrYL6RsUcII4qrDb7UZZOWiZiXTbZ1HFiSZacrZfu3Eu1BGKVUl8ZhhgYOJ1mCPlVmqn4OTnMfZby8M8Jvo3z7HDbC1-lCWMhoQ7o_PH-duA4DnaMyVrchw1S_3aSmVx6rWykvZ80d9Qz-8oSvqZwhkmnMFvUKvQ' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + user: { + id: 'gblowSyVeq' + }, + application: { + id: 'nGVqailVyN', + kind: 'user', + public: false + }, + scope: 'provisioning-api', + exp: 1710950430, + test: false, + rand: 0.6690384536561933, + iat: 1710943230, + iss: 'https://commercelayer.io' + } + }) + }) + + it('should be able to parse a "sales_channel" access token (`client_credentials` grant type).', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJlbldveEZNT25wIiwic2x1ZyI6InRoZS1ibHVlLWJyYW5kLTMiLCJlbnRlcnByaXNlIjpmYWxzZSwicmVnaW9uIjoiZXUtd2VzdC0xIn0sImFwcGxpY2F0aW9uIjp7ImlkIjoibkdWcWFpRVlOQSIsImtpbmQiOiJzYWxlc19jaGFubmVsIiwicHVibGljIjp0cnVlfSwic2NvcGUiOiJtYXJrZXQ6YWxsIiwiZXhwIjoxNzEwOTU3NjMwLCJ0ZXN0Ijp0cnVlLCJyYW5kIjowLjM0Nzg4MjA0NDE1NDI0Njk1LCJpYXQiOjE3MTA5NDMyMzAsImlzcyI6Imh0dHBzOi8vY29tbWVyY2VsYXllci5pbyJ9.XhcMqwVwAh5wP2rbNyODBuPpTicwJjvK09KPmhe3nM1Lg0Hp0bIEQIOS81ohLyLE9ecaRH_7CsfLkAqYmRtdOTAKu9m4xvWw-Z_hBQpY67FTaikInMVltNffLvNDe5qmleNi5jnXtJl_yEGhtlDydpFBx1x8u3ofgtZSPFWm3Tl4KQoxFxT8CnnxPd2LTW_PfvnqS3QEGgvVnEXSTnJ41EU4dB8c9cZmmJY6e9SeH9fHVd469N_ipP4bymIL7kLPpkBBDuxxZ0787dOblGI31geAW-hHGbCpnj4_i5WJAVVsj_ImBtqt9Bihc-O-iHIMJlVOzWWXTAnmWGsHiwL5Ag' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + organization: { + id: 'enWoxFMOnp', + slug: 'the-blue-brand-3', + enterprise: false, + region: 'eu-west-1' + }, + application: { + id: 'nGVqaiEYNA', + kind: 'sales_channel', + public: true + }, + scope: 'market:all', + exp: 1710957630, + test: true, + rand: 0.34788204415424695, + iat: 1710943230, + iss: 'https://commercelayer.io' + } + }) + }) + + it('should be able to parse an "integration" access token (`client_credentials` grant type).', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJlbldveEZNT25wIiwic2x1ZyI6InRoZS1ibHVlLWJyYW5kLTMiLCJlbnRlcnByaXNlIjpmYWxzZSwicmVnaW9uIjoiZXUtd2VzdC0xIn0sImFwcGxpY2F0aW9uIjp7ImlkIjoiZE1uV21pZ2FwYiIsImtpbmQiOiJpbnRlZ3JhdGlvbiIsInB1YmxpYyI6ZmFsc2V9LCJzY29wZSI6Im1hcmtldDphbGwiLCJleHAiOjE3MTA5NTA0MzAsInRlc3QiOnRydWUsInJhbmQiOjAuMjE0Njc5MzMzMDQ0NTc2NzYsImlhdCI6MTcxMDk0MzIzMCwiaXNzIjoiaHR0cHM6Ly9jb21tZXJjZWxheWVyLmlvIn0.dkTswP2_lUwsKDoR8ogdCeGHgoY2zsVAl3kvX8nVBnQJh5N7ODCBGa3Rjy0MkUna7ufiAjUbuSAW8Crm8Jli3eh1WPq3nEkzjnlz8N9syRBgdKwkS46Z-3ZCEdRTBikkF38GAQIDhCqZ6ar6hIdm-6FRxDxSCzQ0zcruJc9g8EwXAO4BhvPOAw3gZ6O2uiLlQSxH3dAqg0qWehMhtZODMtFngBh38pOWbO3tRk1ojyfUq1Ckow8NyVPQa38suIf1wlrKkyKS3okP1WsN2ux7kVn8cXZ3uaP9rsKM82wICYgXfmlcxA-6AlKfgZ5ExCsCOVfubwSy8tUAGp_EPGDr9w' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + organization: { + id: 'enWoxFMOnp', + slug: 'the-blue-brand-3', + enterprise: false, + region: 'eu-west-1' + }, + application: { + id: 'dMnWmigapb', + kind: 'integration', + public: false + }, + scope: 'market:all', + exp: 1710950430, + test: true, + rand: 0.21467933304457676, + iat: 1710943230, + iss: 'https://commercelayer.io' + } + }) + }) + + it('should be able to parse a "customer" access token (`password` grant type).', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJlbldveEZNT25wIiwic2x1ZyI6InRoZS1ibHVlLWJyYW5kLTMiLCJlbnRlcnByaXNlIjpmYWxzZSwicmVnaW9uIjoiZXUtd2VzdC0xIn0sImFwcGxpY2F0aW9uIjp7ImlkIjoibkdWcWFpRVlOQSIsImtpbmQiOiJzYWxlc19jaGFubmVsIiwicHVibGljIjp0cnVlfSwibWFya2V0Ijp7ImlkIjpbIkJqeHJKaHltbE0iXSwicHJpY2VfbGlzdF9pZCI6IlZCeVZwQ2d2a2ciLCJzdG9ja19sb2NhdGlvbl9pZHMiOlsieEdYQlh1ckRNRSIsImRNcVh5dVZWa04iXSwiZ2VvY29kZXJfaWQiOm51bGwsImFsbG93c19leHRlcm5hbF9wcmljZXMiOmZhbHNlfSwib3duZXIiOnsiaWQiOiJnT3F6Wmhacm1RIiwidHlwZSI6IkN1c3RvbWVyIn0sInNjb3BlIjoibWFya2V0OjU4IiwiZXhwIjoxNzEwOTU3NjMwLCJ0ZXN0Ijp0cnVlLCJyYW5kIjowLjkzMzMzODQ4MDYzNTE4OSwiaWF0IjoxNzEwOTQzMjMwLCJpc3MiOiJodHRwczovL2NvbW1lcmNlbGF5ZXIuaW8ifQ.sqtQx3MaI8G6KlhlTctICv5S2ER_gfd1UYi9kNsFOIDNO2pF5bvClhXke8lVZ_FCRq6ogDhirsPH6XfIETdfJmURXlqBwUm1mR3Q_QHbrjurMSbbAnx24W5r1-MAFbRLLErHN7ceVaD75wq3Z7A0VWQIdMtiI1z-k6fUcKyvRmqvONCk_kSKFFXxbofsdwOWKqqjQfc_VnzLNwZDIXKCetK2kB_JI3eGZWhqVXKQI7kWfqb9tCuomXAjUeFkzRKaw0KPiT0YMRBtkb93LOJbCnYwQ0B2l9UY-8e5sl22rqUcqg1CTm4s7hNDswZ_16MPBi6zqOcD5q6ywNI5Xg2BaA' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + organization: { + id: 'enWoxFMOnp', + slug: 'the-blue-brand-3', + enterprise: false, + region: 'eu-west-1' + }, + application: { + id: 'nGVqaiEYNA', + kind: 'sales_channel', + public: true + }, + market: { + id: ['BjxrJhymlM'], + price_list_id: 'VByVpCgvkg', + stock_location_ids: ['xGXBXurDME', 'dMqXyuVVkN'], + geocoder_id: null, + allows_external_prices: false + }, + owner: { + id: 'gOqzZhZrmQ', + type: 'Customer' + }, + scope: 'market:58', + exp: 1710957630, + test: true, + rand: 0.933338480635189, + iat: 1710943230, + iss: 'https://commercelayer.io' + } + }) + }) + + it('should be able to parse a "refreshed customer" access token (`refresh_token` grant type).', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJlbldveEZNT25wIiwic2x1ZyI6InRoZS1ibHVlLWJyYW5kLTMiLCJlbnRlcnByaXNlIjpmYWxzZSwicmVnaW9uIjoiZXUtd2VzdC0xIn0sImFwcGxpY2F0aW9uIjp7ImlkIjoibkdWcWFpRVlOQSIsImtpbmQiOiJzYWxlc19jaGFubmVsIiwicHVibGljIjp0cnVlfSwibWFya2V0Ijp7ImlkIjpbIkJqeHJKaHltbE0iXSwicHJpY2VfbGlzdF9pZCI6IlZCeVZwQ2d2a2ciLCJzdG9ja19sb2NhdGlvbl9pZHMiOlsieEdYQlh1ckRNRSIsImRNcVh5dVZWa04iXSwiZ2VvY29kZXJfaWQiOm51bGwsImFsbG93c19leHRlcm5hbF9wcmljZXMiOmZhbHNlfSwib3duZXIiOnsiaWQiOiJnT3F6Wmhacm1RIiwidHlwZSI6IkN1c3RvbWVyIn0sInNjb3BlIjoibWFya2V0OjU4IiwiZXhwIjoxNzEwOTU3NjMxLCJ0ZXN0Ijp0cnVlLCJyYW5kIjowLjQ2MjkxNTk5ODc5NDI4OTYsImlhdCI6MTcxMDk0MzIzMSwiaXNzIjoiaHR0cHM6Ly9jb21tZXJjZWxheWVyLmlvIn0.MBKSLLzxiclK7yf9ZfygrkjAgplFtJ8MSru0wgiE4qp1WUd-n3sv5T-gu26rT-wOz9bzRAstA83JopiYAUnr6ztrf2P98Ijj9bxLO02wIa1d_MMRC-ThRFM-aeoecGSfCdy3otpIBP4gB2ZhgaZ33kRnoGY9Upf-KlI5WiTrMKKHI5DhBCjfNeZZdET2En7gaK_q02_px21Tu-Txw6X_05QSJjeGC4Z2qwN_bbkO8V_8d8jMkRJZpJonoLgvQwvVzVKzvpgBMXz7hSv4XHfpmt3lgHsmLabhSCWlH32isSA-bE-PRCVXyBXTSER-bQyJfER3p3s26tIQfCB_waNE6A' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + organization: { + id: 'enWoxFMOnp', + slug: 'the-blue-brand-3', + enterprise: false, + region: 'eu-west-1' + }, + application: { + id: 'nGVqaiEYNA', + kind: 'sales_channel', + public: true + }, + market: { + id: ['BjxrJhymlM'], + price_list_id: 'VByVpCgvkg', + stock_location_ids: ['xGXBXurDME', 'dMqXyuVVkN'], + geocoder_id: null, + allows_external_prices: false + }, + owner: { + id: 'gOqzZhZrmQ', + type: 'Customer' + }, + scope: 'market:58', + exp: 1710957631, + test: true, + rand: 0.4629159987942896, + iat: 1710943231, + iss: 'https://commercelayer.io' + } + }) + }) + + it('should be able to parse a "webapp" access token (`authorization_code` grant type).', () => { + const accessToken = + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYTRjYzYyOGQxZmNlM2ZiOTNhM2VlNTU4MjZlNDFjZmFmMThkYzJkZmYzYjA3MjIyNzQwMzgwZTkxOTlkNWQifQ.eyJvcmdhbml6YXRpb24iOnsiaWQiOiJlbldveEZNT25wIiwic2x1ZyI6InRoZS1ibHVlLWJyYW5kLTMiLCJlbnRlcnByaXNlIjpmYWxzZSwicmVnaW9uIjoiZXUtd2VzdC0xIn0sImFwcGxpY2F0aW9uIjp7ImlkIjoiR1J2RGlySmxERyIsImtpbmQiOiJ3ZWJhcHAiLCJwdWJsaWMiOmZhbHNlfSwibWFya2V0Ijp7ImlkIjpbIkJqeHJKaHltbE0iXSwicHJpY2VfbGlzdF9pZCI6IlZCeVZwQ2d2a2ciLCJzdG9ja19sb2NhdGlvbl9pZHMiOlsieEdYQlh1ckRNRSIsImRNcVh5dVZWa04iXSwiZ2VvY29kZXJfaWQiOm51bGwsImFsbG93c19leHRlcm5hbF9wcmljZXMiOmZhbHNlfSwib3duZXIiOnsiaWQiOiJnYmxvd1N5VmVxIiwidHlwZSI6IlVzZXIifSwic2NvcGUiOiJtYXJrZXQ6aWQ6cm9iQW5oUGRHbCIsImV4cCI6MTcxMDk3NDAxOSwidGVzdCI6dHJ1ZSwicmFuZCI6MC4wNjAxMTgxNTQ5MzE0MjM5MiwiaWF0IjoxNzEwOTY2ODE5LCJpc3MiOiJodHRwczovL2NvbW1lcmNlbGF5ZXIuaW8ifQ.O89Y7MyHci-y63HcRjZoEwqHLiQ_Gs3YuawJ7M1O3D96vPpGKeymPK7on05Nq3WPCfFb7CgdFP1sgYj9ZaFq2-TCbJDyhYjbP5ewBfhYqdOyeIXerlm5tpnAEXpIPvMLL6yTdhQCj1aRe2mVVWIdxdA1-jfuXhGVYftVr68gWiQ-QGeacgPCyItcrePcsyRwWhg98zVEWradBMXc6olzSxBPNfflXEFc-A4sbfXbwyLvdB_TjoNjjF2KRGnNZX6LaB3oUPaDQN96QZs9ONjCJ49ttFmcHku3dqR60vhKU4Yo9JAi70xJAsIEjCn9pQ3Wnlwza6fkQHe-xBTeDxHFWw' + + expect(jwtDecode(accessToken)).toStrictEqual({ + header: { + alg: 'RS512', + typ: 'JWT', + kid: 'aba4cc628d1fce3fb93a3ee55826e41cfaf18dc2dff3b07222740380e9199d5d' + }, + payload: { + organization: { + id: 'enWoxFMOnp', + slug: 'the-blue-brand-3', + enterprise: false, + region: 'eu-west-1' + }, + application: { + id: 'GRvDirJlDG', + kind: 'webapp', + public: false + }, + market: { + id: ['BjxrJhymlM'], + price_list_id: 'VByVpCgvkg', + stock_location_ids: ['xGXBXurDME', 'dMqXyuVVkN'], + geocoder_id: null, + allows_external_prices: false + }, + owner: { + id: 'gblowSyVeq', + type: 'User' + }, + scope: 'market:id:robAnhPdGl', + exp: 1710974019, + test: true, + rand: 0.06011815493142392, + iat: 1710966819, + iss: 'https://commercelayer.io' + } + }) + }) +}) diff --git a/packages/js-auth/src/jwtDecode.ts b/packages/js-auth/src/jwtDecode.ts new file mode 100644 index 0000000..7b49323 --- /dev/null +++ b/packages/js-auth/src/jwtDecode.ts @@ -0,0 +1,166 @@ +/** + * Decode a Commerce Layer access token. + */ +export function jwtDecode(accessToken: string): CommerceLayerJWT { + const [header, payload] = accessToken.split('.') + + return { + header: JSON.parse(header != null ? atob(header) : 'null'), + payload: JSON.parse(payload != null ? atob(payload) : 'null') + } +} + +/** + * The `atob()` function decodes a string of data + * which has been encoded using [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64) encoding. + * + * This method works both in Node.js and browsers. + * + * @link [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/atob) + * @param encodedData A binary string (i.e., a string in which each character in the string is treated as a byte of binary data) containing base64-encoded data. + * @returns An ASCII string containing decoded data from `encodedData`. + */ +function atob(encodedData: string): string { + if (typeof window !== 'undefined') { + return window.atob(encodedData) + } + + return Buffer.from(encodedData, 'base64').toString('binary') +} + +interface CommerceLayerJWT { + /** The header typically consists of two parts: the type of the token, which is JWT, and the signing algorithm being used, such as HMAC SHA256 or RSA. */ + header: { + /** Signing algorithm being used (e.g. `HMAC`, `SHA256`, `RSA`, `RS512`). */ + alg: string + /** Type of the token (usually `JWT`). */ + typ?: string + /** Key ID */ + kid?: string + } + + payload: Payload +} + +type Payload = + | JWTProvisioning + | JWTDashboard + | JWTIntegration + | JWTSalesChannel + | JWTWebApp + +interface JWTBase { + /** The type of credentials you're using to authenticate to the APIs. */ + application: { + id: string + public: boolean + } + + /** Scope used to restrict access to a specific active market and/or stock location. */ + scope: string + /** The token expiration time, expressed as an [epoch](https://www.epoch101.com/). */ + exp: number + /** The environment type (true for test mode, false for live mode). */ + test: boolean + /** A randomly generated number, less than one. */ + rand: number + /** Issued at (seconds since Unix epoch). */ + iat: number + /** Who created and signed this token (e.g. `"https://commercelayer.io"`). */ + iss: string +} + +type JWTProvisioning = JWTBase & { + /** The type of credentials you're using to authenticate to the APIs. */ + application: { + kind: 'user' + } + /** The user authenticating to the Provisioning API */ + user: { + id: string + } +} + +type JWTDashboard = JWTBase & { + /** The type of credentials you're using to authenticate to the APIs. */ + application: { + kind: 'dashboard' + } + /** The user authenticating to the Dashboard */ + user: { + id: string + } +} + +type JWTCoreBase = JWTBase & { + /** The organization's unique ID. */ + organization: { + id: string + slug: string + enterprise: boolean + region: string + } + /** The market(s) in scope. */ + market?: { + id: string[] + price_list_id: string + stock_location_ids: string[] + geocoder_id: string | null + allows_external_prices: boolean + } +} + +type JWTWebApp = JWTCoreBase & { + /** The type of credentials you're using to authenticate to the APIs. */ + application: { + kind: 'webapp' + } + /** The owner (if any) authenticating to the APIs. */ + owner: { + id: string + type: 'User' + } +} + +type JWTSalesChannel = JWTCoreBase & { + /** The type of credentials you're using to authenticate to the APIs. */ + application: { + kind: 'sales_channel' + } + /** The owner (if any) authenticating to the APIs. */ + owner?: { + id: string + type: 'Customer' + } +} + +type JWTIntegration = JWTCoreBase & { + /** The type of credentials you're using to authenticate to the APIs. */ + application: { + kind: 'integration' + } +} + +export function jwtIsProvisioning( + payload: Payload +): payload is JWTProvisioning { + return payload.application.kind === 'user' +} + +export function jwtIsDashboard(payload: Payload): payload is JWTDashboard { + return payload.application.kind === 'dashboard' +} + +export function jwtIsIntegration(payload: Payload): payload is JWTIntegration { + return payload.application.kind === 'integration' +} + +export function jwtIsSalesChannel( + payload: Payload +): payload is JWTSalesChannel { + return payload.application.kind === 'sales_channel' +} + +export function jwtIsWebApp(payload: Payload): payload is JWTWebApp { + return payload.application.kind === 'webapp' +}