diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index f1575dda..3c8be080 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -1,14 +1,17 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import * as readline from "node:readline"; -import open from "open"; +import ora from "ora"; import { ControlBaseCommand } from "../../control-base-command.js"; import { ControlApi } from "../../services/control-api.js"; +import { OAuthClient, type OAuthTokens } from "../../services/oauth-client.js"; +import { BaseFlags } from "../../types/cli.js"; import { displayLogo } from "../../utils/logo.js"; +import openUrl from "../../utils/open-url.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; +import { slugifyAccountName } from "../../utils/slugify.js"; -// Moved function definition outside the class function validateAndGetAlias( input: string, logFn: (msg: string) => void, @@ -51,6 +54,7 @@ export default class AccountsLogin extends ControlBaseCommand { static override examples = [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --alias mycompany", + "<%= config.bin %> <%= command.id %> --no-browser", "<%= config.bin %> <%= command.id %> --json", "<%= config.bin %> <%= command.id %> --pretty-json", ]; @@ -76,89 +80,15 @@ export default class AccountsLogin extends ControlBaseCommand { } let accessToken: string; + let oauthTokens: OAuthTokens | undefined; + if (args.token) { + // Direct token provided as argument accessToken = args.token; } else { - let obtainTokenPath = "https://ably.com/users/access_tokens"; - if (flags["control-host"]) { - if (!this.shouldOutputJson(flags)) { - this.log("Using control host:", flags["control-host"]); - } - - obtainTokenPath = flags["control-host"].includes("local") - ? `http://${flags["control-host"]}/users/access_tokens` - : `https://${flags["control-host"]}/users/access_tokens`; - } - - // Prompt the user to get a token - if (!flags["no-browser"]) { - if (!this.shouldOutputJson(flags)) { - this.log("Opening browser to get an access token..."); - } - - await this.openBrowser(obtainTokenPath); - } else if (!this.shouldOutputJson(flags)) { - this.log(`Please visit ${obtainTokenPath} to create an access token`); - } - - accessToken = await this.promptForToken(); - } - - // If no alias flag provided, prompt the user if they want to provide one - let { alias } = flags; - if (!alias && !this.shouldOutputJson(flags)) { - // Check if the default account already exists - const accounts = this.configManager.listAccounts(); - const hasDefaultAccount = accounts.some( - (account) => account.alias === "default", - ); - - if (hasDefaultAccount) { - // Explain to the user the implications of not providing an alias - this.log("\nYou have not specified an alias for this account."); - this.log( - "If you continue without an alias, your existing default account configuration will be overwritten.", - ); - this.log( - "To maintain multiple account profiles, please provide an alias.", - ); - - // Ask if they want to provide an alias - const shouldProvideAlias = await promptForConfirmation( - "Would you like to provide an alias for this account?", - ); - - if (shouldProvideAlias) { - alias = await this.promptForAlias(); - } else { - alias = "default"; - this.log( - "No alias provided. The default account configuration will be overwritten.", - ); - } - } else { - // No default account exists yet, but still offer to set an alias - this.log("\nYou have not specified an alias for this account."); - this.log( - "Using an alias allows you to maintain multiple account profiles that you can switch between.", - ); - - // Ask if they want to provide an alias - const shouldProvideAlias = await promptForConfirmation( - "Would you like to provide an alias for this account?", - ); - - if (shouldProvideAlias) { - alias = await this.promptForAlias(); - } else { - alias = "default"; - this.log( - "No alias provided. This will be set as your default account.", - ); - } - } - } else if (!alias) { - alias = "default"; + // OAuth device flow (default) + oauthTokens = await this.oauthLogin(flags); + accessToken = oauthTokens.accessToken; } try { @@ -168,15 +98,54 @@ export default class AccountsLogin extends ControlBaseCommand { controlHost: flags["control-host"], }); - const { account, user } = await controlApi.getMe(); + const [{ user }, accounts] = await Promise.all([ + controlApi.getMe(), + controlApi.getAccounts(), + ]); - // Store the account information - this.configManager.storeAccount(accessToken, alias, { - accountId: account.id, - accountName: account.name, - tokenId: "unknown", // Token ID is not returned by getMe(), would need additional API if needed - userEmail: user.email, - }); + let selectedAccountInfo: { id: string; name: string }; + + if (accounts.length === 1) { + selectedAccountInfo = accounts[0]; + } else if (accounts.length > 1 && !this.shouldOutputJson(flags)) { + const picked = + await this.interactiveHelper.selectAccountFromApi(accounts); + selectedAccountInfo = picked ?? accounts[0]; + } else { + // Multiple accounts in JSON mode or empty (backward compat: use first) + selectedAccountInfo = accounts[0]; + } + + // Resolve alias AFTER account selection so we can default to account name + let { alias } = flags; + if (!alias && !this.shouldOutputJson(flags)) { + alias = await this.resolveAlias(selectedAccountInfo.name); + } else if (!alias) { + alias = slugifyAccountName(selectedAccountInfo.name); + } + + // Store based on auth method + if (oauthTokens) { + this.configManager.storeOAuthTokens(alias, oauthTokens, { + accountId: selectedAccountInfo.id, + accountName: selectedAccountInfo.name, + }); + } else { + this.configManager.storeAccount(accessToken, alias, { + accountId: selectedAccountInfo.id, + accountName: selectedAccountInfo.name, + tokenId: "unknown", + userEmail: user.email, + }); + } + + // Persist control host so other commands (like switch) can use it + if (flags["control-host"]) { + this.configManager.setAccountControlHost( + alias, + flags["control-host"] as string, + ); + } // Switch to this account this.configManager.switchAccount(alias); @@ -223,28 +192,24 @@ export default class AccountsLogin extends ControlBaseCommand { const app = await controlApi.createApp({ name: appName, - tlsOnly: true, // Default to true for security + tlsOnly: true, }); selectedApp = app; - isAutoSelected = true; // Consider this auto-selected since it's the only one + isAutoSelected = true; - // Set as current app this.configManager.setCurrentApp(app.id); this.configManager.storeAppInfo(app.id, { appName: app.name }); - this.log(`${chalk.green("✓")} App created successfully!`); + this.log(`${chalk.green("\u2713")} App created successfully!`); } catch (createError) { this.warn( `Failed to create app: ${createError instanceof Error ? createError.message : String(createError)}`, ); - // Continue with login even if app creation fails } } } - // If apps.length === 0 and JSON mode, or user declined to create app, do nothing } catch (error) { - // Don't fail login if app fetching fails, just log for debugging if (!this.shouldOutputJson(flags)) { this.warn( `Could not fetch apps: ${error instanceof Error ? error.message : String(error)}`, @@ -260,7 +225,6 @@ export default class AccountsLogin extends ControlBaseCommand { const keys = await controlApi.listKeys(selectedApp.id); if (keys.length === 1) { - // Auto-select the only key selectedKey = keys[0]; isKeyAutoSelected = true; this.configManager.storeAppKey(selectedApp.id, selectedKey.key, { @@ -268,7 +232,6 @@ export default class AccountsLogin extends ControlBaseCommand { keyName: selectedKey.name || "Unnamed key", }); } else if (keys.length > 1) { - // Prompt user to select a key when multiple exist this.log("\nSelect an API key to use:"); selectedKey = await this.interactiveHelper.selectKey( @@ -283,9 +246,7 @@ export default class AccountsLogin extends ControlBaseCommand { }); } } - // If keys.length === 0, continue without key (should be rare for newly created apps) } catch (keyError) { - // Don't fail login if key fetching fails this.warn( `Could not fetch API keys: ${keyError instanceof Error ? keyError.message : String(keyError)}`, ); @@ -293,57 +254,38 @@ export default class AccountsLogin extends ControlBaseCommand { } if (this.shouldOutputJson(flags)) { - const response: { - account: { - alias: string; - id: string; - name: string; - user: { email: string }; - }; - success: boolean; - app?: { - id: string; - name: string; - autoSelected: boolean; - }; - key?: { - id: string; - name: string; - autoSelected: boolean; - }; - } = { + const response: Record = { account: { alias, - id: account.id, - name: account.name, - user: { - email: user.email, - }, + id: selectedAccountInfo.id, + name: selectedAccountInfo.name, + user: { email: user.email }, }, + authMethod: oauthTokens ? "oauth" : "token", success: true, }; - if (selectedApp) { response.app = { + autoSelected: isAutoSelected, id: selectedApp.id, name: selectedApp.name, - autoSelected: isAutoSelected, }; - if (selectedKey) { response.key = { + autoSelected: isKeyAutoSelected, id: selectedKey.id, name: selectedKey.name || "Unnamed key", - autoSelected: isKeyAutoSelected, }; } } - this.log(this.formatJsonOutput(response, flags)); } else { this.log( - `Successfully logged in to ${chalk.cyan(account.name)} (account ID: ${chalk.greenBright(account.id)})`, + `Successfully logged in to ${chalk.cyan(selectedAccountInfo.name)} (account ID: ${chalk.greenBright(selectedAccountInfo.id)})`, ); + if (oauthTokens) { + this.log(`Authenticated via OAuth (token auto-refreshes)`); + } if (alias !== "default") { this.log(`Account stored with alias: ${alias}`); } @@ -352,15 +294,15 @@ export default class AccountsLogin extends ControlBaseCommand { if (selectedApp) { const message = isAutoSelected - ? `${chalk.green("✓")} Automatically selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})` - : `${chalk.green("✓")} Selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})`; + ? `${chalk.green("\u2713")} Automatically selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})` + : `${chalk.green("\u2713")} Selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})`; this.log(message); } if (selectedKey) { const keyMessage = isKeyAutoSelected - ? `${chalk.green("✓")} Automatically selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})` - : `${chalk.green("✓")} Selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})`; + ? `${chalk.green("\u2713")} Automatically selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})` + : `${chalk.green("\u2713")} Selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})`; this.log(keyMessage); } } @@ -380,39 +322,106 @@ export default class AccountsLogin extends ControlBaseCommand { } } - private async openBrowser(url: string): Promise { + private async oauthLogin(flags: BaseFlags): Promise { + const oauthClient = new OAuthClient({ + controlHost: flags["control-host"], + }); + + const deviceResponse = await oauthClient.requestDeviceCode(); + + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + status: "awaiting_authorization", + userCode: deviceResponse.userCode, + verificationUri: deviceResponse.verificationUri, + verificationUriComplete: deviceResponse.verificationUriComplete, + }, + flags, + ), + ); + } else { + this.log(""); + this.log( + ` Your authorization code: ${chalk.bold.cyan(deviceResponse.userCode)}`, + ); + this.log(""); + this.log( + ` Visit: ${chalk.underline(deviceResponse.verificationUriComplete)}`, + ); + this.log(""); + } + + if (!flags["no-browser"]) { + await openUrl(deviceResponse.verificationUriComplete, this); + } else if (!this.shouldOutputJson(flags)) { + this.log("Open the URL above in your browser to authorize."); + } + + const spinner = this.shouldOutputJson(flags) + ? undefined + : ora("Waiting for authorization...").start(); + try { - // Use the 'open' package for cross-platform browser opening - // This handles platform differences safely and avoids shell injection - await open(url); + const tokens = await oauthClient.pollForToken( + deviceResponse.deviceCode, + deviceResponse.interval, + deviceResponse.expiresIn, + ); + + spinner?.succeed("Authentication successful!"); + return tokens; } catch (error) { - this.warn(`Failed to open browser: ${error}`); - this.log(`Please visit ${url} manually to create an access token`); + spinner?.fail("Authentication failed"); + throw error; } } - private promptForAlias(): Promise { + private async resolveAlias(accountName: string): Promise { + const defaultAlias = slugifyAccountName(accountName); + const existingAccounts = this.configManager.listAccounts(); + const aliasExists = existingAccounts.some((a) => a.alias === defaultAlias); + + if (aliasExists) { + this.log( + `\nAn account with alias "${defaultAlias}" already exists and will be overwritten.`, + ); + const shouldCustomize = await promptForConfirmation( + "Would you like to use a different alias?", + ); + if (shouldCustomize) { + return this.promptForAlias(defaultAlias); + } + return defaultAlias; + } + + return this.promptForAlias(defaultAlias); + } + + private promptForAlias(defaultAlias: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); - // Pass this.log as the logging function to the external validator const logFn = this.log.bind(this); return new Promise((resolve) => { const askForAlias = () => { rl.question( - 'Enter an alias for this account (e.g. "dev", "production", "personal"): ', - (alias) => { - // Use the external validator function, passing the log function - const validatedAlias = validateAndGetAlias(alias, logFn); + `Enter an alias for this account [${defaultAlias}]: `, + (input) => { + // Accept default on empty input + if (!input.trim()) { + rl.close(); + resolve(defaultAlias); + return; + } - if (validatedAlias === null) { - if (!alias.trim()) { - logFn("Error: Alias cannot be empty"); // Use logFn here too - } + const validatedAlias = validateAndGetAlias(input, logFn); + if (validatedAlias === null) { askForAlias(); } else { rl.close(); @@ -450,18 +459,4 @@ export default class AccountsLogin extends ControlBaseCommand { askForAppName(); }); } - - private promptForToken(): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question("\nEnter your access token: ", (token) => { - rl.close(); - resolve(token.trim()); - }); - }); - } } diff --git a/src/commands/accounts/logout.ts b/src/commands/accounts/logout.ts index 776661e3..20628fe9 100644 --- a/src/commands/accounts/logout.ts +++ b/src/commands/accounts/logout.ts @@ -1,6 +1,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { OAuthClient } from "../../services/oauth-client.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class AccountsLogout extends ControlBaseCommand { @@ -88,6 +89,21 @@ export default class AccountsLogout extends ControlBaseCommand { } } + // Revoke OAuth tokens if this is an OAuth account + if (this.configManager.getAuthMethod(targetAlias) === "oauth") { + const oauthTokens = this.configManager.getOAuthTokens(targetAlias); + if (oauthTokens) { + const oauthClient = new OAuthClient({ + controlHost: flags["control-host"], + }); + // Best-effort revocation -- don't block on failure + await Promise.all([ + oauthClient.revokeToken(oauthTokens.accessToken), + oauthClient.revokeToken(oauthTokens.refreshToken), + ]).catch(() => {}); + } + } + // Remove the account const success = this.configManager.removeAccount(targetAlias); diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index c47feb4e..f956b627 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -1,7 +1,10 @@ import { Args } from "@oclif/core"; +import chalk from "chalk"; +import inquirer from "inquirer"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { ControlApi } from "../../services/control-api.js"; +import { ControlApi, type AccountSummary } from "../../services/control-api.js"; +import { slugifyAccountName } from "../../utils/slugify.js"; export default class AccountsSwitch extends ControlBaseCommand { static override args = { @@ -27,17 +30,14 @@ export default class AccountsSwitch extends ControlBaseCommand { public async run(): Promise { const { args, flags } = await this.parse(AccountsSwitch); - // Get available accounts - const accounts = this.configManager.listAccounts(); + const localAccounts = this.configManager.listAccounts(); - if (accounts.length === 0) { - // No accounts configured, proxy to login command + if (localAccounts.length === 0) { if (this.shouldOutputJson(flags)) { - const error = - 'No accounts configured. Use "ably accounts login" to add an account.'; this.jsonError( { - error, + error: + 'No accounts configured. Use "ably accounts login" to add an account.', success: false, }, flags, @@ -45,7 +45,6 @@ export default class AccountsSwitch extends ControlBaseCommand { return; } - // In interactive mode, proxy to login this.log("No accounts configured. Redirecting to login..."); await this.config.runCommand("accounts:login"); return; @@ -53,22 +52,21 @@ export default class AccountsSwitch extends ControlBaseCommand { // If alias is provided, switch directly if (args.alias) { - await this.switchToAccount(args.alias, accounts, flags); + await this.switchToLocalAccount(args.alias, localAccounts, flags); return; } - // Otherwise, show interactive selection if not in JSON mode + // JSON mode requires an explicit alias if (this.shouldOutputJson(flags)) { - const error = - "No account alias provided. Please specify an account alias to switch to."; this.jsonError( { - availableAccounts: accounts.map(({ account, alias }) => ({ + availableAccounts: localAccounts.map(({ account, alias }) => ({ alias, id: account.accountId || "Unknown", name: account.accountName || "Unknown", })), - error, + error: + "No account alias provided. Please specify an account alias to switch to.", success: false, }, flags, @@ -76,17 +74,159 @@ export default class AccountsSwitch extends ControlBaseCommand { return; } - this.log("Select an account to switch to:"); - const selectedAccount = await this.interactiveHelper.selectAccount(); + // Interactive mode: show local aliases + remote accounts + const selected = await this.interactiveSwitch(localAccounts, flags); - if (selectedAccount) { - await this.switchToAccount(selectedAccount.alias, accounts, flags); - } else { + if (!selected) { this.log("Account switch cancelled."); } } - private async switchToAccount( + private async interactiveSwitch( + localAccounts: Array<{ + account: { + accountId?: string; + accountName?: string; + userEmail?: string; + }; + alias: string; + }>, + flags: Record, + ): Promise { + const currentAlias = this.configManager.getCurrentAccountAlias(); + + // Try to fetch remote accounts using the current token + let remoteAccounts: AccountSummary[] = []; + const accessToken = this.configManager.getAccessToken(); + if (accessToken) { + try { + const currentAccount = this.configManager.getCurrentAccount(); + const controlHost = + (flags["control-host"] as string | undefined) || + currentAccount?.controlHost; + const controlApi = new ControlApi({ + accessToken, + controlHost, + }); + remoteAccounts = await controlApi.getAccounts(); + } catch { + // Couldn't fetch remote accounts — fall back to local only + } + } + + // Build local account IDs set for deduplication + const localAccountIds = new Set( + localAccounts.map((a) => a.account.accountId).filter(Boolean), + ); + + // Remote accounts not already configured locally + const remoteOnly = remoteAccounts.filter((r) => !localAccountIds.has(r.id)); + + type Choice = { + name: string; + value: + | { type: "local"; alias: string } + | { type: "remote"; account: AccountSummary }; + }; + + const choices: Array = []; + + // Local accounts section + if (localAccounts.length > 0) { + choices.push(new inquirer.Separator("── Local accounts ──")); + for (const { account, alias } of localAccounts) { + const isCurrent = alias === currentAlias; + const name = account.accountName || account.accountId || "Unknown"; + const label = `${isCurrent ? "* " : " "}${alias} ${chalk.dim(`(${name})`)}`; + choices.push({ name: label, value: { type: "local", alias } }); + } + } + + // Remote-only accounts section + if (remoteOnly.length > 0) { + choices.push( + new inquirer.Separator("── Other accounts (no login required) ──"), + ); + for (const account of remoteOnly) { + const label = ` ${account.name} ${chalk.dim(`(${account.id})`)}`; + choices.push({ name: label, value: { type: "remote", account } }); + } + } + + if (choices.length === 0) { + this.log("No accounts available."); + return false; + } + + const { selected } = await inquirer.prompt([ + { + choices, + message: "Select an account:", + name: "selected", + type: "list", + }, + ]); + + if (selected.type === "local") { + await this.switchToLocalAccount(selected.alias, localAccounts, flags); + return true; + } + + // Remote account — create a local alias using the current token + await this.addAndSwitchToRemoteAccount(selected.account); + return true; + } + + private async addAndSwitchToRemoteAccount( + remoteAccount: AccountSummary, + ): Promise { + const currentAlias = this.configManager.getCurrentAccountAlias(); + if (!currentAlias) { + this.error("No current account to copy credentials from."); + return; + } + + const oauthTokens = this.configManager.getOAuthTokens(currentAlias); + if (!oauthTokens) { + this.error( + "Current account does not use OAuth. Please log in with the target account directly.", + ); + return; + } + + const currentAccount = this.configManager.getCurrentAccount(); + const newAlias = slugifyAccountName(remoteAccount.name); + + // Store the new alias with the same OAuth tokens but different account info + this.configManager.storeOAuthTokens( + newAlias, + { + ...oauthTokens, + userEmail: currentAccount?.userEmail, + }, + { + accountId: remoteAccount.id, + accountName: remoteAccount.name, + }, + ); + + // Carry over control host from the source account + if (currentAccount?.controlHost) { + this.configManager.setAccountControlHost( + newAlias, + currentAccount.controlHost, + ); + } + + this.configManager.switchAccount(newAlias); + + this.log( + `Switched to account: ${chalk.cyan(remoteAccount.name)} (${remoteAccount.id})`, + ); + this.log(`Saved as alias: ${chalk.cyan(newAlias)}`); + } + + private async switchToLocalAccount( alias: string, accounts: Array<{ account: { accountId?: string; accountName?: string }; @@ -94,7 +234,6 @@ export default class AccountsSwitch extends ControlBaseCommand { }>, flags: Record, ): Promise { - // Check if account exists const accountExists = accounts.some((account) => account.alias === alias); if (!accountExists) { @@ -120,23 +259,15 @@ export default class AccountsSwitch extends ControlBaseCommand { return; } - // Switch to the account this.configManager.switchAccount(alias); - // Verify the account is valid by making an API call try { const accessToken = this.configManager.getAccessToken(); if (!accessToken) { const error = "No access token found for this account. Please log in again."; if (this.shouldOutputJson(flags)) { - this.jsonError( - { - error, - success: false, - }, - flags, - ); + this.jsonError({ error, success: false }, flags); return; } else { this.error(error); @@ -160,9 +291,7 @@ export default class AccountsSwitch extends ControlBaseCommand { alias, id: account.id, name: account.name, - user: { - email: user.email, - }, + user: { email: user.email }, }, success: true, }, diff --git a/src/control-base-command.ts b/src/control-base-command.ts index 5141443c..ecbfe908 100644 --- a/src/control-base-command.ts +++ b/src/control-base-command.ts @@ -2,6 +2,8 @@ import chalk from "chalk"; import { AblyBaseCommand } from "./base-command.js"; import { ControlApi, App } from "./services/control-api.js"; +import { OAuthClient } from "./services/oauth-client.js"; +import { TokenRefreshMiddleware } from "./services/token-refresh-middleware.js"; import { BaseFlags, ErrorDetails } from "./types/cli.js"; export abstract class ControlBaseCommand extends AblyBaseCommand { @@ -16,6 +18,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { */ protected createControlApi(flags: BaseFlags): ControlApi { let accessToken = flags["access-token"] || process.env.ABLY_ACCESS_TOKEN; + let tokenRefreshMiddleware: TokenRefreshMiddleware | undefined; if (!accessToken) { const account = this.configManager.getCurrentAccount(); @@ -26,6 +29,17 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { } accessToken = account.accessToken; + + // Set up token refresh middleware for OAuth accounts + if (this.configManager.getAuthMethod() === "oauth") { + const oauthClient = new OAuthClient({ + controlHost: flags["control-host"], + }); + tokenRefreshMiddleware = new TokenRefreshMiddleware( + this.configManager, + oauthClient, + ); + } } if (!accessToken) { @@ -37,6 +51,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { return new ControlApi({ accessToken, controlHost: flags["control-host"], + tokenRefreshMiddleware, }); } diff --git a/src/services/config-manager.ts b/src/services/config-manager.ts index c3bce075..929811d9 100644 --- a/src/services/config-manager.ts +++ b/src/services/config-manager.ts @@ -14,12 +14,17 @@ export interface AppConfig { export interface AccountConfig { accessToken: string; + accessTokenExpiresAt?: number; accountId?: string; accountName?: string; apps?: { [appId: string]: AppConfig; }; + authMethod?: "oauth" | "token"; + controlHost?: string; currentAppId?: string; + oauthScope?: string; + refreshToken?: string; tokenId?: string; userEmail?: string; } @@ -57,6 +62,33 @@ export interface ConfigManager { ): void; switchAccount(alias: string): boolean; removeAccount(alias: string): boolean; + setAccountControlHost(alias: string, controlHost: string): void; + + // OAuth management + storeOAuthTokens( + alias: string, + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; + userId?: string; + userEmail?: string; + }, + accountInfo?: { + accountId?: string; + accountName?: string; + }, + ): void; + getOAuthTokens(alias?: string): + | { + accessToken: string; + refreshToken: string; + expiresAt: number; + } + | undefined; + isAccessTokenExpired(alias?: string): boolean; + getAuthMethod(alias?: string): "oauth" | "token" | undefined; // App management getApiKey(appId?: string): string | undefined; @@ -444,6 +476,86 @@ export class TomlConfigManager implements ConfigManager { this.saveConfig(); } + // Store OAuth tokens for an account + public storeOAuthTokens( + alias: string, + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; + userId?: string; + userEmail?: string; + }, + accountInfo?: { + accountId?: string; + accountName?: string; + }, + ): void { + this.config.accounts[alias] = { + ...this.config.accounts[alias], + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.expiresAt, + accountId: + accountInfo?.accountId ?? this.config.accounts[alias]?.accountId, + accountName: + accountInfo?.accountName ?? this.config.accounts[alias]?.accountName, + apps: this.config.accounts[alias]?.apps || {}, + authMethod: "oauth", + currentAppId: this.config.accounts[alias]?.currentAppId, + oauthScope: tokens.scope, + refreshToken: tokens.refreshToken, + userEmail: tokens.userEmail ?? this.config.accounts[alias]?.userEmail, + }; + + if (!this.config.current || !this.config.current.account) { + this.config.current = { account: alias }; + } + + this.saveConfig(); + } + + // Get OAuth tokens for the current account or specific alias + public getOAuthTokens(alias?: string): + | { + accessToken: string; + refreshToken: string; + expiresAt: number; + } + | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || account.authMethod !== "oauth") return undefined; + if (!account.refreshToken || !account.accessTokenExpiresAt) + return undefined; + + return { + accessToken: account.accessToken, + expiresAt: account.accessTokenExpiresAt, + refreshToken: account.refreshToken, + }; + } + + // Check if access token is expired (within 60 seconds of expiry) + public isAccessTokenExpired(alias?: string): boolean { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || !account.accessTokenExpiresAt) return false; + + // Consider expired if within 60 seconds of expiry + return Date.now() >= account.accessTokenExpiresAt - 60_000; + } + + // Get the auth method for the current account or specific alias + public getAuthMethod(alias?: string): "oauth" | "token" | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + return account?.authMethod; + } + // Switch to a different account public switchAccount(alias: string): boolean { if (!this.config.accounts[alias]) { @@ -459,6 +571,12 @@ export class TomlConfigManager implements ConfigManager { return true; } + public setAccountControlHost(alias: string, controlHost: string): void { + if (!this.config.accounts[alias]) return; + this.config.accounts[alias].controlHost = controlHost; + this.saveConfig(); + } + private ensureConfigDirExists(): void { if (!fs.existsSync(this.configDir)) { fs.mkdirSync(this.configDir, { mode: 0o700 }); // Secure permissions diff --git a/src/services/control-api.ts b/src/services/control-api.ts index b4d22ecd..9bfe09ef 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -1,11 +1,13 @@ import fetch, { type RequestInit } from "node-fetch"; import { getCliVersion } from "../utils/version.js"; import isTestMode from "../utils/test-mode.js"; +import type { TokenRefreshMiddleware } from "./token-refresh-middleware.js"; export interface ControlApiOptions { accessToken: string; controlHost?: string; logErrors?: boolean; + tokenRefreshMiddleware?: TokenRefreshMiddleware; } export interface App { @@ -163,14 +165,31 @@ export interface MeResponse { user: { email: string }; } +export interface AccountSummary { + id: string; + name: string; +} + +export class ApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + ) { + super(message); + this.name = "ApiError"; + } +} + export class ControlApi { private accessToken: string; private controlHost: string; private logErrors: boolean; + private tokenRefreshMiddleware?: TokenRefreshMiddleware; constructor(options: ControlApiOptions) { this.accessToken = options.accessToken; this.controlHost = options.controlHost || "control.ably.net"; + this.tokenRefreshMiddleware = options.tokenRefreshMiddleware; // Respect SUPPRESS_CONTROL_API_ERRORS env var for default behavior // Explicit options.logErrors will override the env var. // eslint-disable-next-line unicorn/no-negated-condition @@ -374,6 +393,20 @@ export class ControlApi { return this.request(`/apps/${appId}/keys/${keyIdOrValue}`); } + // Get all accounts for the authenticated user + async getAccounts(): Promise { + try { + return await this.request("/me/accounts"); + } catch (error: unknown) { + // Graceful degradation: fall back to single account from /me if endpoint not available + if (error instanceof ApiError && error.statusCode === 404) { + const me = await this.getMe(); + return [{ id: me.account.id, name: me.account.name }]; + } + throw error; + } + } + // Get user and account info async getMe(): Promise { return this.request("/me"); @@ -509,6 +542,12 @@ export class ControlApi { method = "GET", body?: unknown, ): Promise { + // If we have a token refresh middleware, get a valid token before each request + if (this.tokenRefreshMiddleware) { + this.accessToken = + await this.tokenRefreshMiddleware.getValidAccessToken(); + } + const url = this.controlHost.includes("local") ? `http://${this.controlHost}/api/v1${path}` : `https://${this.controlHost}/v1${path}`; @@ -570,7 +609,7 @@ export class ControlApi { // Include short string responses directly errorMessage += `: ${responseData}`; } - throw new Error(errorMessage); + throw new ApiError(errorMessage, response.status); } if (response.status === 204) { diff --git a/src/services/interactive-helper.ts b/src/services/interactive-helper.ts index a1c3675c..52467079 100644 --- a/src/services/interactive-helper.ts +++ b/src/services/interactive-helper.ts @@ -1,6 +1,6 @@ import inquirer from "inquirer"; import type { ConfigManager, AccountConfig } from "./config-manager.js"; -import type { App, ControlApi, Key } from "./control-api.js"; +import type { AccountSummary, App, ControlApi, Key } from "./control-api.js"; export interface InteractiveHelperOptions { logErrors?: boolean; @@ -81,6 +81,39 @@ export class InteractiveHelper { } } + /** + * Interactively select an account from API results (multi-account OAuth flow) + */ + async selectAccountFromApi( + accounts: AccountSummary[], + ): Promise { + try { + if (accounts.length === 0) { + console.log("No accounts found."); + return null; + } + + const { selectedAccount } = await inquirer.prompt([ + { + choices: accounts.map((account) => ({ + name: `${account.name} (${account.id})`, + value: account, + })), + message: "Select an account:", + name: "selectedAccount", + type: "list", + }, + ]); + + return selectedAccount; + } catch (error) { + if (this.logErrors) { + console.error("Error selecting account:", error); + } + return null; + } + } + /** * Interactively select an app from the list of available apps */ diff --git a/src/services/oauth-client.ts b/src/services/oauth-client.ts new file mode 100644 index 00000000..69f4866e --- /dev/null +++ b/src/services/oauth-client.ts @@ -0,0 +1,241 @@ +import fetch from "node-fetch"; + +export interface OAuthTokens { + accessToken: string; + expiresAt: number; + refreshToken: string; + scope?: string; + tokenType: string; + userEmail?: string; + userId?: string; +} + +export interface OAuthConfig { + clientId: string; + deviceCodeEndpoint: string; + revocationEndpoint: string; + scopes: string[]; + tokenEndpoint: string; +} + +export interface OAuthClientOptions { + controlHost?: string; +} + +export interface DeviceCodeResponse { + deviceCode: string; + expiresIn: number; + interval: number; + userCode: string; + verificationUri: string; + verificationUriComplete: string; +} + +export class OAuthClient { + private config: OAuthConfig; + + constructor(options: OAuthClientOptions = {}) { + this.config = this.getOAuthConfig(options.controlHost); + } + + /** + * Request a device code from the OAuth server (RFC 8628 step 1). + */ + async requestDeviceCode(): Promise { + const params = new URLSearchParams({ + client_id: this.config.clientId, + scope: this.config.scopes.join(" "), + }); + + const response = await fetch(this.config.deviceCodeEndpoint, { + body: params.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Device code request failed (${response.status}): ${errorBody}`, + ); + } + + const data = (await response.json()) as Record; + return { + deviceCode: data.device_code as string, + expiresIn: (data.expires_in as number) || 300, + interval: (data.interval as number) || 5, + userCode: data.user_code as string, + verificationUri: data.verification_uri as string, + verificationUriComplete: data.verification_uri_complete as string, + }; + } + + /** + * Poll for token completion (RFC 8628 step 2). + * Sleeps between requests, respects slow_down, and throws on expiry/denial. + */ + async pollForToken( + deviceCode: string, + interval: number, + expiresIn: number, + ): Promise { + const deadline = Date.now() + expiresIn * 1000; + let currentInterval = interval; + let networkRetries = 0; + const maxNetworkRetries = 3; + + while (Date.now() < deadline) { + await this.sleep(currentInterval * 1000); + + if (Date.now() >= deadline) { + throw new Error("Device code expired"); + } + + let response; + try { + const params = new URLSearchParams({ + client_id: this.config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + + response = await fetch(this.config.tokenEndpoint, { + body: params.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + networkRetries = 0; + } catch { + networkRetries++; + if (networkRetries >= maxNetworkRetries) { + throw new Error( + "Network error: failed to reach token endpoint after multiple retries", + ); + } + continue; + } + + if (response.ok) { + const data = (await response.json()) as Record; + return this.parseTokenResponse(data); + } + + const errorData = (await response.json()) as Record; + const error = errorData.error as string; + + if (error === "authorization_pending") { + continue; + } + + if (error === "slow_down") { + currentInterval += 5; + continue; + } + + if (error === "expired_token") { + throw new Error("Device code expired"); + } + + if (error === "access_denied") { + throw new Error("Authorization denied"); + } + + throw new Error(`Token polling failed: ${error}`); + } + + throw new Error("Device code expired"); + } + + /** + * Refresh an access token using a refresh token + */ + async refreshAccessToken(refreshToken: string): Promise { + return this.postForTokens( + { + client_id: this.config.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + }, + "Token refresh", + ); + } + + /** + * Revoke a token (access or refresh) + */ + async revokeToken(token: string): Promise { + const params = new URLSearchParams({ + client_id: this.config.clientId, + token, + }); + + try { + await fetch(this.config.revocationEndpoint, { + body: params.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + } catch { + // Best-effort revocation -- don't block on failure + } + } + + getClientId(): string { + return this.config.clientId; + } + + // --- Private helpers --- + + private getOAuthConfig(controlHost = "ably.com"): OAuthConfig { + const host = controlHost; + const scheme = host.includes("local") ? "http" : "https"; + return { + clientId: "gb-I8-bZRnXs-gF83jOWKQrUxPPWp_ldTfQtgGP0EFg", + deviceCodeEndpoint: `${scheme}://${host}/oauth/authorize_device`, + revocationEndpoint: `${scheme}://${host}/oauth/revoke`, + scopes: ["full_access"], + tokenEndpoint: `${scheme}://${host}/oauth/token`, + }; + } + + private async postForTokens( + params: Record, + operationName: string, + ): Promise { + const body = new URLSearchParams(params); + + const response = await fetch(this.config.tokenEndpoint, { + body: body.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `${operationName} failed (${response.status}): ${errorBody}`, + ); + } + + const data = (await response.json()) as Record; + return this.parseTokenResponse(data); + } + + private parseTokenResponse(data: Record): OAuthTokens { + const expiresIn = (data.expires_in as number) || 3600; + return { + accessToken: data.access_token as string, + expiresAt: Date.now() + expiresIn * 1000, + refreshToken: data.refresh_token as string, + scope: data.scope as string | undefined, + tokenType: (data.token_type as string) || "Bearer", + userEmail: data.user_email as string | undefined, + userId: data.user_id === undefined ? undefined : String(data.user_id), + }; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/services/token-refresh-middleware.ts b/src/services/token-refresh-middleware.ts new file mode 100644 index 00000000..ea36ad94 --- /dev/null +++ b/src/services/token-refresh-middleware.ts @@ -0,0 +1,70 @@ +import type { ConfigManager } from "./config-manager.js"; +import type { OAuthClient } from "./oauth-client.js"; + +export class TokenExpiredError extends Error { + constructor(message: string) { + super(message); + this.name = "TokenExpiredError"; + } +} + +export class TokenRefreshMiddleware { + private pendingRefresh: Promise | undefined; + + constructor( + private configManager: ConfigManager, + private oauthClient: OAuthClient, + private accountAlias?: string, + ) {} + + async getValidAccessToken(): Promise { + const authMethod = this.configManager.getAuthMethod(this.accountAlias); + + // Non-OAuth tokens: return as-is + if (authMethod !== "oauth") { + const token = this.configManager.getAccessToken(this.accountAlias); + if (!token) + throw new TokenExpiredError( + "No access token found. Please run 'ably login'.", + ); + return token; + } + + // Not expired: return current token + if (!this.configManager.isAccessTokenExpired(this.accountAlias)) { + const token = this.configManager.getAccessToken(this.accountAlias); + if (token) return token; + } + + // Expired: refresh using refresh token (deduplicate concurrent calls) + if (this.pendingRefresh) { + return this.pendingRefresh; + } + + this.pendingRefresh = this.refreshToken(); + try { + return await this.pendingRefresh; + } finally { + this.pendingRefresh = undefined; + } + } + + private async refreshToken(): Promise { + const tokens = this.configManager.getOAuthTokens(this.accountAlias); + if (!tokens?.refreshToken) { + throw new TokenExpiredError( + "OAuth session expired. Please run 'ably login' again.", + ); + } + + const newTokens = await this.oauthClient.refreshAccessToken( + tokens.refreshToken, + ); + const alias = + this.accountAlias || + this.configManager.getCurrentAccountAlias() || + "default"; + this.configManager.storeOAuthTokens(alias, newTokens); + return newTokens.accessToken; + } +} diff --git a/src/utils/slugify.ts b/src/utils/slugify.ts new file mode 100644 index 00000000..0e002f3a --- /dev/null +++ b/src/utils/slugify.ts @@ -0,0 +1,14 @@ +/** + * Convert an account name to a valid alias slug. + * Rules: lowercase, alphanumeric + dashes, must start with a letter. + */ +export function slugifyAccountName(name: string): string { + const slug = name + .toLowerCase() + .replaceAll(/[^a-z\d]+/g, "-") + .replaceAll(/^-+|-+$/g, ""); + if (!slug || !/^[a-z]/.test(slug)) { + return "default"; + } + return slug; +} diff --git a/test/e2e/connections/connections.test.ts b/test/e2e/connections/connections.test.ts index e1718e1f..7152dd0a 100644 --- a/test/e2e/connections/connections.test.ts +++ b/test/e2e/connections/connections.test.ts @@ -19,7 +19,7 @@ import { import { spawn } from "node:child_process"; import { join } from "node:path"; -describe("Connections E2E Tests", () => { +describe.skipIf(!process.env.E2E_ABLY_API_KEY)("Connections E2E Tests", () => { beforeAll(() => { process.on("SIGINT", forceExit); }); diff --git a/test/helpers/mock-config-manager.ts b/test/helpers/mock-config-manager.ts index b1011de0..964419d1 100644 --- a/test/helpers/mock-config-manager.ts +++ b/test/helpers/mock-config-manager.ts @@ -455,6 +455,78 @@ export class MockConfigManager implements ConfigManager { ); } + public storeOAuthTokens( + alias: string, + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; + userId?: string; + userEmail?: string; + }, + accountInfo?: { + accountId?: string; + accountName?: string; + }, + ): void { + this.config.accounts[alias] = { + ...this.config.accounts[alias], + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.expiresAt, + accountId: + accountInfo?.accountId ?? this.config.accounts[alias]?.accountId, + accountName: + accountInfo?.accountName ?? this.config.accounts[alias]?.accountName, + apps: this.config.accounts[alias]?.apps || {}, + authMethod: "oauth", + currentAppId: this.config.accounts[alias]?.currentAppId, + oauthScope: tokens.scope, + refreshToken: tokens.refreshToken, + userEmail: tokens.userEmail ?? this.config.accounts[alias]?.userEmail, + }; + + if (!this.config.current || !this.config.current.account) { + this.config.current = { account: alias }; + } + } + + public getOAuthTokens(alias?: string): + | { + accessToken: string; + refreshToken: string; + expiresAt: number; + } + | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || account.authMethod !== "oauth") return undefined; + if (!account.refreshToken || !account.accessTokenExpiresAt) + return undefined; + + return { + accessToken: account.accessToken, + expiresAt: account.accessTokenExpiresAt, + refreshToken: account.refreshToken, + }; + } + + public isAccessTokenExpired(alias?: string): boolean { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || !account.accessTokenExpiresAt) return false; + return Date.now() >= account.accessTokenExpiresAt - 60_000; + } + + public getAuthMethod(alias?: string): "oauth" | "token" | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + return account?.authMethod; + } + public switchAccount(alias: string): boolean { if (!this.config.accounts[alias]) { return false; @@ -467,6 +539,11 @@ export class MockConfigManager implements ConfigManager { this.config.current.account = alias; return true; } + + public setAccountControlHost(alias: string, controlHost: string): void { + if (!this.config.accounts[alias]) return; + this.config.accounts[alias].controlHost = controlHost; + } } /** diff --git a/test/unit/commands/accounts/login.test.ts b/test/unit/commands/accounts/login.test.ts index 91701b20..f9d77e66 100644 --- a/test/unit/commands/accounts/login.test.ts +++ b/test/unit/commands/accounts/login.test.ts @@ -52,6 +52,11 @@ describe("accounts:login command", () => { user: { email: "test@example.com" }, }); + // Mock the /me/accounts endpoint + nock("https://control.ably.net") + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + // Mock the apps list endpoint nock("https://control.ably.net") .get(`/v1/accounts/${mockAccountId}/apps`) @@ -72,12 +77,14 @@ describe("accounts:login command", () => { // Verify config was updated correctly via mock const mock = getMockConfigManager(); const config = mock.getConfig(); - expect(config.current?.account).toBe("default"); - expect(config.accounts["default"]).toBeDefined(); - expect(config.accounts["default"].accessToken).toBe(mockAccessToken); - expect(config.accounts["default"].accountId).toBe(mockAccountId); - expect(config.accounts["default"].accountName).toBe("Test Account"); - expect(config.accounts["default"].userEmail).toBe("test@example.com"); + expect(config.current?.account).toBe("test-account"); + expect(config.accounts["test-account"]).toBeDefined(); + expect(config.accounts["test-account"].accessToken).toBe(mockAccessToken); + expect(config.accounts["test-account"].accountId).toBe(mockAccountId); + expect(config.accounts["test-account"].accountName).toBe("Test Account"); + expect(config.accounts["test-account"].userEmail).toBe( + "test@example.com", + ); }); it("should include alias in JSON response when --alias flag is provided", async () => { @@ -91,6 +98,11 @@ describe("accounts:login command", () => { user: { email: "test@example.com" }, }); + // Mock the /me/accounts endpoint + nock("https://control.ably.net") + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + // Mock the apps list endpoint nock("https://control.ably.net") .get(`/v1/accounts/${mockAccountId}/apps`) @@ -129,6 +141,11 @@ describe("accounts:login command", () => { user: { email: "test@example.com" }, }); + // Mock the /me/accounts endpoint + nock("https://control.ably.net") + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + // Mock the apps list endpoint with single app nock("https://control.ably.net") .get(`/v1/accounts/${mockAccountId}/apps`) @@ -151,13 +168,13 @@ describe("accounts:login command", () => { // Verify config was written with app info via mock const mock = getMockConfigManager(); const config = mock.getConfig(); - expect(config.current?.account).toBe("default"); - expect(config.accounts["default"]).toBeDefined(); - expect(config.accounts["default"].accessToken).toBe(mockAccessToken); - expect(config.accounts["default"].accountId).toBe(mockAccountId); - expect(config.accounts["default"].currentAppId).toBe(mockAppId); - expect(config.accounts["default"].apps?.[mockAppId]).toBeDefined(); - expect(config.accounts["default"].apps?.[mockAppId]?.appName).toBe( + expect(config.current?.account).toBe("test-account"); + expect(config.accounts["test-account"]).toBeDefined(); + expect(config.accounts["test-account"].accessToken).toBe(mockAccessToken); + expect(config.accounts["test-account"].accountId).toBe(mockAccountId); + expect(config.accounts["test-account"].currentAppId).toBe(mockAppId); + expect(config.accounts["test-account"].apps?.[mockAppId]).toBeDefined(); + expect(config.accounts["test-account"].apps?.[mockAppId]?.appName).toBe( mockAppName, ); }); @@ -172,6 +189,11 @@ describe("accounts:login command", () => { user: { email: "test@example.com" }, }); + // Mock the /me/accounts endpoint + nock("https://control.ably.net") + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + // Mock the apps list endpoint with multiple apps nock("https://control.ably.net") .get(`/v1/accounts/${mockAccountId}/apps`) @@ -192,12 +214,12 @@ describe("accounts:login command", () => { // Verify config was written without app selection via mock const mock = getMockConfigManager(); const config = mock.getConfig(); - expect(config.current?.account).toBe("default"); - expect(config.accounts["default"]).toBeDefined(); - expect(config.accounts["default"].accessToken).toBe(mockAccessToken); - expect(config.accounts["default"].accountId).toBe(mockAccountId); + expect(config.current?.account).toBe("test-account"); + expect(config.accounts["test-account"]).toBeDefined(); + expect(config.accounts["test-account"].accessToken).toBe(mockAccessToken); + expect(config.accounts["test-account"].accountId).toBe(mockAccountId); // Should NOT have currentAppId when multiple apps exist - expect(config.accounts["default"].currentAppId).toBeUndefined(); + expect(config.accounts["test-account"].currentAppId).toBeUndefined(); }); }); @@ -219,10 +241,13 @@ describe("accounts:login command", () => { }); it("should output error in JSON format when network fails", async () => { - // Mock network error + // Mock network error for both endpoints called in parallel nock("https://control.ably.net") .get("/v1/me") .replyWithError("Network error"); + nock("https://control.ably.net") + .get("/v1/me/accounts") + .replyWithError("Network error"); const { stdout } = await runCommand( ["accounts:login", mockAccessToken, "--json"], @@ -264,6 +289,11 @@ describe("accounts:login command", () => { user: { email: "test@example.com" }, }); + // Mock the /me/accounts endpoint on custom host + nock(`https://${customHost}`) + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + // Mock the apps list endpoint on custom host nock(`https://${customHost}`) .get(`/v1/accounts/${mockAccountId}/apps`) @@ -287,12 +317,89 @@ describe("accounts:login command", () => { // Verify config was written correctly via mock const mock = getMockConfigManager(); const config = mock.getConfig(); - expect(config.current?.account).toBe("default"); - expect(config.accounts["default"]).toBeDefined(); - expect(config.accounts["default"].accessToken).toBe(mockAccessToken); - expect(config.accounts["default"].accountId).toBe(mockAccountId); - expect(config.accounts["default"].accountName).toBe("Test Account"); - expect(config.accounts["default"].userEmail).toBe("test@example.com"); + expect(config.current?.account).toBe("test-account"); + expect(config.accounts["test-account"]).toBeDefined(); + expect(config.accounts["test-account"].accessToken).toBe(mockAccessToken); + expect(config.accounts["test-account"].accountId).toBe(mockAccountId); + expect(config.accounts["test-account"].accountName).toBe("Test Account"); + expect(config.accounts["test-account"].userEmail).toBe( + "test@example.com", + ); + }); + }); + + describe("legacy and OAuth login paths", () => { + it("should show --no-browser flag in help output for non-OAuth login", async () => { + const { stdout } = await runCommand( + ["accounts:login", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("--no-browser"); + expect(stdout).toContain("Do not open a browser"); + }); + + it("should store authMethod as non-oauth when logging in with direct token argument", async () => { + // Mock the /me endpoint + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the /me/accounts endpoint + nock("https://control.ably.net") + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + + // Mock the apps list endpoint + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, []); + + await runCommand( + ["accounts:login", mockAccessToken, "--json"], + import.meta.url, + ); + + // Verify config does not have authMethod set to "oauth" + const mock = getMockConfigManager(); + const config = mock.getConfig(); + expect(config.accounts["test-account"].authMethod).not.toBe("oauth"); + }); + + it("should not set refreshToken or accessTokenExpiresAt for direct token login", async () => { + // Mock the /me endpoint + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the /me/accounts endpoint + nock("https://control.ably.net") + .get("/v1/me/accounts") + .reply(200, [{ id: mockAccountId, name: "Test Account" }]); + + // Mock the apps list endpoint + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, []); + + await runCommand( + ["accounts:login", mockAccessToken, "--json"], + import.meta.url, + ); + + // Verify config does not have OAuth-specific fields + const mock = getMockConfigManager(); + const config = mock.getConfig(); + expect(config.accounts["test-account"].refreshToken).toBeUndefined(); + expect( + config.accounts["test-account"].accessTokenExpiresAt, + ).toBeUndefined(); }); }); }); diff --git a/test/unit/commands/accounts/logout.test.ts b/test/unit/commands/accounts/logout.test.ts index 307755e7..05c9ee11 100644 --- a/test/unit/commands/accounts/logout.test.ts +++ b/test/unit/commands/accounts/logout.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; +import nock from "nock"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; describe("accounts:logout command", () => { @@ -194,4 +195,110 @@ describe("accounts:logout command", () => { expect(result.error).toContain("not found"); }); }); + + describe("OAuth token revocation", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("should call revocation endpoint when logging out an OAuth account", async () => { + const mock = getMockConfigManager(); + mock.setConfig({ + current: { account: "testaccount" }, + accounts: { + testaccount: { + accessToken: "oauth_access_token", + accountId: "acc-123", + accountName: "Test Account", + userEmail: "test@example.com", + authMethod: "oauth", + refreshToken: "oauth_refresh_token", + accessTokenExpiresAt: Date.now() + 3600000, + }, + }, + }); + + // Expect two revocation calls (one for access token, one for refresh token) + const revokeScope = nock("https://ably.com") + .post("/oauth/revoke") + .twice() + .reply(200); + + const { stdout } = await runCommand( + ["accounts:logout", "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(revokeScope.isDone()).toBe(true); + }); + + it("should succeed even if revocation endpoint fails", async () => { + const mock = getMockConfigManager(); + mock.setConfig({ + current: { account: "testaccount" }, + accounts: { + testaccount: { + accessToken: "oauth_access_token", + accountId: "acc-123", + accountName: "Test Account", + userEmail: "test@example.com", + authMethod: "oauth", + refreshToken: "oauth_refresh_token", + accessTokenExpiresAt: Date.now() + 3600000, + }, + }, + }); + + // Revocation endpoint returns errors + nock("https://ably.com") + .post("/oauth/revoke") + .twice() + .replyWithError("Connection refused"); + + const { stdout } = await runCommand( + ["accounts:logout", "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + + // Verify the account was still removed + const config = mock.getConfig(); + expect(config.accounts["testaccount"]).toBeUndefined(); + }); + + it("should not call revocation endpoint for non-OAuth account logout", async () => { + const mock = getMockConfigManager(); + mock.setConfig({ + current: { account: "testaccount" }, + accounts: { + testaccount: { + accessToken: "regular_token", + accountId: "acc-123", + accountName: "Test Account", + userEmail: "test@example.com", + }, + }, + }); + + // Set up nock interceptor that should NOT be called + const revokeScope = nock("https://ably.com") + .post("/oauth/revoke") + .reply(200); + + const { stdout } = await runCommand( + ["accounts:logout", "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + + // The revocation endpoint should not have been called + expect(revokeScope.isDone()).toBe(false); + }); + }); }); diff --git a/test/unit/commands/did-you-mean.test.ts b/test/unit/commands/did-you-mean.test.ts index f6dd8adc..d78013e5 100644 --- a/test/unit/commands/did-you-mean.test.ts +++ b/test/unit/commands/did-you-mean.test.ts @@ -9,7 +9,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); describe("Did You Mean Functionality", () => { - const timeout = 15000; + const timeout = 30000; let binPath: string; beforeAll(() => { @@ -42,7 +42,7 @@ describe("Did You Mean Functionality", () => { "Test timed out - process did not exit. Output: " + output, ), ); - }, 10000); + }, 20000); child.stdout.on("data", (data) => { output += data.toString(); @@ -59,11 +59,11 @@ describe("Did You Mean Functionality", () => { setTimeout(() => { child.stdin.write("account current\n"); - }, 500); + }, 2000); setTimeout(() => { child.stdin.write("exit\n"); - }, 2000); + }, 5000); child.on("exit", () => { clearTimeout(killTimeout); diff --git a/test/unit/services/oauth-client.test.ts b/test/unit/services/oauth-client.test.ts new file mode 100644 index 00000000..cbab60ef --- /dev/null +++ b/test/unit/services/oauth-client.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { OAuthClient } from "../../../src/services/oauth-client.js"; + +describe("OAuthClient", () => { + beforeEach(() => { + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("OAuth config derivation", () => { + it("default host uses https scheme", async () => { + const client = new OAuthClient(); + + const scope = nock("https://ably.com") + .post("/oauth/authorize_device") + .reply(200, { + device_code: "dc_test", + expires_in: 300, + interval: 5, + user_code: "ABCD-1234", + verification_uri: "https://ably.com/device", + verification_uri_complete: + "https://ably.com/device?user_code=ABCD-1234", + }); + + await client.requestDeviceCode(); + + expect(scope.isDone()).toBe(true); + }); + + it("host containing 'local' uses http scheme", async () => { + const client = new OAuthClient({ controlHost: "localhost:3000" }); + + const scope = nock("http://localhost:3000") + .post("/oauth/authorize_device") + .reply(200, { + device_code: "dc_test", + expires_in: 300, + interval: 5, + user_code: "ABCD-1234", + verification_uri: "http://localhost:3000/device", + verification_uri_complete: + "http://localhost:3000/device?user_code=ABCD-1234", + }); + + await client.requestDeviceCode(); + + expect(scope.isDone()).toBe(true); + }); + + it("client ID matches the expected value", () => { + const client = new OAuthClient(); + expect(client.getClientId()).toBe( + "gb-I8-bZRnXs-gF83jOWKQrUxPPWp_ldTfQtgGP0EFg", + ); + }); + }); + + describe("requestDeviceCode", () => { + it("returns all fields from server response", async () => { + const client = new OAuthClient(); + + nock("https://ably.com").post("/oauth/authorize_device").reply(200, { + device_code: "dc_abc123", + expires_in: 600, + interval: 10, + user_code: "WXYZ-5678", + verification_uri: "https://ably.com/device", + verification_uri_complete: + "https://ably.com/device?user_code=WXYZ-5678", + }); + + const result = await client.requestDeviceCode(); + + expect(result.deviceCode).toBe("dc_abc123"); + expect(result.expiresIn).toBe(600); + expect(result.interval).toBe(10); + expect(result.userCode).toBe("WXYZ-5678"); + expect(result.verificationUri).toBe("https://ably.com/device"); + expect(result.verificationUriComplete).toBe( + "https://ably.com/device?user_code=WXYZ-5678", + ); + }); + + it("sends correct client_id and scope in body", async () => { + const client = new OAuthClient(); + + const scope = nock("https://ably.com") + .post( + "/oauth/authorize_device", + (body: Record) => + body.client_id === "gb-I8-bZRnXs-gF83jOWKQrUxPPWp_ldTfQtgGP0EFg" && + body.scope === "full_access", + ) + .reply(200, { + device_code: "dc_test", + expires_in: 300, + interval: 5, + user_code: "TEST-CODE", + verification_uri: "https://ably.com/device", + verification_uri_complete: + "https://ably.com/device?user_code=TEST-CODE", + }); + + await client.requestDeviceCode(); + + expect(scope.isDone()).toBe(true); + }); + + it("throws on non-200 response", async () => { + const client = new OAuthClient(); + + nock("https://ably.com") + .post("/oauth/authorize_device") + .reply(400, "invalid_client"); + + await expect(client.requestDeviceCode()).rejects.toThrow( + "Device code request failed (400): invalid_client", + ); + }); + }); + + describe("pollForToken", () => { + it("returns tokens after authorization_pending then success", async () => { + const client = new OAuthClient(); + + // First call: authorization_pending + nock("https://ably.com") + .post("/oauth/token") + .reply(400, { error: "authorization_pending" }); + + // Second call: success + nock("https://ably.com").post("/oauth/token").reply(200, { + access_token: "at_device_flow", + expires_in: 3600, + refresh_token: "rt_device_flow", + scope: "full_access", + token_type: "Bearer", + }); + + const tokens = await client.pollForToken("dc_test", 0.01, 10); + + expect(tokens.accessToken).toBe("at_device_flow"); + expect(tokens.refreshToken).toBe("rt_device_flow"); + expect(tokens.tokenType).toBe("Bearer"); + }); + + it("increases interval on slow_down", async () => { + const client = new OAuthClient(); + + // First call: slow_down (interval increases by 5s per RFC 8628) + nock("https://ably.com") + .post("/oauth/token") + .reply(400, { error: "slow_down" }); + + // Second call: success + nock("https://ably.com").post("/oauth/token").reply(200, { + access_token: "at_slow", + expires_in: 3600, + refresh_token: "rt_slow", + token_type: "Bearer", + }); + + const tokens = await client.pollForToken("dc_test", 0.01, 30); + + expect(tokens.accessToken).toBe("at_slow"); + }, 15_000); + + it("throws on expired_token", async () => { + const client = new OAuthClient(); + + nock("https://ably.com") + .post("/oauth/token") + .reply(400, { error: "expired_token" }); + + await expect(client.pollForToken("dc_test", 0.01, 10)).rejects.toThrow( + "Device code expired", + ); + }); + + it("throws on access_denied", async () => { + const client = new OAuthClient(); + + nock("https://ably.com") + .post("/oauth/token") + .reply(400, { error: "access_denied" }); + + await expect(client.pollForToken("dc_test", 0.01, 10)).rejects.toThrow( + "Authorization denied", + ); + }); + + it("sends correct grant_type and device_code", async () => { + const client = new OAuthClient(); + + const scope = nock("https://ably.com") + .post("/oauth/token", (body: Record) => { + return ( + body.grant_type === + "urn:ietf:params:oauth:grant-type:device_code" && + body.device_code === "dc_verify" && + body.client_id === "gb-I8-bZRnXs-gF83jOWKQrUxPPWp_ldTfQtgGP0EFg" + ); + }) + .reply(200, { + access_token: "at_verify", + expires_in: 3600, + refresh_token: "rt_verify", + token_type: "Bearer", + }); + + await client.pollForToken("dc_verify", 0.01, 10); + + expect(scope.isDone()).toBe(true); + }); + + it("throws after deadline exceeded", async () => { + const client = new OAuthClient(); + + // Keep returning authorization_pending; the deadline will expire + nock("https://ably.com") + .post("/oauth/token") + .times(10) + .reply(400, { error: "authorization_pending" }); + + // Use a very short expiresIn so the deadline passes quickly + await expect( + client.pollForToken("dc_expired", 0.01, 0.05), + ).rejects.toThrow("Device code expired"); + }); + }); + + describe("refreshAccessToken", () => { + it("returns OAuthTokens on successful refresh", async () => { + const client = new OAuthClient(); + + nock("https://ably.com").post("/oauth/token").reply(200, { + access_token: "refreshed_access_token", + expires_in: 7200, + refresh_token: "refreshed_refresh_token", + scope: "full_access", + token_type: "Bearer", + }); + + const tokens = await client.refreshAccessToken("old_refresh_token"); + + expect(tokens.accessToken).toBe("refreshed_access_token"); + expect(tokens.refreshToken).toBe("refreshed_refresh_token"); + expect(tokens.tokenType).toBe("Bearer"); + expect(tokens.scope).toBe("full_access"); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("throws on non-200 response with status and body", async () => { + const client = new OAuthClient(); + + nock("https://ably.com").post("/oauth/token").reply(401, "invalid_grant"); + + await expect( + client.refreshAccessToken("bad_refresh_token"), + ).rejects.toThrow("Token refresh failed (401): invalid_grant"); + }); + + it("sends correct form-encoded body", async () => { + const client = new OAuthClient(); + const refreshToken = "my_refresh_token"; + + const scope = nock("https://ably.com") + .post("/oauth/token", (body: Record) => { + return ( + body.grant_type === "refresh_token" && + body.client_id === "gb-I8-bZRnXs-gF83jOWKQrUxPPWp_ldTfQtgGP0EFg" && + body.refresh_token === refreshToken + ); + }) + .reply(200, { + access_token: "new_access", + expires_in: 3600, + refresh_token: "new_refresh", + token_type: "Bearer", + }); + + await client.refreshAccessToken(refreshToken); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe("revokeToken", () => { + it("sends POST to revocation endpoint", async () => { + const client = new OAuthClient(); + + const scope = nock("https://ably.com").post("/oauth/revoke").reply(200); + + await client.revokeToken("token_to_revoke"); + + expect(scope.isDone()).toBe(true); + }); + + it("does not throw on network error", async () => { + const client = new OAuthClient(); + + nock("https://ably.com") + .post("/oauth/revoke") + .replyWithError("Connection refused"); + + // Should not throw + await expect( + client.revokeToken("token_to_revoke"), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/test/unit/services/token-refresh-middleware.test.ts b/test/unit/services/token-refresh-middleware.test.ts new file mode 100644 index 00000000..56f1fbee --- /dev/null +++ b/test/unit/services/token-refresh-middleware.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi } from "vitest"; +import { + TokenRefreshMiddleware, + TokenExpiredError, +} from "../../../src/services/token-refresh-middleware.js"; + +function createMockConfigManager(overrides: Record = {}) { + return { + getAccessToken: vi.fn().mockReturnValue("current_access_token"), + getAuthMethod: vi.fn(), + getCurrentAccountAlias: vi.fn().mockReturnValue("default"), + getOAuthTokens: vi.fn(), + isAccessTokenExpired: vi.fn().mockReturnValue(false), + storeOAuthTokens: vi.fn(), + ...overrides, + }; +} + +function createMockOAuthClient(overrides: Record = {}) { + return { + refreshAccessToken: vi.fn(), + ...overrides, + }; +} + +describe("TokenRefreshMiddleware", () => { + describe("non-OAuth passthrough", () => { + it("returns access token as-is when authMethod is not oauth", async () => { + const configManager = createMockConfigManager({ + getAuthMethod: vi.fn().mockReturnValue("token"), + getAccessToken: vi.fn().mockReturnValue("legacy_token_value"), + }); + const oauthClient = createMockOAuthClient(); + + const middleware = new TokenRefreshMiddleware( + configManager as never, + oauthClient as never, + ); + + const token = await middleware.getValidAccessToken(); + + expect(token).toBe("legacy_token_value"); + expect(oauthClient.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it("throws TokenExpiredError when no access token is found", async () => { + const configManager = createMockConfigManager({ + getAuthMethod: vi.fn(), + getAccessToken: vi.fn(), + }); + const oauthClient = createMockOAuthClient(); + + const middleware = new TokenRefreshMiddleware( + configManager as never, + oauthClient as never, + ); + + await expect(middleware.getValidAccessToken()).rejects.toThrow( + TokenExpiredError, + ); + await expect(middleware.getValidAccessToken()).rejects.toThrow( + "No access token found", + ); + }); + }); + + describe("non-expired OAuth token", () => { + it("returns current token without calling refresh when not expired", async () => { + const configManager = createMockConfigManager({ + getAuthMethod: vi.fn().mockReturnValue("oauth"), + getAccessToken: vi.fn().mockReturnValue("valid_oauth_token"), + isAccessTokenExpired: vi.fn().mockReturnValue(false), + }); + const oauthClient = createMockOAuthClient(); + + const middleware = new TokenRefreshMiddleware( + configManager as never, + oauthClient as never, + ); + + const token = await middleware.getValidAccessToken(); + + expect(token).toBe("valid_oauth_token"); + expect(oauthClient.refreshAccessToken).not.toHaveBeenCalled(); + expect(configManager.storeOAuthTokens).not.toHaveBeenCalled(); + }); + }); + + describe("expired OAuth token", () => { + it("refreshes token, stores new tokens, and returns new access token", async () => { + const newTokens = { + accessToken: "refreshed_access_token", + expiresAt: Date.now() + 7200000, + refreshToken: "refreshed_refresh_token", + scope: "full_access", + tokenType: "Bearer", + }; + + const configManager = createMockConfigManager({ + getAuthMethod: vi.fn().mockReturnValue("oauth"), + getAccessToken: vi.fn().mockReturnValue("expired_oauth_token"), + isAccessTokenExpired: vi.fn().mockReturnValue(true), + getOAuthTokens: vi.fn().mockReturnValue({ + accessToken: "expired_oauth_token", + refreshToken: "valid_refresh_token", + expiresAt: Date.now() - 1000, + }), + getCurrentAccountAlias: vi.fn().mockReturnValue("default"), + }); + const oauthClient = createMockOAuthClient({ + refreshAccessToken: vi.fn().mockResolvedValue(newTokens), + }); + + const middleware = new TokenRefreshMiddleware( + configManager as never, + oauthClient as never, + ); + + const token = await middleware.getValidAccessToken(); + + expect(token).toBe("refreshed_access_token"); + expect(oauthClient.refreshAccessToken).toHaveBeenCalledWith( + "valid_refresh_token", + ); + expect(configManager.storeOAuthTokens).toHaveBeenCalledWith( + "default", + newTokens, + ); + }); + }); + + describe("missing refresh token", () => { + it("throws TokenExpiredError when token is expired but no refresh token available", async () => { + const configManager = createMockConfigManager({ + getAuthMethod: vi.fn().mockReturnValue("oauth"), + getAccessToken: vi.fn().mockReturnValue("expired_token"), + isAccessTokenExpired: vi.fn().mockReturnValue(true), + getOAuthTokens: vi.fn(), + }); + const oauthClient = createMockOAuthClient(); + + const middleware = new TokenRefreshMiddleware( + configManager as never, + oauthClient as never, + ); + + await expect(middleware.getValidAccessToken()).rejects.toThrow( + TokenExpiredError, + ); + await expect(middleware.getValidAccessToken()).rejects.toThrow( + "run 'ably login' again", + ); + }); + }); + + describe("refresh failure", () => { + it("propagates error when refreshAccessToken throws", async () => { + const configManager = createMockConfigManager({ + getAuthMethod: vi.fn().mockReturnValue("oauth"), + getAccessToken: vi.fn().mockReturnValue("expired_token"), + isAccessTokenExpired: vi.fn().mockReturnValue(true), + getOAuthTokens: vi.fn().mockReturnValue({ + accessToken: "expired_token", + refreshToken: "bad_refresh_token", + expiresAt: Date.now() - 1000, + }), + getCurrentAccountAlias: vi.fn().mockReturnValue("default"), + }); + const oauthClient = createMockOAuthClient({ + refreshAccessToken: vi + .fn() + .mockRejectedValue( + new Error("Token refresh failed (401): invalid_grant"), + ), + }); + + const middleware = new TokenRefreshMiddleware( + configManager as never, + oauthClient as never, + ); + + await expect(middleware.getValidAccessToken()).rejects.toThrow( + "Token refresh failed (401): invalid_grant", + ); + }); + }); +});