From 11a002052dc5f896e2afc66e75c00dd535baf769 Mon Sep 17 00:00:00 2001 From: Toby Date: Thu, 3 Oct 2024 19:11:20 +0200 Subject: [PATCH] make verify return decoded token --- README.md | 133 ++++++++++++++++++++++++--------------- src/index.ts | 59 +++++++++-------- src/utils.ts | 10 +-- tests/algorithms.spec.ts | 4 +- tests/index.spec.ts | 8 +-- tests/utils.spec.ts | 2 +- 6 files changed, 124 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 89ea2e6..08dcc64 100644 --- a/README.md +++ b/README.md @@ -15,57 +15,66 @@ A lightweight JWT implementation with ZERO dependencies for Cloudflare Workers. ## Install -``` +```bash npm i @tsndr/cloudflare-worker-jwt ``` ## Examples - ### Basic Example ```typescript async () => { - import jwt from '@tsndr/cloudflare-worker-jwt' + import jwt from "@tsndr/cloudflare-worker-jwt" - // Creating a token - const token = await jwt.sign({ name: 'John Doe', email: 'john.doe@gmail.com' }, 'secret') + // Create a token + const token = await sign({ + sub: "1234", + name: "John Doe", + email: "john.doe@gmail.com" + }, "secret") - // Verifing token - const isValid = await jwt.verify(token, 'secret') + // Verify token + const verifiedToken = await verify(token, "secret") - // Check for validity - if (!isValid) + // Abort if token isn't valid + if (!verifiedToken) return - // Decoding token - const { payload } = jwt.decode(token) + // Access token payload + const { payload } = verifiedToken + + // { sub: "1234", name: "John Doe", email: "john.doe@gmail.com" } } ``` + ### Restrict Timeframe ```typescript async () => { - import jwt from '@tsndr/cloudflare-worker-jwt' + import jwt from "@tsndr/cloudflare-worker-jwt" - // Creating a token - const token = await jwt.sign({ - name: 'John Doe', - email: 'john.doe@gmail.com', + // Create a token + const token = await sign({ + sub: "1234", + name: "John Doe", + email: "john.doe@gmail.com", nbf: Math.floor(Date.now() / 1000) + (60 * 60), // Not before: Now + 1h exp: Math.floor(Date.now() / 1000) + (2 * (60 * 60)) // Expires: Now + 2h - }, 'secret') + }, "secret") - // Verifing token - const isValid = await jwt.verify(token, 'secret') // false + // Verify token + const verifiedToken = await verify(token, "secret") // false - // Check for validity - if (!isValid) + // Abort if token isn't valid + if (!verifiedToken) return - // Decoding token - const { payload } = jwt.decode(token) // { name: 'John Doe', email: 'john.doe@gmail.com', ... } + // Access token payload + const { payload } = verifiedToken + + // { sub: "1234", name: "John Doe", email: "john.doe@gmail.com", ... } } ``` @@ -78,79 +87,101 @@ async () => {
### Sign -#### `jwt.sign(payload, secret, [options])` +#### `sign(payload, secret, [options])` Signs a payload and returns the token. + #### Arguments -Argument | Type | Status | Default | Description ------------------------- | ------------------ | -------- | ----------- | ----------- +Argument | Type | Status | Default | Description +------------------------ | ----------------------------------- | -------- | ----------- | ----------- `payload` | `object` | required | - | The payload object. To use `nbf` (Not Before) and/or `exp` (Expiration Time) add `nbf` and/or `exp` to the payload. `secret` | `string`, `JsonWebKey`, `CryptoKey` | required | - | A string which is used to sign the payload. `options` | `string`, `object` | optional | `HS256` | Either the `algorithm` string or an object. `options.algorithm` | `string` | optional | `HS256` | See [Available Algorithms](#available-algorithms) `options.keyid` | `string` | optional | `undefined` | The `keyid` or `kid` to be set in the header of the resulting JWT. + #### `return` + Returns token as a `string`. +
+ ### Verify -#### `jwt.verify(token, secret, [options])` +#### `verify(token, secret, [options])` -Verifies the integrity of the token and returns a boolean value. +Verifies the integrity of the token. -Argument | Type | Status | Default | Description ------------------------- | ------------------ | -------- | ------- | ----------- -`token` | `string` | required | - | The token string generated by `jwt.sign()`. +Argument | Type | Status | Default | Description +------------------------ | ----------------------------------- | -------- | ------- | ----------- +`token` | `string` | required | - | The token string generated by `sign()`. `secret` | `string`, `JsonWebKey`, `CryptoKey` | required | - | The string which was used to sign the payload. `options` | `string`, `object` | optional | `HS256` | Either the `algorithm` string or an object. `options.algorithm` | `string` | optional | `HS256` | See [Available Algorithms](#available-algorithms) `options.clockTolerance` | `number` | optional | `0` | Clock tolerance in seconds, to help with slighly out of sync systems. -`options.throwError` | `boolean` | optional | `false` | By default this we will only throw implementation errors, only set this to `true` if you want verification errors to be thrown as well. +`options.throwError` | `boolean` | optional | `false` | By default this we will only throw integration errors, only set this to `true` if you want verification errors to be thrown as well. #### `throws` -If `options.throwError` is `true` and the token is invalid, an error will be thrown. + +Throws integration errors and if `options.throwError` is set to `true` also throws `ALG_MISMATCH`, `NOT_YET_VALID`, `EXPIRED` or `INVALID_SIGNATURE`. + #### `return` -Returns `true` if signature, `nbf` (if set) and `exp` (if set) are valid, otherwise returns `false`. + +Returns the decoded token or `undefined`. + +```typescript +{ + header: { + alg: "HS256", + typ: "JWT" + }, + payload: { + name: "John Doe", + email: "john.doe@gmail.com" + } +} +``` +
+ ### Decode -#### `jwt.decode(token)` +#### `decode(token)` -Returns the payload **without** verifying the integrity of the token. Please use `jwt.verify()` first to keep your application secure! +Just returns the decoded token **without** verifying verifying it. Please use `verify()` if you intend to verify it as well. Argument | Type | Status | Default | Description ----------- | -------- | -------- | ------- | ----------- -`token` | `string` | required | - | The token string generated by `jwt.sign()`. +`token` | `string` | required | - | The token string generated by `sign()`. + #### `return` + Returns an `object` containing `header` and `payload`: -```javascript + +```typescript { header: { - alg: 'HS256', - typ: 'JWT' + alg: "HS256", + typ: "JWT" }, payload: { - name: 'John Doe', - email: 'john.doe@gmail.com' + name: "John Doe", + email: "john.doe@gmail.com" } } ``` + ### Available Algorithms - - ES256 - - ES384 - - ES512 - - HS256 - - HS384 - - HS512 - - RS256 - - RS384 - - RS512 \ No newline at end of file + + - `ES256`, `ES384`, `ES512` + - `HS256`, `HS384`, `HS512` + - `RS256`, `RS384`, `RS512` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4c41db9..9018d11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,11 +137,11 @@ const algorithms: JwtAlgorithms = { /** * Signs a payload and returns the token * - * @param {JwtPayload} payload The payload object. To use `nbf` (Not Before) and/or `exp` (Expiration Time) add `nbf` and/or `exp` to the payload. - * @param {string | JsonWebKey | CryptoKey} secret A string which is used to sign the payload. - * @param {JwtSignOptions | JwtAlgorithm | string} [options={ algorithm: "HS256", header: { typ: "JWT" } }] The options object or the algorithm. - * @throws {Error} If there"s a validation issue. - * @returns {Promise} Returns token as a `string`. + * @param payload The payload object. To use `nbf` (Not Before) and/or `exp` (Expiration Time) add `nbf` and/or `exp` to the payload. + * @param secret A string which is used to sign the payload. + * @param [options={ algorithm: "HS256", header: { typ: "JWT" } }] The options object or the algorithm. + * @throws If there"s a validation issue. + * @returns Returns token as a `string`. */ export async function sign(payload: JwtPayload, secret: string | JsonWebKey | CryptoKey, options: JwtSignOptions
| JwtAlgorithm = "HS256"): Promise { if (typeof options === "string") @@ -177,13 +177,13 @@ export async function sign(payload: JwtPayload} Returns `true` if signature, `nbf` (if set) and `exp` (if set) are valid, otherwise returns `false`. + * @param token The token string generated by `sign()`. + * @param secret The string which was used to sign the payload. + * @param options The options object or the algorithm. + * @throws Throws integration errors and if `options.throwError` is set to `true` also throws `NOT_YET_VALID`, `EXPIRED` or `INVALID_SIGNATURE`. + * @returns Returns the decoded token or `undefined`. */ -export async function verify(token: string, secret: string | JsonWebKey | CryptoKey, options: JwtVerifyOptions | JwtAlgorithm = "HS256"): Promise { +export async function verify(token: string, secret: string | JsonWebKey | CryptoKey, options: JwtVerifyOptions | JwtAlgorithm = "HS256"): Promise | undefined> { if (typeof options === "string") options = { algorithm: options } options = { algorithm: "HS256", clockTolerance: 0, throwError: false, ...options } @@ -207,41 +207,40 @@ export async function verify(token: string, secret: string | JsonWebKey | Crypto if (!algorithm) throw new Error("algorithm not found") - const { header, payload } = decode(token) - - if (header?.alg !== options.algorithm) { - if (options.throwError) - throw new Error("ALG_MISMATCH") - return false - } + const decodedToken = decode(token) try { - if (!payload) - throw new Error("PARSE_ERROR") + if (decodedToken.header?.alg !== options.algorithm) + throw new Error("INVALID_SIGNATURE") - const now = Math.floor(Date.now() / 1000) + if (decodedToken.payload) { + const now = Math.floor(Date.now() / 1000) - if (payload.nbf && payload.nbf > now && (payload.nbf - now) > (options.clockTolerance ?? 0)) - throw new Error("NOT_YET_VALID") + if (decodedToken.payload.nbf && decodedToken.payload.nbf > now && (decodedToken.payload.nbf - now) > (options.clockTolerance ?? 0)) + throw new Error("NOT_YET_VALID") - if (payload.exp && payload.exp <= now && (now - payload.exp) > (options.clockTolerance ?? 0)) - throw new Error("EXPIRED") + if (decodedToken.payload.exp && decodedToken.payload.exp <= now && (now - decodedToken.payload.exp) > (options.clockTolerance ?? 0)) + throw new Error("EXPIRED") + } const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["verify"]) - return await crypto.subtle.verify(algorithm, key, base64UrlToArrayBuffer(tokenParts[2]), textToArrayBuffer(`${tokenParts[0]}.${tokenParts[1]}`)) + if (!await crypto.subtle.verify(algorithm, key, base64UrlToArrayBuffer(tokenParts[2]), textToArrayBuffer(`${tokenParts[0]}.${tokenParts[1]}`))) + throw new Error("INVALID_SIGNATURE") + + return decodedToken } catch(err) { if (options.throwError) throw err - return false + return } } /** - * Returns the payload **without** verifying the integrity of the token. Please use `jwt.verify()` first to keep your application secure! + * Returns the payload **without** verifying the integrity of the token. Please use `verify()` first to keep your application secure! * - * @param {string} token The token string generated by `jwt.sign()`. - * @returns {JwtData} Returns an `object` containing `header` and `payload`. + * @param token The token string generated by `sign()`. + * @returns Returns an `object` containing `header` and `payload`. */ export function decode(token: string): JwtData { return { diff --git a/src/utils.ts b/src/utils.ts index aaaa913..0b8dd90 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +export type KeyUsages = "sign" | "verify" + export function bytesToByteString(bytes: Uint8Array): string { let byteStr = "" for (let i = 0; i < bytes.byteLength; i++) { @@ -39,9 +41,10 @@ export function base64UrlToArrayBuffer(b64url: string): ArrayBuffer { } export function textToBase64Url(str: string): string { - const encoder = new TextEncoder(); - const charCodes = encoder.encode(str); - const binaryStr = String.fromCharCode(...charCodes); + const encoder = new TextEncoder() + const charCodes = encoder.encode(str) + const binaryStr = String.fromCharCode(...charCodes) + return btoa(binaryStr).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") } @@ -49,7 +52,6 @@ export function pemToBinary(pem: string): ArrayBuffer { return base64StringToArrayBuffer(pem.replace(/-+(BEGIN|END).*/g, "").replace(/\s/g, "")) } -type KeyUsages = "sign" | "verify"; export async function importTextSecret(key: string, algorithm: SubtleCryptoImportKeyAlgorithm, keyUsages: KeyUsages[]): Promise { return await crypto.subtle.importKey("raw", textToArrayBuffer(key), algorithm, true, keyUsages) } diff --git a/tests/algorithms.spec.ts b/tests/algorithms.spec.ts index f130e8f..bdb21f2 100644 --- a/tests/algorithms.spec.ts +++ b/tests/algorithms.spec.ts @@ -83,7 +83,7 @@ describe("Internal", () => { expect(decoded.payload).toMatchObject(payload) const verified = await jwt.verify(token, data.public, algorithm) - expect(verified).toBe(true) + expect(verified).toBeTruthy() }) }) @@ -99,6 +99,6 @@ describe("External", async () => { }) const verified = await jwt.verify(data.token, data.public, algorithm) - expect(verified).toBe(true) + expect(verified).toBeTruthy() }) }) \ No newline at end of file diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 2a9df05..7b49d6b 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -14,11 +14,11 @@ describe("Verify", async () => { const expiredToken = await jwt.sign({ sub: "me", exp: now - offset }, secret) test("Valid", () => { - expect(jwt.verify(validToken, secret, { throwError: true })).resolves.toBe(true) + expect(jwt.verify(validToken, secret, { throwError: true })).resolves.toBeTruthy() }) test("Not yet expired", () => { - expect(jwt.verify(notYetExpired, secret, { throwError: true })).resolves.toBe(true) + expect(jwt.verify(notYetExpired, secret, { throwError: true })).resolves.toBeTruthy() }) test("Not yet valid", () => { @@ -30,8 +30,8 @@ describe("Verify", async () => { }) test("Clock offset", () => { - expect(jwt.verify(notYetValidToken, secret, { clockTolerance: offset, throwError: true })).resolves.toBe(true) - expect(jwt.verify(expiredToken, secret, { clockTolerance: offset, throwError: true })).resolves.toBe(true) + expect(jwt.verify(notYetValidToken, secret, { clockTolerance: offset, throwError: true })).resolves.toBeTruthy() + expect(jwt.verify(expiredToken, secret, { clockTolerance: offset, throwError: true })).resolves.toBeTruthy() expect(jwt.verify(notYetValidToken, secret, { clockTolerance: offset - 1, throwError: true })).rejects.toThrowError("NOT_YET_VALID") expect(jwt.verify(expiredToken, secret, { clockTolerance: offset - 1, throwError: true })).rejects.toThrowError("EXPIRED") diff --git a/tests/utils.spec.ts b/tests/utils.spec.ts index a4a88be..54979cf 100644 --- a/tests/utils.spec.ts +++ b/tests/utils.spec.ts @@ -77,5 +77,5 @@ describe("Imports", () => { }) describe.todo("Payload", () => { - test.todo("decodePayload") + test.todo("decodePayload") }) \ No newline at end of file