Skip to content

Commit

Permalink
Merge pull request #71 from commercelayer/jwt-bearer
Browse files Browse the repository at this point in the history
Add support to JWT Bearer
  • Loading branch information
marcomontalbano committed Mar 26, 2024
2 parents d561938 + d92fad8 commit 2cbf7a9
Show file tree
Hide file tree
Showing 12 changed files with 426 additions and 37 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions packages/js-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/js-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,17 @@
"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": {
"access": "public"
},
"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",
Expand Down
3 changes: 3 additions & 0 deletions packages/js-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { authenticate } from './authenticate.js'

export {
jwtDecode,
jwtIsDashboard,
Expand All @@ -8,6 +9,8 @@ export {
jwtIsWebApp
} from './jwtDecode.js'

export { createAssertion } from './jwtEncode.js'

export type {
AuthenticateOptions,
AuthenticateReturn,
Expand Down
24 changes: 4 additions & 20 deletions packages/js-auth/src/jwtDecode.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,17 @@
import { decodeBase64URLSafe } from '#utils/base64.js'

/**
* 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')
header: JSON.parse(header != null ? decodeBase64URLSafe(header) : 'null'),
payload: JSON.parse(payload != null ? decodeBase64URLSafe(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: {
Expand Down
26 changes: 26 additions & 0 deletions packages/js-auth/src/jwtEncode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import jwt from 'jsonwebtoken'
import { createAssertion } from './jwtEncode.js'

describe('createAssertion', () => {
it('should be able to create a JWT assertion.', 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)
})
})
94 changes: 94 additions & 0 deletions packages/js-auth/src/jwtEncode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { encodeBase64URLSafe } from '#utils/base64.js'

interface Owner {
type: 'User' | 'Customer'
id: string
}

/**
* 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<string> {
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<string, unknown>
}
}
}

async function jwtEncode(
payload: Record<string, unknown>,
secret: string
): Promise<string> {
const header = { alg: 'HS512', typ: 'JWT' }

const encodedHeader = encodeBase64URLSafe(JSON.stringify(header))

const encodedPayload = encodeBase64URLSafe(
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<string> {
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 encodeBase64URLSafe(String.fromCharCode(...new Uint8Array(signature)))
}
40 changes: 23 additions & 17 deletions packages/js-auth/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 GrantType> =
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 GrantType> =
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
30 changes: 30 additions & 0 deletions packages/js-auth/src/types/jwtBearer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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
* import { createAssertion } from '@commercelayer/js-auth'
* ```
*/
assertion: string
}

export interface TJwtBearerReturn extends Omit<TPasswordReturn, 'ownerType'> {
ownerType: 'user' | 'customer'
}
Loading

0 comments on commit 2cbf7a9

Please sign in to comment.