From 85e3c2d0c346ea14faa015e6c17709fe5d67c965 Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Fri, 22 Mar 2024 22:13:49 +0100 Subject: [PATCH 1/4] feat: add support to JWT Bearer --- package.json | 1 + packages/js-auth/package.json | 3 + packages/js-auth/src/index.ts | 3 + packages/js-auth/src/jwtDecode.ts | 20 +----- packages/js-auth/src/jwtEncode.spec.ts | 26 ++++++++ packages/js-auth/src/jwtEncode.ts | 60 ++++++++++++++++++ packages/js-auth/src/types/index.ts | 40 ++++++------ packages/js-auth/src/types/jwtBearer.ts | 37 +++++++++++ packages/js-auth/src/utils/base64.ts | 51 ++++++++++++++++ pnpm-lock.yaml | 81 +++++++++++++++++++++++++ 10 files changed, 287 insertions(+), 35 deletions(-) create mode 100644 packages/js-auth/src/jwtEncode.spec.ts create mode 100644 packages/js-auth/src/jwtEncode.ts create mode 100644 packages/js-auth/src/types/jwtBearer.ts create mode 100644 packages/js-auth/src/utils/base64.ts diff --git a/package.json b/package.json index 2fe435e..e14273b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "pnpm --filter js-auth build", "prepare": "husky install", "test": "pnpm --filter js-auth test", + "test:watch": "pnpm --filter js-auth test:watch", "make:version": "lerna version --no-private", "example:cjs": "pnpm --filter cjs start", "example:esm": "pnpm --filter esm start", diff --git a/packages/js-auth/package.json b/packages/js-auth/package.json index 028e5f1..d200d4f 100644 --- a/packages/js-auth/package.json +++ b/packages/js-auth/package.json @@ -35,6 +35,7 @@ "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", "test": "pnpm run lint && vitest run --silent", + "test:watch": "vitest --silent", "build": "tsup" }, "publishConfig": { @@ -42,7 +43,9 @@ }, "license": "MIT", "devDependencies": { + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.11.27", + "jsonwebtoken": "^9.0.2", "tsup": "^8.0.2", "typescript": "^5.4.2", "vite-tsconfig-paths": "^4.3.2", diff --git a/packages/js-auth/src/index.ts b/packages/js-auth/src/index.ts index e860cee..8b0d528 100644 --- a/packages/js-auth/src/index.ts +++ b/packages/js-auth/src/index.ts @@ -1,4 +1,5 @@ export { authenticate } from './authenticate.js' + export { jwtDecode, jwtIsDashboard, @@ -8,6 +9,8 @@ export { jwtIsWebApp } from './jwtDecode.js' +export { createAssertion } from './jwtEncode.js' + export type { AuthenticateOptions, AuthenticateReturn, diff --git a/packages/js-auth/src/jwtDecode.ts b/packages/js-auth/src/jwtDecode.ts index 90a3eb0..3afe9ac 100644 --- a/packages/js-auth/src/jwtDecode.ts +++ b/packages/js-auth/src/jwtDecode.ts @@ -1,3 +1,5 @@ +import { atob } from '#utils/base64.js' + /** * Decode a Commerce Layer access token. */ @@ -10,24 +12,6 @@ export function jwtDecode(accessToken: string): CommerceLayerJWT { } } -/** - * 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: { diff --git a/packages/js-auth/src/jwtEncode.spec.ts b/packages/js-auth/src/jwtEncode.spec.ts new file mode 100644 index 0000000..6dc9e4b --- /dev/null +++ b/packages/js-auth/src/jwtEncode.spec.ts @@ -0,0 +1,26 @@ +import jwt from 'jsonwebtoken' +import { createAssertion } from './jwtEncode.js' + +describe('createAssertion', () => { + it('should be able to parse a "dashboard" access token.', async () => { + const payload = { + 'https://commercelayer.io/claims': { + owner: { + type: 'User', + id: '1234' + }, + custom_claim: { + name: 'John' + } + } + } as const + + const jsonwebtokenAssertion = jwt.sign(payload, 'cl', { + algorithm: 'HS512' + }) + + const assertion = await createAssertion(payload) + + expect(assertion).toStrictEqual(jsonwebtokenAssertion) + }) +}) diff --git a/packages/js-auth/src/jwtEncode.ts b/packages/js-auth/src/jwtEncode.ts new file mode 100644 index 0000000..626f6b3 --- /dev/null +++ b/packages/js-auth/src/jwtEncode.ts @@ -0,0 +1,60 @@ +import { base64url } from '#utils/base64.js' + +interface Owner { + type: 'User' | 'Customer' + id: string +} + +export async function createAssertion(payload: { + 'https://commercelayer.io/claims': { + /** The customer or user you want to make the calls on behalf of. */ + owner: Owner + /** Any other information (key/value pairs) you want to enrich the token with. */ + custom_claim?: Record + } +}): Promise { + return await jwtEncode(payload, 'cl') +} + +async function jwtEncode( + payload: Record, + secret: string +): Promise { + const header = { alg: 'HS512', typ: 'JWT' } + + const encodedHeader = base64url(JSON.stringify(header)) + + const encodedPayload = base64url( + JSON.stringify({ + ...payload, + iat: Math.floor(new Date().getTime() / 1000) + }) + ) + + const unsignedToken = `${encodedHeader}.${encodedPayload}` + + const signature = await createSignature(unsignedToken, secret) + + return `${unsignedToken}.${signature}` +} + +async function createSignature(data: string, secret: string): Promise { + const enc = new TextEncoder() + const algorithm = { name: 'HMAC', hash: 'SHA-512' } + + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + algorithm, + false, + ['sign', 'verify'] + ) + + const signature = await crypto.subtle.sign( + algorithm.name, + key, + enc.encode(data) + ) + + return base64url(String.fromCharCode(...new Uint8Array(signature))) +} diff --git a/packages/js-auth/src/types/index.ts b/packages/js-auth/src/types/index.ts index 30d30d2..f2a3783 100644 --- a/packages/js-auth/src/types/index.ts +++ b/packages/js-auth/src/types/index.ts @@ -6,6 +6,7 @@ import type { TBaseReturn } from './base.js' import type { TClientCredentialsOptions } from './clientCredentials.js' import type { TPasswordOptions, TPasswordReturn } from './password.js' import type { TRefreshTokenOptions } from './refreshToken.js' +import type { TJwtBearerOptions, TJwtBearerReturn } from './jwtBearer.js' /** * The grant type. @@ -15,25 +16,30 @@ export type GrantType = | 'refresh_token' | 'client_credentials' | 'authorization_code' + | 'urn:ietf:params:oauth:grant-type:jwt-bearer' export type AuthenticateOptions = - TGrantType extends 'password' - ? TPasswordOptions - : TGrantType extends 'refresh_token' - ? TRefreshTokenOptions - : TGrantType extends 'client_credentials' - ? TClientCredentialsOptions - : TGrantType extends 'authorization_code' - ? TAuthorizationCodeOptions - : never + TGrantType extends 'urn:ietf:params:oauth:grant-type:jwt-bearer' + ? TJwtBearerOptions + : TGrantType extends 'password' + ? TPasswordOptions + : TGrantType extends 'refresh_token' + ? TRefreshTokenOptions + : TGrantType extends 'client_credentials' + ? TClientCredentialsOptions + : TGrantType extends 'authorization_code' + ? TAuthorizationCodeOptions + : never export type AuthenticateReturn = - TGrantType extends 'password' - ? TPasswordReturn - : TGrantType extends 'refresh_token' + TGrantType extends 'urn:ietf:params:oauth:grant-type:jwt-bearer' + ? TJwtBearerReturn + : TGrantType extends 'password' ? TPasswordReturn - : TGrantType extends 'client_credentials' - ? TBaseReturn - : TGrantType extends 'authorization_code' - ? TAuthorizationCodeReturn - : never + : TGrantType extends 'refresh_token' + ? TPasswordReturn + : TGrantType extends 'client_credentials' + ? TBaseReturn + : TGrantType extends 'authorization_code' + ? TAuthorizationCodeReturn + : never diff --git a/packages/js-auth/src/types/jwtBearer.ts b/packages/js-auth/src/types/jwtBearer.ts new file mode 100644 index 0000000..1ed5297 --- /dev/null +++ b/packages/js-auth/src/types/jwtBearer.ts @@ -0,0 +1,37 @@ +import type { TBaseOptions } from '#types/base.js' +import type { TPasswordReturn } from './password.js' + +/** + * Commerce Layer, through OAuth2, provides the support of token exchange in the on-behalf-of (delegation) scenario which allows, + * for example, to make calls on behalf of a user and get an access token of the requesting user without direct user interaction. + * Sales channels and webapps can accomplish it by leveraging the JWT Bearer flow, + * which allows a client application to obtain an access token using a JSON Web Token (JWT) assertion. + * @see https://docs.commercelayer.io/core/authentication/jwt-bearer + */ +export interface TJwtBearerOptions extends TBaseOptions { + /** Your application's client secret. */ + clientSecret: string + /** + * A single JSON Web Token ([learn more](https://docs.commercelayer.io/core/authentication/jwt-bearer#creating-the-jwt-assertion)). + * Max size is 4KB. + * + * **You can use the `createAssertion` helper method**. + * + * @example + * ```ts + * { + * assertion: await createAssertion({ + * owner: { + * type: 'Customer', + * id: 'aEwdr55W' + * } + * }) + * } + * ``` + */ + assertion: string +} + +export interface TJwtBearerReturn extends Omit { + ownerType: 'user' | 'customer' +} diff --git a/packages/js-auth/src/utils/base64.ts b/packages/js-auth/src/utils/base64.ts new file mode 100644 index 0000000..3ba170f --- /dev/null +++ b/packages/js-auth/src/utils/base64.ts @@ -0,0 +1,51 @@ +/** + * 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`. + */ +export function atob(encodedData: string): string { + if (typeof window !== 'undefined') { + return window.atob(encodedData) + } + + return Buffer.from(encodedData, 'base64').toString('binary') +} + +/** + * The `btoa()` method creates a [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64)-encoded [ASCII](https://developer.mozilla.org/en-US/docs/Glossary/ASCII) + * string from a _binary string_ (i.e., a string in which each character in the string is treated as a byte of binary data). + * + * This method works both in Node.js and browsers. + * + * @link [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/btoa) + * @param stringToEncode The binary string to encode. + * @returns An ASCII string containing the Base64 representation of `stringToEncode`. + */ +export function btoa(stringToEncode: string): string { + if (typeof window !== 'undefined') { + return window.btoa(stringToEncode) + } + + return Buffer.from(stringToEncode, 'binary').toString('base64') +} + +/** + * + * @param source + * @returns + */ +export function base64url(source: string): string { + return ( + btoa(source) + // Remove padding equal characters + .replace(/=+$/, '') + // Replace characters according to base64url specifications + .replace(/\+/g, '-') + .replace(/\//g, '_') + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd0157d..f79475a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,9 +57,15 @@ importers: packages/js-auth: devDependencies: + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.6 '@types/node': specifier: ^20.11.27 version: 20.11.27 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 tsup: specifier: ^8.0.2 version: 8.0.2(typescript@5.4.2) @@ -1425,6 +1431,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/jsonwebtoken@9.0.6: + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + dependencies: + '@types/node': 20.11.27 + dev: true + /@types/minimatch@3.0.5: resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} dev: true @@ -2031,6 +2043,10 @@ packages: fill-range: 7.0.1 dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: true + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -2639,6 +2655,12 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: true + /ejs@3.1.9: resolution: {integrity: sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==} engines: {node: '>=0.10.0'} @@ -4329,6 +4351,37 @@ packages: engines: {'0': node >= 0.2.0} dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.0 + dev: true + + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: true + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: true + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -4531,14 +4584,42 @@ packages: p-locate: 5.0.0 dev: true + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: true + + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: true + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: true + /lodash.ismatch@4.4.0: resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==} dev: true + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: true + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: true + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: true + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: true + /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true From 73beb10f173b15ee52a71cb8cdeae0b6b9a9c61c Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Fri, 22 Mar 2024 22:56:53 +0100 Subject: [PATCH 2/4] fix: custom_claim can be anything --- packages/js-auth/src/jwtEncode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js-auth/src/jwtEncode.ts b/packages/js-auth/src/jwtEncode.ts index 626f6b3..cec3f8d 100644 --- a/packages/js-auth/src/jwtEncode.ts +++ b/packages/js-auth/src/jwtEncode.ts @@ -10,7 +10,7 @@ export async function createAssertion(payload: { /** The customer or user you want to make the calls on behalf of. */ owner: Owner /** Any other information (key/value pairs) you want to enrich the token with. */ - custom_claim?: Record + custom_claim?: Record } }): Promise { return await jwtEncode(payload, 'cl') From 7d0e266b7a86fcf66b2d5a25fad0e68b2683df94 Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Mon, 25 Mar 2024 19:17:32 +0100 Subject: [PATCH 3/4] chore: add documentation about the 'createAssertion' --- packages/js-auth/README.md | 45 ++++++++++++++++ packages/js-auth/src/jwtEncode.spec.ts | 4 +- packages/js-auth/src/jwtEncode.ts | 50 ++++++++++++++--- packages/js-auth/src/types/jwtBearer.ts | 9 +--- packages/js-auth/src/utils/base64.spec.ts | 65 +++++++++++++++++++++++ packages/js-auth/src/utils/base64.ts | 43 ++++++++------- 6 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 packages/js-auth/src/utils/base64.spec.ts diff --git a/packages/js-auth/README.md b/packages/js-auth/README.md index df3be47..a64a7f0 100644 --- a/packages/js-auth/README.md +++ b/packages/js-auth/README.md @@ -17,6 +17,7 @@ A JavaScript Library wrapper that helps you use the Commerce Layer API for [Auth - [Integration application with client credentials flow](#integration-client-credentials) - [Webapp application with authorization code flow](#webapp-authorization-code) - [Provisioning application](#provisioning) + - [JWT bearer](#jwt-bearer) - [Utilities](#utilities) - [Decode an access token](#decode-an-access-token) - [Contributors guide](#contributors-guide) @@ -205,6 +206,50 @@ console.log('My access token: ', auth.accessToken) console.log('Expiration date: ', auth.expires) ``` +### JWT bearer + +Commerce Layer, through OAuth2, provides the support of token exchange in the _on-behalf-of_ (delegation) scenario which allows, +for example, to make calls on behalf of a user and get an access token of the requesting user without direct user interaction. +**Sales channels** and **webapps** can accomplish it by leveraging the [JWT Bearer flow](https://docs.commercelayer.io/core/authentication/jwt-bearer), +which allows a client application to obtain an access token using a JSON Web Token (JWT) [_assertion_](https://docs.commercelayer.io/core/authentication/jwt-bearer#creating-the-jwt-assertion). + +You can use this code to create an _assertion_: + +```ts +const assertion = await createAssertion({ + payload: { + 'https://commercelayer.io/claims': { + owner: { + type: 'Customer', + id: '4tepftJsT2' + }, + custom_claim: { + customer: { + first_name: 'John', + last_name: 'Doe' + } + } + } + } +}) +``` + +You can now get an access token using the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type: + +```ts +import { authenticate } from '@commercelayer/js-auth' + +const auth = await authenticate('urn:ietf:params:oauth:grant-type:jwt-bearer', { + clientId: 'your-client-id', + clientSecret: 'your-client-secret', + scope: 'market:code:europe', + assertion +}) + +console.log('My access token: ', auth.accessToken) +console.log('Expiration date: ', auth.expires) +``` + ## Utilities ### Decode an access token diff --git a/packages/js-auth/src/jwtEncode.spec.ts b/packages/js-auth/src/jwtEncode.spec.ts index 6dc9e4b..296c988 100644 --- a/packages/js-auth/src/jwtEncode.spec.ts +++ b/packages/js-auth/src/jwtEncode.spec.ts @@ -2,7 +2,7 @@ import jwt from 'jsonwebtoken' import { createAssertion } from './jwtEncode.js' describe('createAssertion', () => { - it('should be able to parse a "dashboard" access token.', async () => { + it('should be able to create a JWT assertion.', async () => { const payload = { 'https://commercelayer.io/claims': { owner: { @@ -19,7 +19,7 @@ describe('createAssertion', () => { algorithm: 'HS512' }) - const assertion = await createAssertion(payload) + const assertion = await createAssertion({ payload }) expect(assertion).toStrictEqual(jsonwebtokenAssertion) }) diff --git a/packages/js-auth/src/jwtEncode.ts b/packages/js-auth/src/jwtEncode.ts index cec3f8d..f524a49 100644 --- a/packages/js-auth/src/jwtEncode.ts +++ b/packages/js-auth/src/jwtEncode.ts @@ -5,17 +5,51 @@ interface Owner { id: string } -export async function createAssertion(payload: { - 'https://commercelayer.io/claims': { - /** The customer or user you want to make the calls on behalf of. */ - owner: Owner - /** Any other information (key/value pairs) you want to enrich the token with. */ - custom_claim?: Record - } -}): Promise { +/** + * Create a JWT assertion as the first step of the [JWT bearer token authorization grant flow](https://docs.commercelayer.io/core/authentication/jwt-bearer). + * + * The JWT assertion is a digitally signed JSON object containing information + * about the client and the user on whose behalf the access token is being requested. + * + * This JWT assertion can include information such as the issuer (typically the client), + * the owner (the user on whose behalf the request is made), and any other relevant claims. + * + * @example + * ```ts + * const assertion = await createAssertion({ + * payload: { + * 'https://commercelayer.io/claims': { + * owner: { + * type: 'Customer', + * id: '4tepftJsT2' + * }, + * custom_claim: { + * customer: { + * first_name: 'John', + * last_name: 'Doe' + * } + * } + * } + * } + * }) + * ``` + */ +export async function createAssertion({ payload }: Assertion): Promise { return await jwtEncode(payload, 'cl') } +interface Assertion { + /** Assertion payload. */ + payload: { + 'https://commercelayer.io/claims': { + /** The customer or user you want to make the calls on behalf of. */ + owner: Owner + /** Any other information (key/value pairs) you want to enrich the token with. */ + custom_claim?: Record + } + } +} + async function jwtEncode( payload: Record, secret: string diff --git a/packages/js-auth/src/types/jwtBearer.ts b/packages/js-auth/src/types/jwtBearer.ts index 1ed5297..ce35282 100644 --- a/packages/js-auth/src/types/jwtBearer.ts +++ b/packages/js-auth/src/types/jwtBearer.ts @@ -19,14 +19,7 @@ export interface TJwtBearerOptions extends TBaseOptions { * * @example * ```ts - * { - * assertion: await createAssertion({ - * owner: { - * type: 'Customer', - * id: 'aEwdr55W' - * } - * }) - * } + * import { createAssertion } from '@commercelayer/js-auth' * ``` */ assertion: string diff --git a/packages/js-auth/src/utils/base64.spec.ts b/packages/js-auth/src/utils/base64.spec.ts new file mode 100644 index 0000000..7749df9 --- /dev/null +++ b/packages/js-auth/src/utils/base64.spec.ts @@ -0,0 +1,65 @@ +import { atob, btoa, base64url } from './base64.js' + +const stringifiedObject = JSON.stringify({ + customer: { + first_name: 'John', + last_name: 'Doe' + } +}) + +describe('btoa', () => { + it('should be able to create a Base64-encoded ASCII string from a binary string.', () => { + expect(btoa('')).toEqual('') + expect(btoa('Hello, world')).toEqual('SGVsbG8sIHdvcmxk') + + expect(btoa(stringifiedObject)).toEqual( + 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ==' + ) + }) + + expect( + btoa( + '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' + ) + ).toEqual('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1+jA=') + + expect(btoa('subjects?_d=1')).toEqual('c3ViamVjdHM/X2Q9MQ==') +}) + +describe('atob', () => { + it('should be able to decode a string of data which has been encoded using Base64 encoding.', () => { + expect(atob('')).toEqual('') + expect(atob('SGVsbG8sIHdvcmxk')).toEqual('Hello, world') + + expect( + atob( + 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ==' + ) + ).toEqual(stringifiedObject) + + expect(atob('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1+jA=')).toEqual( + '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' + ) + + expect(atob('c3ViamVjdHM/X2Q9MQ==')).toEqual('subjects?_d=1') + }) +}) + +describe('base64url', () => { + it('should be able to create a Base64-encoded ASCII string from a binary string.', () => { + expect(base64url('')).toEqual('') + expect(base64url('Hello, world')).toEqual('SGVsbG8sIHdvcmxk') + + expect(base64url(stringifiedObject)).toEqual( + 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ' + ) + + expect( + base64url( + '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' + ) + ).toEqual('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1-jA') + + expect(base64url('subjects?_d=1')).toEqual('c3ViamVjdHM_X2Q9MQ') + }) +}) diff --git a/packages/js-auth/src/utils/base64.ts b/packages/js-auth/src/utils/base64.ts index 3ba170f..85dc1ea 100644 --- a/packages/js-auth/src/utils/base64.ts +++ b/packages/js-auth/src/utils/base64.ts @@ -1,47 +1,50 @@ /** - * The `atob()` function decodes a string of data - * which has been encoded using [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64) encoding. + * Creates a [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64)-encoded [ASCII](https://developer.mozilla.org/en-US/docs/Glossary/ASCII) + * string from a _binary string_ (i.e., a string in which each character in the string is treated as a byte of binary data). * * 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`. + * @link [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/btoa) + * @param stringToEncode The binary string to encode. + * @returns An ASCII string containing the Base64 representation of `stringToEncode`. */ -export function atob(encodedData: string): string { +export function btoa(stringToEncode: string): string { if (typeof window !== 'undefined') { - return window.atob(encodedData) + return window.btoa(stringToEncode) } - return Buffer.from(encodedData, 'base64').toString('binary') + return Buffer.from(stringToEncode, 'binary').toString('base64') } /** - * The `btoa()` method creates a [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64)-encoded [ASCII](https://developer.mozilla.org/en-US/docs/Glossary/ASCII) - * string from a _binary string_ (i.e., a string in which each character in the string is treated as a byte of binary data). + * 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/btoa) - * @param stringToEncode The binary string to encode. - * @returns An ASCII string containing the Base64 representation of `stringToEncode`. + * @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`. */ -export function btoa(stringToEncode: string): string { +export function atob(encodedData: string): string { if (typeof window !== 'undefined') { - return window.btoa(stringToEncode) + return window.atob(encodedData) } - return Buffer.from(stringToEncode, 'binary').toString('base64') + return Buffer.from(encodedData, 'base64').toString('binary') } /** + * The "Base64 URL safe" omits the padding `=` and replaces `+/` with `-_` + * to avoid characters that might cause problems in URL path segments or query parameters. * - * @param source - * @returns + * This is a common variant of [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64). + * @param stringToEncode The binary string to encode. + * @returns An ASCII string containing the Base64 URL safe representation of `stringToEncode`. */ -export function base64url(source: string): string { +export function base64url(stringToEncode: string): string { return ( - btoa(source) + btoa(stringToEncode) // Remove padding equal characters .replace(/=+$/, '') // Replace characters according to base64url specifications From d92fad86f337b1290eb3dade6ca6e2c47caccd46 Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Tue, 26 Mar 2024 07:55:04 +0100 Subject: [PATCH 4/4] fix: decode/encode is always from/to Base64 URL safe --- packages/js-auth/src/jwtDecode.ts | 6 +- packages/js-auth/src/jwtEncode.ts | 8 +-- packages/js-auth/src/utils/base64.spec.ts | 68 ++++++++++++----------- packages/js-auth/src/utils/base64.ts | 57 +++++++++---------- 4 files changed, 68 insertions(+), 71 deletions(-) diff --git a/packages/js-auth/src/jwtDecode.ts b/packages/js-auth/src/jwtDecode.ts index 3afe9ac..590a083 100644 --- a/packages/js-auth/src/jwtDecode.ts +++ b/packages/js-auth/src/jwtDecode.ts @@ -1,4 +1,4 @@ -import { atob } from '#utils/base64.js' +import { decodeBase64URLSafe } from '#utils/base64.js' /** * Decode a Commerce Layer access token. @@ -7,8 +7,8 @@ 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') + header: JSON.parse(header != null ? decodeBase64URLSafe(header) : 'null'), + payload: JSON.parse(payload != null ? decodeBase64URLSafe(payload) : 'null') } } diff --git a/packages/js-auth/src/jwtEncode.ts b/packages/js-auth/src/jwtEncode.ts index f524a49..83da78d 100644 --- a/packages/js-auth/src/jwtEncode.ts +++ b/packages/js-auth/src/jwtEncode.ts @@ -1,4 +1,4 @@ -import { base64url } from '#utils/base64.js' +import { encodeBase64URLSafe } from '#utils/base64.js' interface Owner { type: 'User' | 'Customer' @@ -56,9 +56,9 @@ async function jwtEncode( ): Promise { const header = { alg: 'HS512', typ: 'JWT' } - const encodedHeader = base64url(JSON.stringify(header)) + const encodedHeader = encodeBase64URLSafe(JSON.stringify(header)) - const encodedPayload = base64url( + const encodedPayload = encodeBase64URLSafe( JSON.stringify({ ...payload, iat: Math.floor(new Date().getTime() / 1000) @@ -90,5 +90,5 @@ async function createSignature(data: string, secret: string): Promise { enc.encode(data) ) - return base64url(String.fromCharCode(...new Uint8Array(signature))) + return encodeBase64URLSafe(String.fromCharCode(...new Uint8Array(signature))) } diff --git a/packages/js-auth/src/utils/base64.spec.ts b/packages/js-auth/src/utils/base64.spec.ts index 7749df9..96304ff 100644 --- a/packages/js-auth/src/utils/base64.spec.ts +++ b/packages/js-auth/src/utils/base64.spec.ts @@ -1,4 +1,4 @@ -import { atob, btoa, base64url } from './base64.js' +import { decodeBase64URLSafe, encodeBase64URLSafe } from './base64.js' const stringifiedObject = JSON.stringify({ customer: { @@ -7,59 +7,61 @@ const stringifiedObject = JSON.stringify({ } }) -describe('btoa', () => { - it('should be able to create a Base64-encoded ASCII string from a binary string.', () => { - expect(btoa('')).toEqual('') - expect(btoa('Hello, world')).toEqual('SGVsbG8sIHdvcmxk') +describe('encodeBase64UrlSafe', () => { + it('should be able to create a Base64 URL safe encoded ASCII string from a binary string.', () => { + expect(encodeBase64URLSafe('')).toEqual('') + expect(encodeBase64URLSafe('Hello, world')).toEqual('SGVsbG8sIHdvcmxk') - expect(btoa(stringifiedObject)).toEqual( - 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ==' + expect(encodeBase64URLSafe(stringifiedObject)).toEqual( + 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ' ) - }) - expect( - btoa( - '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' - ) - ).toEqual('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1+jA=') + expect( + encodeBase64URLSafe( + '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' + ) + ).toEqual('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1-jA') - expect(btoa('subjects?_d=1')).toEqual('c3ViamVjdHM/X2Q9MQ==') + expect(encodeBase64URLSafe('subjects?_d=1')).toEqual('c3ViamVjdHM_X2Q9MQ') + }) }) -describe('atob', () => { +describe('decodeBase64UrlSafe', () => { it('should be able to decode a string of data which has been encoded using Base64 encoding.', () => { - expect(atob('')).toEqual('') - expect(atob('SGVsbG8sIHdvcmxk')).toEqual('Hello, world') + expect(decodeBase64URLSafe('')).toEqual('') + expect(decodeBase64URLSafe('SGVsbG8sIHdvcmxk')).toEqual('Hello, world') expect( - atob( + decodeBase64URLSafe( 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ==' ) ).toEqual(stringifiedObject) - expect(atob('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1+jA=')).toEqual( + expect( + decodeBase64URLSafe('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1+jA=') + ).toEqual( '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' ) - expect(atob('c3ViamVjdHM/X2Q9MQ==')).toEqual('subjects?_d=1') + expect(decodeBase64URLSafe('c3ViamVjdHM/X2Q9MQ==')).toEqual('subjects?_d=1') }) -}) -describe('base64url', () => { - it('should be able to create a Base64-encoded ASCII string from a binary string.', () => { - expect(base64url('')).toEqual('') - expect(base64url('Hello, world')).toEqual('SGVsbG8sIHdvcmxk') - - expect(base64url(stringifiedObject)).toEqual( - 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ' - ) + it('should be able to decode a string of data which has been encoded using Base64 URL safe encoding.', () => { + expect(decodeBase64URLSafe('')).toEqual('') + expect(decodeBase64URLSafe('SGVsbG8sIHdvcmxk')).toEqual('Hello, world') expect( - base64url( - '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' + decodeBase64URLSafe( + 'eyJjdXN0b21lciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSJ9fQ' ) - ).toEqual('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1-jA') + ).toEqual(stringifiedObject) + + expect( + decodeBase64URLSafe('MIIHNjCCBh6gAwIBAgIQCVe4E0h49mzI0NcSqMy1-jA') + ).toEqual( + '0\x82\x0760\x82\x06\x1E \x03\x02\x01\x02\x02\x10\tW¸\x13HxölÈÐ×\x12¨Ìµú0' + ) - expect(base64url('subjects?_d=1')).toEqual('c3ViamVjdHM_X2Q9MQ') + expect(decodeBase64URLSafe('c3ViamVjdHM_X2Q9MQ')).toEqual('subjects?_d=1') }) }) diff --git a/packages/js-auth/src/utils/base64.ts b/packages/js-auth/src/utils/base64.ts index 85dc1ea..d00c88c 100644 --- a/packages/js-auth/src/utils/base64.ts +++ b/packages/js-auth/src/utils/base64.ts @@ -1,54 +1,49 @@ /** - * Creates a [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64)-encoded [ASCII](https://developer.mozilla.org/en-US/docs/Glossary/ASCII) + * Creates a [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64) URL safe encoded [ASCII](https://developer.mozilla.org/en-US/docs/Glossary/ASCII) * string from a _binary string_ (i.e., a string in which each character in the string is treated as a byte of binary data). * + * The "Base64 URL safe" omits the padding `=` and replaces `+/` with `-_` to avoid characters that might cause problems in URL path segments or query parameters. + * * This method works both in Node.js and browsers. * - * @link [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/btoa) * @param stringToEncode The binary string to encode. - * @returns An ASCII string containing the Base64 representation of `stringToEncode`. + * @returns An ASCII string containing the Base64 URL safe representation of `stringToEncode`. */ -export function btoa(stringToEncode: string): string { +export function encodeBase64URLSafe(stringToEncode: string): string { if (typeof window !== 'undefined') { - return window.btoa(stringToEncode) + return window.btoa( + stringToEncode + // Remove padding equal characters + .replaceAll('=', '') + // Replace characters according to base64url specifications + .replaceAll('+', '-') + .replaceAll('/', '_') + ) } - return Buffer.from(stringToEncode, 'binary').toString('base64') + return Buffer.from(stringToEncode, 'binary').toString('base64url') } /** * Decodes a string of data - * which has been encoded using [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64) encoding. + * which has been encoded using [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64) URL safe encoding. + * + * The "Base64 URL safe" omits the padding `=` and replaces `+/` with `-_` to avoid characters that might cause problems in URL path segments or query parameters. * * 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. + * @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 URL safe -encoded data. * @returns An ASCII string containing decoded data from `encodedData`. */ -export function atob(encodedData: string): string { +export function decodeBase64URLSafe(encodedData: string): string { if (typeof window !== 'undefined') { - return window.atob(encodedData) + return window.atob( + encodedData + // Replace characters according to base64url specifications + .replaceAll('-', '+') + .replaceAll('_', '/') + ) } - return Buffer.from(encodedData, 'base64').toString('binary') -} - -/** - * The "Base64 URL safe" omits the padding `=` and replaces `+/` with `-_` - * to avoid characters that might cause problems in URL path segments or query parameters. - * - * This is a common variant of [Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64). - * @param stringToEncode The binary string to encode. - * @returns An ASCII string containing the Base64 URL safe representation of `stringToEncode`. - */ -export function base64url(stringToEncode: string): string { - return ( - btoa(stringToEncode) - // Remove padding equal characters - .replace(/=+$/, '') - // Replace characters according to base64url specifications - .replace(/\+/g, '-') - .replace(/\//g, '_') - ) + return Buffer.from(encodedData, 'base64url').toString('binary') }