diff --git a/.changeset/internalize-jwt-decode-client.md b/.changeset/internalize-jwt-decode-client.md new file mode 100644 index 000000000..745d9f8b6 --- /dev/null +++ b/.changeset/internalize-jwt-decode-client.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/client": patch +--- + +Internalize `jwt-decode` in `@knocklabs/client` and drop the runtime dependency. Token decoding now uses a small inlined decoder, removing the package from consumers' install graphs. diff --git a/packages/client/package.json b/packages/client/package.json index 8809dea23..d3311d524 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -75,7 +75,6 @@ "axios": "^1.15.1", "axios-retry": "^4.5.0", "eventemitter2": "^6.4.5", - "jwt-decode": "^4.0.0", "nanoid": "^3.3.12", "phoenix": "1.8.5", "urlpattern-polyfill": "^10.0.0" diff --git a/packages/client/src/interfaces.ts b/packages/client/src/interfaces.ts index 43baccad3..fdf065ab0 100644 --- a/packages/client/src/interfaces.ts +++ b/packages/client/src/interfaces.ts @@ -1,6 +1,6 @@ import { GenericData } from "@knocklabs/types"; -import { JwtPayload } from "jwt-decode"; +import { JwtPayload } from "./jwt"; import Knock from "./knock"; export type LogLevel = "debug"; diff --git a/packages/client/src/jwt/index.ts b/packages/client/src/jwt/index.ts new file mode 100644 index 000000000..714166aa2 --- /dev/null +++ b/packages/client/src/jwt/index.ts @@ -0,0 +1,83 @@ +/** + * Decodes a JWT payload by base64url-decoding the second segment and parsing + * it as JSON. This does not verify the token signature. + */ + +export interface JwtPayload { + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; +} + +export class InvalidTokenError extends Error {} +InvalidTokenError.prototype.name = "InvalidTokenError"; + +function b64DecodeUnicode(str: string): string { + return decodeURIComponent( + atob(str).replace(/(.)/g, (char) => { + let code = char.charCodeAt(0).toString(16).toUpperCase(); + if (code.length < 2) { + code = "0" + code; + } + return "%" + code; + }), + ); +} + +function base64UrlDecode(str: string): string { + let output = str.replace(/-/g, "+").replace(/_/g, "/"); + switch (output.length % 4) { + case 0: + break; + case 2: + output += "=="; + break; + case 3: + output += "="; + break; + default: + throw new Error("base64 string is not of the correct length"); + } + + try { + return b64DecodeUnicode(output); + } catch { + return atob(output); + } +} + +export function jwtDecode(token: string): T { + if (typeof token !== "string") { + throw new InvalidTokenError("Invalid token specified: must be a string"); + } + + // The payload is the second segment of the JWT (header.payload.signature). + const part = token.split(".")[1]; + + if (typeof part !== "string") { + throw new InvalidTokenError("Invalid token specified: missing part #2"); + } + + let decoded: string; + try { + decoded = base64UrlDecode(part); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new InvalidTokenError( + `Invalid token specified: invalid base64 for part #2 (${message})`, + ); + } + + try { + return JSON.parse(decoded) as T; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new InvalidTokenError( + `Invalid token specified: invalid json for part #2 (${message})`, + ); + } +} diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index d1f4648eb..06d881da4 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -1,5 +1,3 @@ -import { jwtDecode } from "jwt-decode"; - import ApiClient from "./api"; import FeedClient from "./clients/feed"; import MessageClient from "./clients/messages"; @@ -16,6 +14,7 @@ import { UserIdOrUserWithProperties, UserTokenExpiringCallback, } from "./interfaces"; +import { jwtDecode } from "./jwt"; const DEFAULT_HOST = "https://api.knock.app"; diff --git a/packages/client/test/jwt.test.ts b/packages/client/test/jwt.test.ts new file mode 100644 index 000000000..9b7355982 --- /dev/null +++ b/packages/client/test/jwt.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; + +import { InvalidTokenError, jwtDecode } from "../src/jwt"; + +// Builds a JWT-shaped string for the given payload. Only the payload segment +// is meaningful to `jwtDecode`; the header and signature are placeholders. +function encodeToken(payload: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }), + ).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +describe("jwtDecode", () => { + test("decodes standard registered claims", () => { + const token = encodeToken({ + sub: "user_123", + exp: 9999999999, + iat: 1516239022, + }); + + const decoded = jwtDecode(token); + + expect(decoded.sub).toBe("user_123"); + expect(decoded.exp).toBe(9999999999); + expect(decoded.iat).toBe(1516239022); + }); + + test("decodes custom claims via a type parameter", () => { + const token = encodeToken({ sub: "user_123", role: "admin" }); + + const decoded = jwtDecode<{ sub: string; role: string }>(token); + + expect(decoded).toEqual({ sub: "user_123", role: "admin" }); + }); + + test("decodes payloads containing unicode characters", () => { + const token = encodeToken({ name: "José Ünïcode 🚀" }); + + const decoded = jwtDecode<{ name: string }>(token); + + expect(decoded.name).toBe("José Ünïcode 🚀"); + }); + + test("decodes base64url payloads regardless of length (padding)", () => { + // Vary payload length to exercise each `length % 4` padding branch. + for (const id of ["a", "ab", "abc", "abcd"]) { + const decoded = jwtDecode<{ id: string }>(encodeToken({ id })); + expect(decoded.id).toBe(id); + } + }); + + test("throws InvalidTokenError when the token is not a string", () => { + // @ts-expect-error - exercising the runtime guard against non-string input + expect(() => jwtDecode(undefined)).toThrow(InvalidTokenError); + }); + + test("throws InvalidTokenError when the payload segment is missing", () => { + expect(() => jwtDecode("only-one-segment")).toThrow(InvalidTokenError); + }); + + test("throws InvalidTokenError when the payload is not valid JSON", () => { + const notJson = Buffer.from("not json").toString("base64url"); + + expect(() => jwtDecode(`header.${notJson}.signature`)).toThrow( + InvalidTokenError, + ); + }); +}); diff --git a/packages/client/test/knock.test.ts b/packages/client/test/knock.test.ts index 39d6f7f55..dd5660346 100644 --- a/packages/client/test/knock.test.ts +++ b/packages/client/test/knock.test.ts @@ -1,15 +1,15 @@ -import { jwtDecode } from "jwt-decode"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { jwtDecode } from "../src/jwt"; import Knock from "../src/knock"; import { authenticateKnock, createMockKnock } from "./test-utils/mocks"; const TEST_BRANCH_SLUG = "lorem-ipsum-dolor-branch"; -// ✅ Mock the named export `jwtDecode` from the "jwt-decode" module. +// ✅ Mock the named export `jwtDecode` from the internal jwt module. // It will always return a decoded token with an `exp` 61 seconds in the future. -vi.mock("jwt-decode", () => ({ +vi.mock("../src/jwt", () => ({ jwtDecode: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 61, })), diff --git a/yarn.lock b/yarn.lock index cdcf61e41..4105814e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4870,7 +4870,6 @@ __metadata: eslint: "npm:^8.56.0" eventemitter2: "npm:^6.4.5" jsonwebtoken: "npm:^9.0.3" - jwt-decode: "npm:^4.0.0" nanoid: "npm:^3.3.12" phoenix: "npm:1.8.5" prettier: "npm:^3.5.3" @@ -15801,13 +15800,6 @@ __metadata: languageName: node linkType: hard -"jwt-decode@npm:^4.0.0": - version: 4.0.0 - resolution: "jwt-decode@npm:4.0.0" - checksum: 10c0/de75bbf89220746c388cf6a7b71e56080437b77d2edb29bae1c2155048b02c6b8c59a3e5e8d6ccdfd54f0b8bda25226e491a4f1b55ac5f8da04cfbadec4e546c - languageName: node - linkType: hard - "keyv@npm:^4.5.3, keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4"