Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/Environment-Variables/General-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ These environment variables are most commonly used during development as well as
| `ABLY_TOKEN` | Authentication | Token/JWT for data plane commands | None |
| `ABLY_ACCESS_TOKEN` | Authentication | Access token for Control API commands | None |
| `ABLY_APP_ID` | App Selection | Default app for `--app` flag | None |
| `ABLY_CLIENT_ID` | Configuration | Client ID the CLI acts as | Generated once per install |
| `ABLY_CLI_CONFIG_DIR` | Configuration | Custom config directory | `~/.ably` |
| `ABLY_HISTORY_FILE` | Configuration | Custom history file location | `~/.ably/history` |
| `ABLY_CLI_DEFAULT_DURATION` | Behavior | Auto-exit long-running commands (seconds) | None (forever) |
Expand Down
1 change: 1 addition & 0 deletions docs/Project-Structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ This document outlines the directory structure of the Ably CLI project.
│ │ ├── command_not_found/ # Fuzzy-match suggestions for unknown commands
│ │ └── init/ # CLI initialization
│ ├── services/ # Business logic
│ │ ├── client-identity.ts # Resolves the client ID the CLI acts as
│ │ ├── config-manager.ts # CLI configuration (accounts, apps, API keys)
│ │ ├── control-api.ts # Ably Control API HTTP client
│ │ ├── history-manager.ts # Interactive mode command history persistence
Expand Down
221 changes: 199 additions & 22 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@ import { InteractiveBaseCommand } from "./interactive-base-command.js";
import type * as Ably from "@ably/pubsub-server";
import { createHttpClient, createRealtimeClient } from "@ably/pubsub-server";
import chalk from "chalk";
import colorJson from "color-json";
import { randomUUID } from "node:crypto";
import colorJson from "color-json";

import {
ConfigManager,
createConfigManager,
type DataPlaneConfig,
} from "./services/config-manager.js";
import { ControlApi, controlHostScheme } from "./services/control-api.js";
import {
CLIENT_ID_CLAIM,
CLIENT_ID_ENV_VAR,
InvalidClientIdError,
type ClientIdentity,
describeClientIdSource,
readTokenIdentity,
resolveClientIdentity,
} from "./services/client-identity.js";
import {
extractAppIdFromApiKey,
extractKeyNameFromApiKey,
Expand Down Expand Up @@ -146,6 +155,17 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
private _cachedRestClient: Ably.PubSubHttpClient | null = null;
private _cachedRealtimeClient: Ably.PubSubRealtimeClient | null = null;

/**
* Suffix the default client ID per process, for commands that run as many
* concurrent processes (bench), so they stay under the per-client-ID
* connection cap. An explicit --client-id is always used as given.
*/
protected suffixClientIdPerProcess = false;

// The identity this command acts as, suffix included, so the banner and
// every client this command builds agree on it.
private actingIdentity: ClientIdentity | undefined;

// Core global flags available to all commands (verbose, json, pretty-json, web-cli-help)
static globalFlags = { ...coreGlobalFlags };

Expand Down Expand Up @@ -727,6 +747,13 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
);
}
}

const clientId = this.bannerClientId(flags);
if (clientId) {
displayParts.push(
`${chalk.blue("Client=")}${chalk.blue.bold(clientId)}`,
);
}
}
}

Expand All @@ -740,6 +767,49 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
}
}

/**
* The client ID shown in the banner. Invalid values are reported when the
* client is built, so the banner just omits them.
*/
private bannerClientId(flags: BaseFlags): string | undefined {
if (process.env.ABLY_TOKEN) {
const identity = readTokenIdentity(process.env.ABLY_TOKEN);
if (identity.kind !== "jwt") return undefined;
if (identity.clientId !== "*") return identity.clientId;
}

try {
return this.resolveActingIdentity(flags).clientId;
} catch {
return undefined;
}
}

/**
* The identity this command acts as under API-key auth or a wildcard
* token, resolved once.
*
* @throws InvalidClientIdError when the winning value is empty or "*".
*/
private resolveActingIdentity(flags: BaseFlags): ClientIdentity {
if (this.actingIdentity) return this.actingIdentity;

const identity = resolveClientIdentity(
flags["client-id"],
this.configManager,
);
this.actingIdentity =
identity.clientId &&
this.suffixClientIdPerProcess &&
identity.source !== "flag"
? {
...identity,
clientId: `${identity.clientId}-${randomUUID().slice(0, 8)}`,
}
: identity;
return this.actingIdentity;
}

/**
* Resolve host, port and TLS for the data plane from the first source that
* supplies them: `--url`, `ABLY_URL`, `ABLY_ENDPOINT`, then the account
Expand Down Expand Up @@ -1157,15 +1227,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
// Handle authentication: ABLY_TOKEN env → flags["api-key"] (set by ensureAppAndKey) → ABLY_API_KEY env → config
if (process.env.ABLY_TOKEN) {
options.token = process.env.ABLY_TOKEN;

// When using token auth, we don't set the clientId as it may conflict
// with any clientId embedded in the token
if (flags["client-id"] && !this.shouldSuppressOutput(flags)) {
this.logWarning(
"clientId is ignored when using token authentication as the clientId is embedded in the token.",
flags,
);
}
this.applyTokenIdentity(options, process.env.ABLY_TOKEN, flags);
} else if (flags["api-key"]) {
this.applyApiKeyAuth(options, flags["api-key"], flags);
} else if (process.env.ABLY_API_KEY) {
Expand All @@ -1174,8 +1236,6 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
const apiKey = this.configManager.getApiKey();
if (apiKey) {
options.key = apiKey;

// Handle client ID for API key auth
this.setClientId(options, flags);
}
}
Expand Down Expand Up @@ -1549,18 +1609,135 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
this.setClientId(options, flags);
}

private setClientId(options: Ably.ClientOptions, flags: BaseFlags): void {
if (flags["client-id"]) {
// Special case: "none" means explicitly no client ID
if (flags["client-id"].toLowerCase() === "none") {
// Don't set clientId at all
} else {
options.clientId = flags["client-id"];
/**
* 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 {
const identity = this.validatedIdentity(flags, () =>
this.resolveActingIdentity(flags),
);

this.logCliEvent(
flags,
"clientId",
"resolved",
identity.clientId
? `Acting as client ID ${identity.clientId} (from ${identity.source}).`
: "Acting with no client ID.",
{ clientId: identity.clientId, source: identity.source },
);

return identity.clientId;
}

/**
* The client ID to bind a minted token to: --client-id when given, which
* may be "*" to let the holder assume any identity, otherwise the identity
* the CLI itself acts as.
*/
protected resolveTokenClientId(flags: BaseFlags): string | undefined {
if (flags["client-id"] === undefined) return this.resolveClientId(flags);

const identity = this.validatedIdentity(flags, () =>
resolveClientIdentity(flags["client-id"], this.configManager, {
allowWildcard: true,
}),
);

// The client that issues the token acts as the token's identity, so the
// default identity is never resolved. It cannot act as "*", and only
// signs with the key, so it then acts with no client ID. The "none"
// warning has already been shown.
this.actingIdentity = {
...identity,
clientId: identity.clientId === "*" ? undefined : identity.clientId,
optedOut: false,
};

return identity.clientId;
}

/**
* Resolve an identity, failing the command on an invalid value and warning
* when the deprecated "none" opt-out was used.
*/
private validatedIdentity(
flags: BaseFlags,
resolve: () => ClientIdentity,
): ClientIdentity {
let identity;
try {
identity = resolve();
} catch (error) {
if (error instanceof InvalidClientIdError) {
this.fail(error.message, flags, "clientId");
}
} else {
// Generate a default client ID for the CLI
options.clientId = `ably-cli-${randomUUID().slice(0, 8)}`;

throw error;
}

if (identity.optedOut) {
this.logWarning(
`Acting with no client ID ("none" from ${describeClientIdSource(identity.source)}) is deprecated: apps that require identified clients reject it. Remove it to use your default client ID, or set ${CLIENT_ID_ENV_VAR}.`,
flags,
);
}

return identity;
}

private setClientId(options: Ably.ClientOptions, flags: BaseFlags): void {
const clientId = this.resolveClientId(flags);
if (clientId !== undefined) {
options.clientId = clientId;
}
}

/**
* Under token auth the identity is the token's, never overridden. A JWT
* must carry a clientId; a native Ably token is opaque, so its identity is
* only known once Ably accepts it. A wildcard ("*") JWT lets its holder
* pick an identity, so the CLI acts as the one it would under API-key auth.
*/
private applyTokenIdentity(
options: Ably.ClientOptions,
token: string,
flags: BaseFlags,
): void {
const identity = readTokenIdentity(token);
if (identity.kind === "jwt" && identity.clientId === "*") {
this.setClientId(options, flags);
return;
}

if (flags["client-id"] && !this.shouldSuppressOutput(flags)) {
this.logWarning(
"--client-id is ignored when using token authentication; the client ID comes from the token.",
flags,
);
}

if (identity.kind === "jwt" && identity.clientId === undefined) {
this.fail(
`The JWT in ABLY_TOKEN has no ${CLIENT_ID_CLAIM} claim. Tokens used with the CLI must carry a client ID; re-issue it with "ably auth issue-jwt-token --client-id <id>".`,
flags,
"auth",
);
}

this.logCliEvent(
flags,
"clientId",
"resolved",
identity.kind === "jwt"
? `Acting as client ID ${identity.clientId} (from token).`
: "Acting as the client ID bound to the token.",
{
clientId: identity.kind === "jwt" ? identity.clientId : undefined,
source: "token",
},
);
}

/**
Expand Down
17 changes: 3 additions & 14 deletions src/commands/auth/issue-ably-token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Flags } from "@oclif/core";
import type * as Ably from "@ably/pubsub-server";
import { randomUUID } from "node:crypto";

import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags } from "../../flags.js";
Expand Down Expand Up @@ -38,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 bind the token to. Use "*" to let the token holder act as any client ID. Defaults to the client ID the CLI acts as.',
}),
"token-only": Flags.boolean({
default: false,
Expand Down Expand Up @@ -82,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 {
// Generate a default client ID
tokenParams.clientId = `ably-cli-${randomUUID().slice(0, 8)}`;
}
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
21 changes: 3 additions & 18 deletions src/commands/auth/issue-jwt-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ 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 bind the token to. Use "*" to let the token holder act as any client ID. Defaults to the client ID the CLI acts as.',
}),
"token-only": Flags.boolean({
default: false,
Expand Down Expand Up @@ -102,23 +102,8 @@ 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 {
// Generate a default client ID
const defaultClientId = `ably-cli-${randomUUID().slice(0, 8)}`;
jwtPayload["x-ably-clientId"] = defaultClientId;
clientId = defaultClientId;
}
const clientId = this.resolveTokenClientId(flags) ?? null;
if (clientId) jwtPayload["x-ably-clientId"] = clientId;

// Sign the JWT
const token = jwt.sign(jwtPayload, keySecret, {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/bench/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ interface BenchmarkPayload {
}

export default class BenchPublisher extends AblyBaseCommand {
protected override suffixClientIdPerProcess = true;

static override args = {
channelName: Args.string({
description: "The channel name to publish to",
Expand Down
2 changes: 2 additions & 0 deletions src/commands/bench/subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface TestMetrics {
}

export default class BenchSubscriber extends AblyBaseCommand {
protected override suffixClientIdPerProcess = true;

static override args = {
channelName: Args.string({
description: "The channel name to subscribe to",
Expand Down
Loading