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
31 changes: 31 additions & 0 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1637,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 Down
26 changes: 6 additions & 20 deletions src/commands/auth/issue-ably-token.ts
Original file line number Diff line number Diff line change
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
38 changes: 19 additions & 19 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 @@ -48,6 +50,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
description:
'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,
description:
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
157 changes: 43 additions & 114 deletions src/commands/auth/revoke-token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Flags } from "@oclif/core";
import * as https from "node:https";
import stripAnsi from "strip-ansi";

import { AblyBaseCommand } from "../../base-command.js";
Expand Down Expand Up @@ -97,128 +96,58 @@ export default class RevokeTokenCommand extends AblyBaseCommand {
}
}

try {
// Extract the keyName (appId.keyId) from the API key
const keyParts = apiKey.split(":");
if (keyParts.length !== 2) {
this.fail(
"Invalid API key format. Expected format: appId.keyId:secret",
flags,
"revokeToken",
);
}
let reauthNote = "";
if (flags["allow-reauth-margin"]) {
reauthNote =
" Connected clients have a 30s grace period to obtain new tokens before disconnection.";
}

const keyName = keyParts[0]!;
const secret = keyParts[1]!;
try {
const rest = await this.createAblyRestClient({
...flags,
"api-key": apiKey,
});
Comment on lines +105 to +109
if (!rest) return;

const requestBody: Record<string, unknown> = {
targets: [targetSpecifier],
};
const response = await rest.auth.revokeTokens(
[{ type: clientId ? "clientId" : "revocationKey", value: targetValue }],
flags["allow-reauth-margin"] ? { allowReauthMargin: true } : undefined,
);

let reauthNote = "";
if (flags["allow-reauth-margin"]) {
requestBody.allowReauthMargin = true;
reauthNote =
" Connected clients have a 30s grace period to obtain new tokens before disconnection.";
const failure = response.results.find((result) => "error" in result);
if (failure && "error" in failure) {
this.fail(failure.error, flags, "revokeToken", {
target: targetSpecifier,
});
}

try {
// Make direct HTTPS request to Ably REST API
const response = await this.makeHttpRequest(
keyName,
secret,
requestBody,
);
const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`;

if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
revocation: {
allowReauthMargin: flags["allow-reauth-margin"],
message: stripAnsi(successMessage),
target: targetSpecifier,
response,
},
const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`;

if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
revocation: {
allowReauthMargin: flags["allow-reauth-margin"],
message: stripAnsi(successMessage),
target: targetSpecifier,
response,
},
flags,
);
} else {
this.logSuccessMessage(successMessage, flags);
}
} catch (requestError: unknown) {
const error = requestError as Error & { statusCode?: number };
if (error.statusCode === 404) {
this.fail(
"No matching tokens found or already revoked",
flags,
"revokeToken",
);
}
throw requestError;
},
flags,
);
} else {
this.logSuccessMessage(successMessage, flags);
}
} catch (error) {
if ((error as { statusCode?: number }).statusCode === 404) {
this.fail(
"No matching tokens found or already revoked",
flags,
"revokeToken",
);
}

this.fail(error, flags, "revokeToken");
}
}

// Helper method to make a direct HTTP request to the Ably REST API
private makeHttpRequest(
keyName: string,
secret: string,
requestBody: Record<string, unknown>,
): Promise<Record<string, unknown> | string | null> {
return new Promise((resolve, reject) => {
const encodedAuth = Buffer.from(`${keyName}:${secret}`).toString(
"base64",
);

const options = {
headers: {
Accept: "application/json",
Authorization: `Basic ${encodedAuth}`,
"Content-Type": "application/json",
},
hostname: "rest.ably.io",
method: "POST",
path: `/keys/${keyName}/revokeTokens`,
port: 443,
};

const req = https.request(options, (res) => {
let data = "";

res.on("data", (chunk) => {
data += chunk;
});

res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
try {
const jsonResponse: Record<string, unknown> | null =
data.length > 0
? (JSON.parse(data) as Record<string, unknown>)
: null;
resolve(jsonResponse);
} catch {
resolve(data);
}
} else {
const err = new Error(
`Request failed with status code ${res.statusCode}: ${data}`,
) as Error & { statusCode?: number };
err.statusCode = res.statusCode;
reject(err);
}
});
});

req.on("error", (error) => {
reject(error);
});

req.write(JSON.stringify(requestBody));
req.end();
});
}
}
2 changes: 1 addition & 1 deletion src/services/client-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function validateClientId(value: string, source: ClientIdSource): void {

if (value === "*") {
throw new InvalidClientIdError(
`${origin} cannot be "*". The wildcard is only valid inside a token's capability; the CLI must act as one concrete client ID.`,
`${origin} cannot be "*". The CLI acts as, and issues tokens to, one concrete client ID.`,
);
}
}
Expand Down
6 changes: 6 additions & 0 deletions test/helpers/mock-ably-rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface MockRestAuth {
clientId: string;
createTokenRequest: Mock;
requestToken: Mock;
revokeTokens: Mock;
}

/**
Expand Down Expand Up @@ -232,6 +233,11 @@ function createMockRestAuth(): MockRestAuth {
token: "mock-token",
expires: Date.now() + 3600000,
}),
revokeTokens: vi.fn().mockResolvedValue({
successCount: 1,
failureCount: 0,
results: [],
}),
};
}

Expand Down
Loading