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

Add support to JWT Bearer #71

Merged
merged 4 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 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