Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/internalize-jwt-decode-client.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
83 changes: 83 additions & 0 deletions packages/client/src/jwt/index.ts
Original file line number Diff line number Diff line change
@@ -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<T = JwtPayload>(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})`,
);
}
}
3 changes: 1 addition & 2 deletions packages/client/src/knock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { jwtDecode } from "jwt-decode";

import ApiClient from "./api";
import FeedClient from "./clients/feed";
import MessageClient from "./clients/messages";
Expand All @@ -16,6 +14,7 @@ import {
UserIdOrUserWithProperties,
UserTokenExpiringCallback,
} from "./interfaces";
import { jwtDecode } from "./jwt";

const DEFAULT_HOST = "https://api.knock.app";

Expand Down
70 changes: 70 additions & 0 deletions packages/client/test/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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,
);
});
});
6 changes: 3 additions & 3 deletions packages/client/test/knock.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})),
Expand Down
8 changes: 0 additions & 8 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading