Skip to content
Open
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
2 changes: 2 additions & 0 deletions .claude/skills/ably-new-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ static flags = {

**When to include `clientIdFlag`:** Add `...clientIdFlag` to commands where client identity affects the operation: subscribe, publish, enter, set, acquire, update, delete, append, annotate. The reason is that users may want to test auth scenarios — e.g., "can client B update client A's message?" — so they need the ability to set their client ID. Do NOT add to read-only queries (get, get-all, history, occupancy get) — Ably capabilities are operation-based, not clientId-based, so client identity is irrelevant for pure reads.

**Target client IDs are not identity:** the base command treats `--client-id` as the client ID the CLI acts as only when the command declares it via the shared `clientIdFlag` object. When a command's `--client-id` names a *target* or *filter* (a push recipient, a token's subject), declare a command-local `Flags.string()` instead — never spread `clientIdFlag` for that — so the value can't become the CLI's own identity. `test/unit/base/client-id-target-guard.test.ts` enforces this for every command.

For history commands, also use `timeRangeFlags`:
```typescript
import { productApiFlags, timeRangeFlags } from "../../flags.js";
Expand Down
55 changes: 51 additions & 4 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
type ServerUrl,
} from "./utils/server-url.js";
import { errorMessage, getFriendlyAblyErrorHint } from "./utils/errors.js";
import { coreGlobalFlags } from "./flags.js";
import { clientIdFlag, coreGlobalFlags } from "./flags.js";
import { InteractiveHelper } from "./services/interactive-helper.js";
import { promptForConfirmation } from "./utils/prompt-confirmation.js";
import { BaseFlags, CommandConfig } from "./types/cli.js";
Expand Down Expand Up @@ -772,7 +772,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
}

try {
return resolveClientIdentity(flags["client-id"], this.configManager)
return resolveClientIdentity(this.identityFlag(flags), this.configManager)
.clientId;
} catch {
return undefined;
Expand Down Expand Up @@ -1578,14 +1578,30 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
this.setClientId(options, flags);
}

/**
* The --client-id value, but only when the command declares the shared
* identity flag. Push and token commands declare their own --client-id to
* name a target or filter, which must never become the CLI's own identity.
*/
private identityFlag(flags: BaseFlags): string | undefined {
const declared = (this.constructor as { flags?: Record<string, unknown> })
.flags?.["client-id"];
return declared === clientIdFlag["client-id"]
? flags["client-id"]
: undefined;
}

/**
* The client ID this command acts as under API-key auth. Fails the command
* on a value the CLI cannot act as, such as "" or "*".
*/
protected resolveClientId(flags: BaseFlags): string | undefined {
let identity;
try {
identity = resolveClientIdentity(flags["client-id"], this.configManager);
identity = resolveClientIdentity(
this.identityFlag(flags),
this.configManager,
);
} catch (error) {
if (error instanceof InvalidClientIdError) {
this.fail(error.message, flags, "clientId");
Expand Down Expand Up @@ -1621,6 +1637,37 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
return clientId;
}

/**
* The client ID a minted token is issued to: the command's --client-id, or
* the client ID the CLI acts as. "none" issues an anonymous token, with a
* warning, and "*" is refused rather than minting a token that can act as
* any client.
*/
protected resolveTokenClientId(flags: BaseFlags): string | undefined {
const requested = flags["client-id"];
if (requested === undefined) return this.resolveClientId(flags);

let identity;
try {
identity = resolveClientIdentity(requested, this.configManager);
} catch (error) {
if (error instanceof InvalidClientIdError) {
this.fail(error.message, flags, "clientId");
}

throw error;
}

if (identity.optedOut) {
this.logWarning(
"Issuing a token with no client ID. Apps that require identified clients reject it.",
flags,
);
}

return identity.clientId;
}

private setClientId(options: Ably.ClientOptions, flags: BaseFlags): void {
const clientId = this.resolveClientId(flags);
if (clientId !== undefined) {
Expand All @@ -1634,7 +1681,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
* only known once Ably accepts it.
*/
private applyTokenIdentity(token: string, flags: BaseFlags): void {
if (flags["client-id"] && !this.shouldSuppressOutput(flags)) {
if (this.identityFlag(flags) && !this.shouldSuppressOutput(flags)) {
this.logWarning(
"--client-id is ignored when using token authentication; the client ID comes from the token.",
flags,
Expand Down
28 changes: 7 additions & 21 deletions src/commands/auth/issue-ably-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand {
}),
"client-id": Flags.string({
description:
'Client ID to associate with the token. Use "none" to explicitly issue a token with no client ID, otherwise a default will be generated.',
'Client ID to issue the token to (defaults to the client ID the CLI acts as). Use "none" to issue a token with no client ID.',
}),
"token-only": Flags.boolean({
default: false,
Expand Down Expand Up @@ -81,18 +81,8 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand {
ttl: flags.ttl * 1000, // Convert to milliseconds for Ably SDK
};

// Handle client ID - use special "none" value to explicitly indicate no clientId
if (flags["client-id"]) {
if (flags["client-id"].toLowerCase() === "none") {
// No client ID - leave clientId undefined in the token params
} else {
// Use the provided client ID
tokenParams.clientId = flags["client-id"];
}
} else {
// Default to the identity the CLI itself acts as
tokenParams.clientId = this.resolveClientId(flags);
}
const clientId = this.resolveTokenClientId(flags);
if (clientId !== undefined) tokenParams.clientId = clientId;

// Create Ably REST client and request token
const rest = await this.createAblyRestClient(
Expand Down Expand Up @@ -126,9 +116,7 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand {
value: tokenDetails.token,
issuedAt: new Date(tokenDetails.issued).toISOString(),
expiresAt: new Date(tokenDetails.expires).toISOString(),
...(tokenDetails.clientId
? { clientId: tokenDetails.clientId }
: {}),
clientId: tokenDetails.clientId ?? null,
capability: tokenDetails.capability,
Comment on lines +119 to 120
},
},
Expand All @@ -145,11 +133,9 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand {
`${formatLabel("Expires")} ${new Date(tokenDetails.expires).toISOString()}`,
);
this.log(`${formatLabel("TTL")} ${flags.ttl} seconds`);
if (tokenDetails.clientId) {
this.log(
`${formatLabel("Client ID")} ${formatClientId(tokenDetails.clientId)}`,
);
}
this.log(
`${formatLabel("Client ID")} ${tokenDetails.clientId ? formatClientId(tokenDetails.clientId) : "anonymous"}`,
);
this.log(
`${formatLabel("Capability")} ${this.formatJsonOutput({ capability: tokenDetails.capability }, flags)}`,
);
Expand Down
40 changes: 20 additions & 20 deletions src/commands/auth/issue-jwt-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface JwtPayload {
"x-ably-appId": string;
"x-ably-capability": Record<string, string[]>;
"x-ably-clientId"?: string;
"x-ably-clientType"?: "server";
}

export default class IssueJwtTokenCommand extends AblyBaseCommand {
Expand All @@ -27,6 +28,7 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
'$ ably auth issue-jwt-token --capability \'{"*":["*"]}\'',
'$ ably auth issue-jwt-token --capability \'{"chat:*":["publish","subscribe"], "status:*":["subscribe"]}\' --ttl 3600',
"$ ably auth issue-jwt-token --client-id client123 --ttl 86400",
"$ ably auth issue-jwt-token --client-type server --client-id backend-worker",
"$ ably auth issue-jwt-token --json",
"$ ably auth issue-jwt-token --pretty-json",
"$ ably auth issue-jwt-token --token-only",
Expand All @@ -46,7 +48,12 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
}),
"client-id": Flags.string({
description:
'Client ID to associate with the token. Use "none" to explicitly issue a token with no client ID, otherwise a default will be generated.',
'Client ID to issue the token to (defaults to the client ID the CLI acts as). Use "none" to issue a token with no client ID.',
}),
"client-type": Flags.string({
description:
"Classify clients using the token as servers, exempt from MAU counting, by adding the signed x-ably-clientType claim",
options: ["server"],
}),
"token-only": Flags.boolean({
default: false,
Expand Down Expand Up @@ -102,22 +109,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
"x-ably-capability": capabilities,
};

// Handle client ID - use special "none" value to explicitly indicate no clientId
let clientId: null | string = null;
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"];
clientId = flags["client-id"];
}
} else {
// Default to the identity the CLI itself acts as
clientId = this.resolveClientId(flags) ?? null;
if (clientId) jwtPayload["x-ably-clientId"] = clientId;
}
const clientId = this.resolveTokenClientId(flags);
if (clientId !== undefined) jwtPayload["x-ably-clientId"] = clientId;

const clientType = flags["client-type"] as "server" | undefined;
if (clientType) jwtPayload["x-ably-clientType"] = clientType;

// Sign the JWT
const token = jwt.sign(jwtPayload, keySecret, {
Expand All @@ -141,7 +137,8 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
token: {
appId,
capability: capabilities,
...(clientId ? { clientId } : {}),
clientId: clientId ?? null,
...(clientType ? { clientType } : {}),
Comment on lines +140 to +141
expires: new Date(jwtPayload.exp * 1000).toISOString(),
issued: new Date(jwtPayload.iat * 1000).toISOString(),
keyId,
Expand All @@ -165,8 +162,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
this.log(`${formatLabel("TTL")} ${flags.ttl} seconds`);
this.log(`${formatLabel("App ID")} ${appId}`);
this.log(`${formatLabel("Key ID")} ${keyId}`);
if (clientId) {
this.log(`${formatLabel("Client ID")} ${formatClientId(clientId)}`);
this.log(
`${formatLabel("Client ID")} ${clientId ? formatClientId(clientId) : "anonymous"}`,
);
if (clientType) {
this.log(`${formatLabel("Client Type")} ${clientType}`);
}
this.log(
`${formatLabel("Capability")} ${this.formatJsonOutput(capabilities, flags)}`,
Expand Down
Loading