diff --git a/package.json b/package.json index 0bd30d29..c4a1d204 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "color-json": "^3.0.5", "fast-levenshtein": "^3.0.0", "inquirer": "^9.2.16", - "jsonwebtoken": "^9.0.2", + "jose": "^5.10.0", "node-fetch": "^3.3.2", "open": "^10.1.0", "ora": "^8.2.0", @@ -125,7 +125,6 @@ "@types/fast-levenshtein": "^0.0.4", "@types/fs-extra": "^11.0.4", "@types/inquirer": "^9.0.7", - "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.10.0", "@types/node-fetch": "^2.6.13", "@types/react": "^18.3.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc94f3f..67bac197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,9 +58,9 @@ importers: inquirer: specifier: ^9.2.16 version: 9.3.7 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.2 + jose: + specifier: ^5.10.0 + version: 5.10.0 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -101,9 +101,6 @@ importers: '@types/inquirer': specifier: ^9.0.7 version: 9.0.7 - '@types/jsonwebtoken': - specifier: ^9.0.7 - version: 9.0.9 '@types/node': specifier: ^20.10.0 version: 20.17.30 @@ -2257,15 +2254,9 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/jsonwebtoken@9.0.9': - resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} - '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/mute-stream@0.0.4': resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} @@ -2890,9 +2881,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -3315,9 +3303,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - edge-runtime@2.5.9: resolution: {integrity: sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==} engines: {node: '>=16'} @@ -4483,6 +4468,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4563,20 +4551,10 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - jwa@1.4.1: - resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} - - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4674,30 +4652,9 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -8490,17 +8447,10 @@ snapshots: dependencies: '@types/node': 20.17.30 - '@types/jsonwebtoken@9.0.9': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 20.17.30 - '@types/keyv@3.1.4': dependencies: '@types/node': 20.17.30 - '@types/ms@2.1.0': {} - '@types/mute-stream@0.0.4': dependencies: '@types/node': 20.17.30 @@ -9350,8 +9300,6 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) - buffer-equal-constant-time@1.0.1: {} - buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -9740,10 +9688,6 @@ snapshots: eastasianwidth@0.2.0: {} - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - edge-runtime@2.5.9: dependencies: '@edge-runtime/format': 2.2.1 @@ -11178,6 +11122,8 @@ snapshots: jiti@2.4.2: {} + jose@5.10.0: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -11258,19 +11204,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.1 - jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 @@ -11278,17 +11211,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - jwa@1.4.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@3.2.2: - dependencies: - jwa: 1.4.1 - safe-buffer: 5.2.1 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -11359,22 +11281,8 @@ snapshots: lodash.clonedeep@4.5.0: {} - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} - lodash.sortby@4.7.0: {} lodash@4.17.21: {} diff --git a/src/commands/auth/issue-jwt-token.ts b/src/commands/auth/issue-jwt-token.ts index ab0eeaf2..4c707a55 100644 --- a/src/commands/auth/issue-jwt-token.ts +++ b/src/commands/auth/issue-jwt-token.ts @@ -1,18 +1,9 @@ import { Flags } from "@oclif/core"; -import jwt from "jsonwebtoken"; +import { SignJWT } from "jose"; import { randomUUID } from "node:crypto"; import { AblyBaseCommand } from "../../base-command.js"; -interface JwtPayload { - exp: number; - iat: number; - jti: string; - "x-ably-appId": string; - "x-ably-capability": Record; - "x-ably-clientId"?: string; -} - export default class IssueJwtTokenCommand extends AblyBaseCommand { static description = "Creates an Ably JWT token with capabilities"; @@ -74,7 +65,7 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { } // Parse capabilities - let capabilities; + let capabilities: Record; try { capabilities = JSON.parse(flags.capability); } catch (error) { @@ -83,38 +74,37 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { ); } - // Create JWT payload - const jwtPayload: JwtPayload = { - exp: Math.floor(Date.now() / 1000) + flags.ttl, // expiration - iat: Math.floor(Date.now() / 1000), // issued at - jti: randomUUID(), // unique token ID - "x-ably-appId": appId, - "x-ably-capability": capabilities, - }; - - // Handle client ID - use special "none" value to explicitly indicate no clientId + // Determine client ID - use special "none" value to explicitly indicate no clientId let clientId: null | string = null; + let clientIdClaim: Record = {}; if (flags["client-id"]) { - if (flags["client-id"].toLowerCase() === "none") { - // No client ID - don't add it to the token - clientId = null; - } else { - // Use the provided client ID - jwtPayload["x-ably-clientId"] = flags["client-id"]; + if (flags["client-id"].toLowerCase() !== "none") { clientId = flags["client-id"]; + clientIdClaim = { "x-ably-clientId": clientId }; } } else { // Generate a default client ID - const defaultClientId = `ably-cli-${randomUUID().slice(0, 8)}`; - jwtPayload["x-ably-clientId"] = defaultClientId; - clientId = defaultClientId; + clientId = `ably-cli-${randomUUID().slice(0, 8)}`; + clientIdClaim = { "x-ably-clientId": clientId }; } - // Sign the JWT - const token = jwt.sign(jwtPayload, keySecret, { - algorithm: "HS256", - keyid: keyId, - }); + // Timestamps for display + const iat = Math.floor(Date.now() / 1000); + const exp = iat + flags.ttl; + + // Sign the JWT using jose's fluent API — standard claims via setters, + // Ably-specific custom claims passed directly to the constructor. + const secretBytes = new TextEncoder().encode(keySecret); + const token = await new SignJWT({ + "x-ably-appId": appId, + "x-ably-capability": capabilities, + ...clientIdClaim, + }) + .setProtectedHeader({ alg: "HS256", kid: keyId }) + .setIssuedAt(iat) + .setExpirationTime(exp) + .setJti(randomUUID()) + .sign(secretBytes); // If token-only flag is set, output just the token string if (flags["token-only"]) { @@ -129,8 +119,8 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { appId, capability: capabilities, clientId, - expires: new Date(jwtPayload.exp * 1000).toISOString(), - issued: new Date(jwtPayload.iat * 1000).toISOString(), + expires: new Date(exp * 1000).toISOString(), + issued: new Date(iat * 1000).toISOString(), keyId, token, ttl: flags.ttl, @@ -143,12 +133,12 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { this.log("Generated Ably JWT Token:"); this.log(`Token: ${token}`); this.log(`Type: JWT`); - this.log(`Issued: ${new Date(jwtPayload.iat * 1000).toISOString()}`); - this.log(`Expires: ${new Date(jwtPayload.exp * 1000).toISOString()}`); + this.log(`Issued: ${new Date(iat * 1000).toISOString()}`); + this.log(`Expires: ${new Date(exp * 1000).toISOString()}`); this.log(`TTL: ${flags.ttl} seconds`); this.log(`App ID: ${appId}`); this.log(`Key ID: ${keyId}`); - this.log(`Client ID: ${clientId || "None"}`); + this.log(`Client ID: ${clientId ?? "None"}`); this.log(`Capability: ${this.formatJsonOutput(capabilities, flags)}`); } } catch (error) { diff --git a/test/unit/commands/auth/issue-jwt-token.test.ts b/test/unit/commands/auth/issue-jwt-token.test.ts index 2ad108fc..cdefdd62 100644 --- a/test/unit/commands/auth/issue-jwt-token.test.ts +++ b/test/unit/commands/auth/issue-jwt-token.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect } from "vitest"; import { runCommand } from "@oclif/test"; -import jwt from "jsonwebtoken"; +import { jwtVerify } from "jose"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +async function verifyToken(token: string, secret: string) { + const secretBytes = new TextEncoder().encode(secret); + const { payload } = await jwtVerify(token, secretBytes, { + algorithms: ["HS256"], + }); + return payload as Record; +} + describe("auth:issue-jwt-token command", () => { describe("successful JWT token issuance", () => { it("should issue a JWT token successfully", async () => { @@ -37,9 +45,7 @@ describe("auth:issue-jwt-token command", () => { expect(token).toBeTruthy(); // Verify the token is a valid JWT - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }); + const decoded = await verifyToken(token, mockKeySecret); expect(decoded).toHaveProperty("x-ably-appId", appId); expect(decoded).toHaveProperty("x-ably-capability"); }); @@ -60,13 +66,15 @@ describe("auth:issue-jwt-token command", () => { ); const token = stdout.trim(); - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }) as any; + const decoded = await verifyToken(token, mockKeySecret); expect(decoded["x-ably-capability"]).toHaveProperty("chat:*"); - expect(decoded["x-ably-capability"]["chat:*"]).toContain("publish"); - expect(decoded["x-ably-capability"]["chat:*"]).toContain("subscribe"); + expect( + (decoded["x-ably-capability"] as Record)["chat:*"], + ).toContain("publish"); + expect( + (decoded["x-ably-capability"] as Record)["chat:*"], + ).toContain("subscribe"); }); it("should issue a token with custom TTL", async () => { @@ -80,12 +88,10 @@ describe("auth:issue-jwt-token command", () => { ); const token = stdout.trim(); - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }) as any; + const decoded = await verifyToken(token, mockKeySecret); // Check that exp - iat equals TTL - expect(decoded.exp - decoded.iat).toBe(ttl); + expect((decoded.exp as number) - (decoded.iat as number)).toBe(ttl); }); it("should issue a token with custom client ID", async () => { @@ -99,9 +105,7 @@ describe("auth:issue-jwt-token command", () => { ); const token = stdout.trim(); - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }) as any; + const decoded = await verifyToken(token, mockKeySecret); expect(decoded["x-ably-clientId"]).toBe(customClientId); }); @@ -115,9 +119,7 @@ describe("auth:issue-jwt-token command", () => { ); const token = stdout.trim(); - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }) as any; + const decoded = await verifyToken(token, mockKeySecret); expect(decoded["x-ably-clientId"]).toBeUndefined(); }); @@ -160,12 +162,12 @@ describe("auth:issue-jwt-token command", () => { ); const token = stdout.trim(); - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }) as any; + const decoded = await verifyToken(token, mockKeySecret); expect(decoded["x-ably-capability"]).toHaveProperty("*"); - expect(decoded["x-ably-capability"]["*"]).toContain("*"); + expect( + (decoded["x-ably-capability"] as Record)["*"], + ).toContain("*"); }); it("should generate token with default TTL of 1 hour", async () => { @@ -177,12 +179,10 @@ describe("auth:issue-jwt-token command", () => { ); const token = stdout.trim(); - const decoded = jwt.verify(token, mockKeySecret, { - algorithms: ["HS256"], - }) as any; + const decoded = await verifyToken(token, mockKeySecret); // Default TTL is 3600 seconds (1 hour) - expect(decoded.exp - decoded.iat).toBe(3600); + expect((decoded.exp as number) - (decoded.iat as number)).toBe(3600); }); });