Skip to content
Draft
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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
108 changes: 8 additions & 100 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 30 additions & 40 deletions src/commands/auth/issue-jwt-token.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
"x-ably-clientId"?: string;
}

export default class IssueJwtTokenCommand extends AblyBaseCommand {
static description = "Creates an Ably JWT token with capabilities";

Expand Down Expand Up @@ -74,7 +65,7 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
}

// Parse capabilities
let capabilities;
let capabilities: Record<string, string[]>;
try {
capabilities = JSON.parse(flags.capability);
} catch (error) {
Expand All @@ -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<string, string> = {};
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,
Comment on lines +98 to +101
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Ably JWT authentication x-ably-capability claim format specification

💡 Result:

For an Ably JWT (JWT signed with your Ably API key secret), the x-ably-capability claim must be a string whose contents are JSON representing an Ably capability map (resource → list of operations). (ably.com)

Claim format (payload)

{
  "x-ably-capability": "{\"chat:*\": [\"publish\",\"subscribe\"], \"status\": [\"subscribe\",\"history\"]}"
}

x-ably-capability is not a JSON object in the JWT payload; it’s a JSON-encoded string. (ably.com)

Capability map JSON (inside the string)

Shape

{
  "<resource>": ["<operation>", "..."],
  "<resource2>": ["*"]
}

Resources

  • Channel or channel pattern (supports :-segment wildcards like namespace:*, and * for all channels). (ably.com)
  • Queue resource: starts with "[queue]" (e.g. "[queue]myapp-queue"), wildcard rules apply (e.g. "[queue]*"). (ably.com)
  • Metachannel resource: starts with "[meta]" (e.g. "[meta]metaname"), wildcard rules apply (e.g. "[meta]*"). (ably.com)
  • Special resource [*]* matches all channels, all queues, and all metachannels. (ably.com)

Operations
Use any of Ably’s capability operations, e.g. subscribe, publish, presence, history, stats, push-subscribe, push-admin, channel-metadata, etc. (or "*" for all operations). (ably.com)

Validation note

An empty string for x-ably-capability can cause JWT decode/validation failure. (faqs.ably.com)

Citations:


🏁 Script executed:

# Check the current implementation in the file
cat -n src/commands/auth/issue-jwt-token.ts | sed -n '90,110p'

Repository: ably/ably-cli

Length of output: 956


🏁 Script executed:

# Check the test file to see how x-ably-capability is being asserted
cat -n test/unit/commands/auth/issue-jwt-token.test.ts | sed -n '60,80p'

Repository: ably/ably-cli

Length of output: 847


🏁 Script executed:

# Also check lines around 167 mentioned in the review
cat -n test/unit/commands/auth/issue-jwt-token.test.ts | sed -n '160,175p'

Repository: ably/ably-cli

Length of output: 717


Encode x-ably-capability as JSON string, not object.

Line 100 passes capabilities as an object. According to Ably's JWT specification, x-ably-capability must be a JSON-encoded string within the JWT payload. Passing it as an object will cause tokens to fail Ably JWT validation at runtime.

      const token = await new SignJWT({
        "x-ably-appId": appId,
-       "x-ably-capability": capabilities,
+       "x-ably-capability": JSON.stringify(capabilities),
        ...clientIdClaim,
      })

Tests currently expect decoded["x-ably-capability"] as an object (lines 71 and 167), but this masks the bug. After fixing the code, tests must parse the claim string before accessing properties:

-     expect(decoded["x-ably-capability"]).toHaveProperty("chat:*");
+     const capabilityMap = JSON.parse(decoded["x-ably-capability"] as string);
+     expect(capabilityMap).toHaveProperty("chat:*");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const token = await new SignJWT({
"x-ably-appId": appId,
"x-ably-capability": capabilities,
...clientIdClaim,
const token = await new SignJWT({
"x-ably-appId": appId,
"x-ably-capability": JSON.stringify(capabilities),
...clientIdClaim,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/auth/issue-jwt-token.ts` around lines 98 - 101, The JWT payload
creation in the SignJWT call incorrectly includes capabilities as an object;
change the payload construction in issue-jwt-token (the SignJWT instantiation
that sets "x-ably-capability") to include JSON.stringify(capabilities) instead
of the raw object, and update any tests that inspect
decoded["x-ably-capability"] to JSON.parse that claim before accessing
properties (tests referencing decoded["x-ably-capability"] at the two failing
assertions should be modified to parse the string claim first).

})
.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"]) {
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
Loading
Loading