Skip to content

[DX-547] Add OAuth2 PKCE login flow as default authentication method#144

Open
umair-ably wants to merge 4 commits intomainfrom
oauth-login-flow
Open

[DX-547] Add OAuth2 PKCE login flow as default authentication method#144
umair-ably wants to merge 4 commits intomainfrom
oauth-login-flow

Conversation

@umair-ably
Copy link
Contributor

@umair-ably umair-ably commented Mar 3, 2026

REMOVED!!!

oauth.cli.mov

NEW - DEVICE FLOW:

OAuth.device.flow.mov

Summary by CodeRabbit

Release Notes

  • New Features
    • Added OAuth-based authentication with browser login flow.
    • Automatic access token refresh for OAuth-authenticated accounts.
    • Legacy token-based login available via --legacy flag.
    • Improved token revocation on logout for OAuth accounts.
    • Enhanced authentication success messaging and user feedback.

Note

Cursor Bugbot is generating a summary for commit 9e8402d. Configure here.

@umair-ably umair-ably requested a review from AndyTWF March 3, 2026 15:40
@vercel
Copy link

vercel bot commented Mar 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cli-web-cli Ready Ready Preview, Comment Mar 4, 2026 10:55am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

Walkthrough

This PR introduces OAuth-based authentication support, adding an OAuthClient for PKCE-based authorization flows, TokenRefreshMiddleware for automatic token refresh, OAuth token storage in ConfigManager, and updates to the login and logout commands to support both OAuth and legacy token authentication methods with proper fallback mechanisms.

Changes

Cohort / File(s) Summary
OAuth Client Implementation
src/services/oauth-client.ts, src/services/oauth-callback-page.ts, src/services/token-refresh-middleware.ts
Introduces OAuthClient with full PKCE OAuth flow, token refresh, and revocation support. Adds TokenRefreshMiddleware for automatic token validation and refresh. Provides HTML callback pages for OAuth success/error states.
Login Command
src/commands/accounts/login.ts
Reworks login flow to prioritize OAuth while supporting legacy token-based authentication. Adds --legacy flag, introduces OAuth and legacy token login methods, alias resolution, and conditional storage based on authentication method. Updated JSON output includes authMethod and autoSelected fields.
Logout Command
src/commands/accounts/logout.ts
Adds OAuth token revocation during logout; retrieves and revokes both access and refresh tokens for OAuth-authenticated accounts with best-effort error handling.
Configuration Management
src/services/config-manager.ts
Extends AccountConfig with OAuth fields (accessTokenExpiresAt, authMethod, oauthScope, refreshToken). Adds four new ConfigManager methods: storeOAuthTokens, getOAuthTokens, isAccessTokenExpired, getAuthMethod.
API & Token Refresh Integration
src/services/control-api.ts, src/control-base-command.ts
Updates ControlApi to accept and wire tokenRefreshMiddleware for automatic token refresh before API requests. ControlBaseCommand initializes OAuthClient and middleware when OAuth is configured.
Testing Infrastructure
test/helpers/mock-config-manager.ts
Extends MockConfigManager with OAuth token management methods matching the ConfigManager public API.
Test Coverage
test/unit/commands/accounts/login.test.ts, test/unit/commands/accounts/logout.test.ts, test/unit/services/oauth-client.test.ts, test/unit/services/token-refresh-middleware.test.ts
Adds comprehensive tests for OAuth login paths, token revocation during logout, full OAuthClient PKCE flow and edge cases, and TokenRefreshMiddleware token expiration and refresh logic.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as Login Command
    participant OAuthClient
    participant LocalServer as Callback Server
    participant OAuthServer
    participant ConfigManager

    User->>CLI: Run accounts:login
    CLI->>OAuthClient: login(openBrowser)
    OAuthClient->>OAuthClient: Generate PKCE verifier & challenge
    OAuthClient->>LocalServer: Start callback server on localhost
    OAuthClient->>OAuthServer: Open browser with auth URL
    OAuthServer->>User: Display login screen
    User->>OAuthServer: Authenticate and authorize
    OAuthServer->>LocalServer: Redirect to /callback with code & state
    LocalServer->>OAuthClient: Callback received
    OAuthClient->>OAuthClient: Validate state
    OAuthClient->>OAuthServer: Exchange code for tokens
    OAuthServer->>OAuthClient: Return access & refresh tokens
    OAuthClient->>CLI: Return OAuthTokens
    CLI->>ConfigManager: storeOAuthTokens(alias, tokens)
    ConfigManager->>ConfigManager: Save to config file
    CLI->>User: Success message
Loading
sequenceDiagram
    participant API as API Request
    participant ControlApi
    participant TokenRefreshMiddleware
    participant ConfigManager
    participant OAuthClient
    participant OAuthServer

    API->>ControlApi: request()
    ControlApi->>TokenRefreshMiddleware: getValidAccessToken()
    TokenRefreshMiddleware->>ConfigManager: getAuthMethod()
    alt OAuth Account
        TokenRefreshMiddleware->>ConfigManager: isAccessTokenExpired()
        alt Token Expired
            TokenRefreshMiddleware->>ConfigManager: getOAuthTokens()
            TokenRefreshMiddleware->>OAuthClient: refreshAccessToken(refreshToken)
            OAuthClient->>OAuthServer: POST token endpoint
            OAuthServer->>OAuthClient: New tokens
            TokenRefreshMiddleware->>ConfigManager: storeOAuthTokens()
            TokenRefreshMiddleware->>ControlApi: Return new accessToken
        else Token Valid
            TokenRefreshMiddleware->>ControlApi: Return existing token
        end
    else Non-OAuth Account
        TokenRefreshMiddleware->>ControlApi: Return stored token
    end
    ControlApi->>ControlApi: Execute API request
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 OAuth hops into the scene so grand,
With tokens dancing hand-in-hand!
PKCE flows and fresh refreshes,
No more passwords in the trenches! 🎉
Safe callback servers standing tall,
This login change enchants us all! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective of the pull request: implementing OAuth2 PKCE login flow as the default authentication method. The changeset confirms this is the primary feature with comprehensive OAuth implementation across multiple files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch oauth-login-flow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces an OAuth2 Authorization Code + PKCE login flow as the default authentication path for the CLI, including token persistence, automatic access-token refresh for Control API calls, and best-effort token revocation on logout.

Changes:

  • Add OAuth PKCE client + localhost callback server flow, including token refresh and revocation helpers.
  • Persist OAuth tokens (refresh token + expiry) in config and add a middleware to auto-refresh expired access tokens.
  • Update accounts:login to default to OAuth (with fallback to legacy token flow) and accounts:logout to revoke OAuth tokens; add unit tests.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/services/oauth-client.ts Implements OAuth PKCE login, token refresh, and token revocation.
src/services/oauth-callback-page.ts Adds HTML pages for success/error callback responses (with escaping).
src/services/token-refresh-middleware.ts Adds middleware to return a valid access token (refreshing when needed).
src/services/control-api.ts Integrates token refresh middleware to update access token before each request.
src/services/config-manager.ts Extends config schema + manager interface to store/read OAuth tokens and expiry.
src/control-base-command.ts Creates and wires token refresh middleware for OAuth-authenticated accounts.
src/commands/accounts/login.ts Makes OAuth the default login path; adds --legacy and refactors legacy token flow.
src/commands/accounts/logout.ts Adds best-effort OAuth token revocation during logout.
test/helpers/mock-config-manager.ts Updates mock config manager to support new OAuth config methods.
test/unit/services/token-refresh-middleware.test.ts Adds unit tests for refresh middleware behavior and error cases.
test/unit/services/oauth-client.test.ts Adds unit tests for PKCE generation, refresh, revoke, callback flow, and HTML escaping.
test/unit/commands/accounts/login.test.ts Adds tests asserting legacy/direct-token login doesn’t mark auth as OAuth.
test/unit/commands/accounts/logout.test.ts Adds tests for OAuth token revocation behavior on logout.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private async openBrowser(url: string): Promise<void> {
private async oauthLogin(flags: BaseFlags): Promise<OAuthTokens> {
const oauthClient = new OAuthClient({
controlHost: flags["control-host"],
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

OAuthClient is being configured with flags["control-host"], but that flag is documented as overriding the Control API host (default control.ably.net). OAuth endpoints live on the dashboard host (default ably.com), and there is already a global --dashboard-host flag.

To avoid surprising behavior when someone overrides --control-host (e.g. pointing Control API to a different env but still expecting OAuth against the dashboard), consider wiring OAuthClient to flags["dashboard-host"] (or introducing a dedicated --oauth-host), rather than reusing --control-host.

Suggested change
controlHost: flags["control-host"],
controlHost: flags["dashboard-host"],

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +103
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(() => {});
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This also uses flags["control-host"] to choose the OAuth revocation endpoint. Since --control-host is meant for the Control API host (see src/base-command.ts flag description), revocation may be sent to the wrong domain when users override control host.

Consider using --dashboard-host (or a dedicated OAuth host flag) to configure OAuth revocation, keeping host overrides aligned with their documented purpose.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +41
if (this.configManager.getAuthMethod() === "oauth") {
const oauthClient = new OAuthClient({
controlHost: flags["control-host"],
});
tokenRefreshMiddleware = new TokenRefreshMiddleware(
this.configManager,
oauthClient,
);
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

OAuthClient is instantiated with flags["control-host"] here, but --control-host is documented as the Control API host override. If the user overrides control host to point to another environment, token refresh/revocation may be attempted against the wrong OAuth host.

Consider configuring OAuthClient from flags["dashboard-host"] (or a dedicated OAuth host flag) instead, so host overrides remain consistent with existing flag semantics.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +33
export interface OAuthClientOptions {
controlHost?: string;
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

OAuthClientOptions.controlHost is named like the Control API host, but it’s actually used to build OAuth authorization/token/revocation endpoints (defaulting to ably.com). Renaming this option to something like oauthHost / dashboardHost would make call sites clearer and reduce the risk of wiring the wrong flag (e.g. --control-host).

Copilot uses AI. Check for mistakes.
Comment on lines +331 to +334
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.login(async (url) => {
await openUrl(url, this);
});
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

In --json / --pretty-json mode, this will still call openUrl(url, this), and openUrl always writes human-readable log lines (e.g. “Opening … in your browser…”). That will prepend non-JSON text before the final JSON output, making stdout invalid JSON for automation.

Consider skipping OAuth/interactive browser opening when shouldOutputJson(flags) is true (e.g. require a token arg / --legacy --no-browser), or call openUrl with a silent logger in JSON mode so stdout remains parseable.

Copilot uses AI. Check for mistakes.
Comment on lines +356 to +361
if (!flags["no-browser"]) {
if (!this.shouldOutputJson(flags)) {
this.log("Opening browser to get an access token...");
}
await openUrl(obtainTokenPath, this);
} else if (!this.shouldOutputJson(flags)) {
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

legacyTokenLogin() can also break JSON output: even though the surrounding code guards some this.log(...) calls, openUrl() itself logs unconditionally. If a user runs accounts:login --json without providing a token (or if OAuth falls back to legacy), the output will include “Opening …” lines before the JSON payload.

To keep JSON output valid, avoid calling openUrl() in JSON mode (or use a no-op logger), and ideally avoid any interactive prompting in JSON mode unless the token is provided via args/env.

Copilot uses AI. Check for mistakes.
@umair-ably umair-ably changed the title Add OAuth2 PKCE login flow as default authentication method [DX-547] Add OAuth2 PKCE login flow as default authentication method Mar 3, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/control-base-command.ts (1)

34-41: Pin token refresh to a fixed account alias.

Lines [34-41] create TokenRefreshMiddleware without accountAlias, so refresh writes depend on mutable current-account state. Capture and pass the alias when building the API client instance.

♻️ Suggested patch
-      if (this.configManager.getAuthMethod() === "oauth") {
+      const accountAlias = this.configManager.getCurrentAccountAlias();
+      if (this.configManager.getAuthMethod(accountAlias) === "oauth") {
         const oauthClient = new OAuthClient({
           controlHost: flags["control-host"],
         });
         tokenRefreshMiddleware = new TokenRefreshMiddleware(
           this.configManager,
           oauthClient,
+          accountAlias,
         );
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/control-base-command.ts` around lines 34 - 41, The TokenRefreshMiddleware
is created without an explicit account alias so refresh writes depend on mutable
current-account state; capture the current account alias from this.configManager
(e.g., call the existing method that returns the active account alias) before
constructing OAuthClient/TokenRefreshMiddleware and pass that alias into
TokenRefreshMiddleware (or into OAuthClient if it accepts accountAlias) so the
middleware is pinned to a fixed account; update the construction around
OAuthClient and TokenRefreshMiddleware to include the captured accountAlias
parameter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/accounts/login.ts`:
- Around line 100-108: The catch block in src/commands/accounts/login.ts
currently always falls back to interactive legacyTokenLogin even when JSON
output is requested; change the control flow so that if
this.shouldOutputJson(flags) is true the method rethrows (or returns a failed
promise) instead of calling legacyTokenLogin, and only call legacyTokenLogin
when shouldOutputJson(flags) is false. Update the catch to reference
shouldOutputJson(flags), throw the caught error (or propagate it) when JSON is
enabled, and keep the existing warn/log + accessToken = await
legacyTokenLogin(flags) path inside the non-JSON branch.

In `@src/commands/accounts/logout.ts`:
- Around line 96-98: The OAuthClient is being constructed using only
flags["control-host"], which ignores configured fallbacks; update the
OAuthClient instantiation in logout.ts to derive controlHost using the
configured fallback chain (flags["control-host"] || config.controlHost ||
'control.ably.net') and pass that value to new OAuthClient(...) so revocation
targets the configured endpoint; reference the OAuthClient construction and the
flags["control-host"] and config.controlHost symbols when making the change.
- Around line 100-103: The current Promise.all awaiting
oauthClient.revokeToken(oauthTokens.accessToken) and
oauthClient.revokeToken(oauthTokens.refreshToken) can hang logout if the remote
endpoint is slow; wrap each revoke call with a timeout guard (e.g., Promise.race
between revokeToken(...) and a short timeout-resolving promise) or run them
fire-and-forget (start revoke calls without awaiting) and ensure errors are
caught, then proceed immediately to local account removal. Update the logout
flow around the Promise.all call so revokeToken calls are bounded by the timeout
wrapper or not awaited, still catching/logging any errors from
oauthClient.revokeToken to avoid unhandled rejections.

In `@src/services/oauth-client.ts`:
- Around line 95-106: Add a timeout/abort mechanism to the OAuth HTTP calls so
fetch doesn't hang: in revokeToken() and postForTokens() create an
AbortController, start a setTimeout that calls controller.abort() after a
sensible timeout (e.g. configurable constant), pass controller.signal to fetch,
and clear the timeout on success/failure to avoid leaks; ensure fetch errors due
to abort are handled or rethrown consistently by the existing error path in
revokeToken and postForTokens so behavior remains unchanged except for enforced
timeouts.
- Around line 81-89: The refreshAccessToken flow calls postForTokens without
providing the existing refresh token as a fallback, so when the token endpoint
omits refresh_token the code later casts data.refresh_token and ends up with
undefined; update refreshAccessToken to pass the current refreshToken into
postForTokens (so postForTokens -> parseTokenResponse can accept an optional
fallbackRefreshToken) and modify parseTokenResponse to use the provided
fallbackRefreshToken when data.refresh_token is missing, ensuring the returned
OAuthTokens.refreshToken remains the existing value if the server omits a new
one; target the functions refreshAccessToken, postForTokens and
parseTokenResponse to add the fallback parameter and use it as described.

---

Nitpick comments:
In `@src/control-base-command.ts`:
- Around line 34-41: The TokenRefreshMiddleware is created without an explicit
account alias so refresh writes depend on mutable current-account state; capture
the current account alias from this.configManager (e.g., call the existing
method that returns the active account alias) before constructing
OAuthClient/TokenRefreshMiddleware and pass that alias into
TokenRefreshMiddleware (or into OAuthClient if it accepts accountAlias) so the
middleware is pinned to a fixed account; update the construction around
OAuthClient and TokenRefreshMiddleware to include the captured accountAlias
parameter.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 040e7df and a7fc9df.

📒 Files selected for processing (13)
  • src/commands/accounts/login.ts
  • src/commands/accounts/logout.ts
  • src/control-base-command.ts
  • src/services/config-manager.ts
  • src/services/control-api.ts
  • src/services/oauth-callback-page.ts
  • src/services/oauth-client.ts
  • src/services/token-refresh-middleware.ts
  • test/helpers/mock-config-manager.ts
  • test/unit/commands/accounts/login.test.ts
  • test/unit/commands/accounts/logout.test.ts
  • test/unit/services/oauth-client.test.ts
  • test/unit/services/token-refresh-middleware.test.ts

Comment on lines 100 to 108
} catch (error) {
if (!this.shouldOutputJson(flags)) {
this.log("Opening browser to get an access token...");
this.warn(
`OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
);
this.log("Falling back to manual token login...");
}

await this.openBrowser(obtainTokenPath);
} else if (!this.shouldOutputJson(flags)) {
this.log(`Please visit ${obtainTokenPath} to create an access token`);
accessToken = await this.legacyTokenLogin(flags);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid interactive fallback when --json is enabled.

Lines [100-108] fall back to legacyTokenLogin after OAuth failure, which prompts for token input and can hang non-interactive JSON workflows.

✅ Suggested patch
       } catch (error) {
-        if (!this.shouldOutputJson(flags)) {
-          this.warn(
-            `OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
-          );
-          this.log("Falling back to manual token login...");
-        }
-        accessToken = await this.legacyTokenLogin(flags);
+        if (this.shouldOutputJson(flags)) {
+          throw new Error(
+            `OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
+          );
+        }
+        this.warn(
+          `OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
+        );
+        this.log("Falling back to manual token login...");
+        accessToken = await this.legacyTokenLogin(flags);
       }
📝 Committable suggestion

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

Suggested change
} catch (error) {
if (!this.shouldOutputJson(flags)) {
this.log("Opening browser to get an access token...");
this.warn(
`OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
);
this.log("Falling back to manual token login...");
}
await this.openBrowser(obtainTokenPath);
} else if (!this.shouldOutputJson(flags)) {
this.log(`Please visit ${obtainTokenPath} to create an access token`);
accessToken = await this.legacyTokenLogin(flags);
}
} catch (error) {
if (this.shouldOutputJson(flags)) {
throw new Error(
`OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.warn(
`OAuth login failed: ${error instanceof Error ? error.message : String(error)}`,
);
this.log("Falling back to manual token login...");
accessToken = await this.legacyTokenLogin(flags);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/accounts/login.ts` around lines 100 - 108, The catch block in
src/commands/accounts/login.ts currently always falls back to interactive
legacyTokenLogin even when JSON output is requested; change the control flow so
that if this.shouldOutputJson(flags) is true the method rethrows (or returns a
failed promise) instead of calling legacyTokenLogin, and only call
legacyTokenLogin when shouldOutputJson(flags) is false. Update the catch to
reference shouldOutputJson(flags), throw the caught error (or propagate it) when
JSON is enabled, and keep the existing warn/log + accessToken = await
legacyTokenLogin(flags) path inside the non-JSON branch.

Comment on lines +96 to +98
const oauthClient = new OAuthClient({
controlHost: flags["control-host"],
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use configured host fallback when creating OAuthClient.

At Line [97], revocation host is sourced only from flags["control-host"]. If users rely on configured host values, revocation can target the wrong endpoint and silently not revoke tokens.

As per coding guidelines, "Use configuration for endpoint URLs (controlHost) instead of hardcoding endpoint URLs, following the pattern: flags.controlHost || config.controlHost || 'control.ably.net'."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/accounts/logout.ts` around lines 96 - 98, The OAuthClient is
being constructed using only flags["control-host"], which ignores configured
fallbacks; update the OAuthClient instantiation in logout.ts to derive
controlHost using the configured fallback chain (flags["control-host"] ||
config.controlHost || 'control.ably.net') and pass that value to new
OAuthClient(...) so revocation targets the configured endpoint; reference the
OAuthClient construction and the flags["control-host"] and config.controlHost
symbols when making the change.

Comment on lines +100 to +103
await Promise.all([
oauthClient.revokeToken(oauthTokens.accessToken),
oauthClient.revokeToken(oauthTokens.refreshToken),
]).catch(() => {});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Best-effort revocation should not block logout indefinitely.

At Line [100], logout still awaits both remote revocation calls. A slow/unresponsive endpoint can stall logout before local account removal.

Suggested timeout guard
-        // Best-effort revocation -- don't block on failure
-        await Promise.all([
-          oauthClient.revokeToken(oauthTokens.accessToken),
-          oauthClient.revokeToken(oauthTokens.refreshToken),
-        ]).catch(() => {});
+        // Best-effort revocation -- don't block on failure or long hangs
+        const revokeWithTimeout = (token: string, timeoutMs = 3000) =>
+          Promise.race([
+            oauthClient.revokeToken(token),
+            new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
+          ]);
+
+        await Promise.all([
+          revokeWithTimeout(oauthTokens.accessToken),
+          revokeWithTimeout(oauthTokens.refreshToken),
+        ]).catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/accounts/logout.ts` around lines 100 - 103, The current
Promise.all awaiting oauthClient.revokeToken(oauthTokens.accessToken) and
oauthClient.revokeToken(oauthTokens.refreshToken) can hang logout if the remote
endpoint is slow; wrap each revoke call with a timeout guard (e.g., Promise.race
between revokeToken(...) and a short timeout-resolving promise) or run them
fire-and-forget (start revoke calls without awaiting) and ensure errors are
caught, then proceed immediately to local account removal. Update the logout
flow around the Promise.all call so revokeToken calls are bounded by the timeout
wrapper or not awaited, still catching/logging any errors from
oauthClient.revokeToken to avoid unhandled rejections.

Comment on lines +81 to +89
async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
return this.postForTokens(
{
client_id: this.config.clientId,
grant_type: "refresh_token",
refresh_token: refreshToken,
},
"Token refresh",
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "oauth-client.ts" --type f

Repository: ably/ably-cli

Length of output: 85


🏁 Script executed:

cat -n src/services/oauth-client.ts | head -100

Repository: ably/ably-cli

Length of output: 3168


🏁 Script executed:

cat -n src/services/oauth-client.ts | sed -n '280,320p'

Repository: ably/ably-cli

Length of output: 1575


🌐 Web query:

OAuth 2.0 refresh token grant response specification RFC 6749

💡 Result:

In RFC 6749, the Refresh Token Grant is defined in Section 6, and its token endpoint response is the standard token response from Section 5.1 (success) or Section 5.2 (error). (datatracker.ietf.org)

Successful response (RFC 6749 §5.1; used by §6)

HTTP 200 with Content-Type: application/json and the following headers:

JSON body parameters (top-level):

  • access_token (REQUIRED)
  • token_type (REQUIRED)
  • expires_in (RECOMMENDED)
  • refresh_token (OPTIONAL; the server may issue a new one—if it does, the client must replace/discard the old one) (datatracker.ietf.org)
  • scope (OPTIONAL if identical to what was requested; otherwise REQUIRED) (datatracker.ietf.org)

Error response (RFC 6749 §5.2; used by §6)

Typically HTTP 400 with application/json, plus the same no-store / no-cache headers, and JSON:

Error codes include: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope. (datatracker.ietf.org)

Citations:


🌐 Web query:

Ably OAuth 2.0 refresh token grant response includes refresh_token field

💡 Result:

Ably’s token authentication is not OAuth 2.0, so there is no OAuth “refresh_token grant” and an Ably token response does not include a refresh_token field. Instead, Ably returns a TokenDetails object whose fields are token, expires, issued, capability, and (optionally) clientId—and when a token is near expiry you obtain a new Ably token (the SDK can do this automatically via authUrl/authCallback). [1], [2]


🏁 Script executed:

pnpm exec eslint src/services/oauth-client.ts --format=compact

Repository: ably/ably-cli

Length of output: 864


🏁 Script executed:

rg -A 5 -B 5 "refresh_token" src/ --type ts --type tsx -i

Repository: ably/ably-cli

Length of output: 84


🏁 Script executed:

fd "test" --type f | head -20

Repository: ably/ably-cli

Length of output: 863


🌐 Web query:

Ably OAuth 2.0 authorization server refresh token endpoint specification

💡 Result:

Ably is not an OAuth 2.0 / OpenID Connect authorization server, so it does not provide an OAuth 2.0 “refresh token endpoint” (i.e., no /oauth/token refresh-token grant for Ably auth).

Instead, Ably uses API keys and Ably-issued short-lived tokens (“token authentication”). When a token is near expiry, the Ably SDK automatically renews it by calling your authUrl (or authCallback) so your backend can mint a fresh Ably token (typically by using an API key to create a token request / token). Ably’s docs describe this renewal mechanism as “Token refresh” handled by the SDK. [1], [2]


🏁 Script executed:

rg "tokenEndpoint\|oauth" src/services/oauth-client.ts -A 2 -B 2

Repository: ably/ably-cli

Length of output: 39


🏁 Script executed:

rg "getOAuthConfig" src/services/oauth-client.ts -A 20

Repository: ably/ably-cli

Length of output: 1466


🏁 Script executed:

fd -e test.ts -e spec.ts -e test.js | xargs grep -l "oauth" -i 2>/dev/null | head -5

Repository: ably/ably-cli

Length of output: 233


🏁 Script executed:

cat -n src/services/oauth-client.ts | sed -n '100,200p'

Repository: ably/ably-cli

Length of output: 3956


🏁 Script executed:

cat -n test/unit/services/oauth-client.test.ts | head -150

Repository: ably/ably-cli

Length of output: 6349


🏁 Script executed:

cat -n test/unit/services/oauth-client.test.ts | grep -A 10 -B 5 "refresh"

Repository: ably/ably-cli

Length of output: 5383


🏁 Script executed:

cat -n test/unit/services/oauth-client.test.ts | grep -A 15 "parseTokenResponse\|data\\.refresh_token"

Repository: ably/ably-cli

Length of output: 39


Preserve the existing refresh token when OAuth responses omit refresh_token.

According to OAuth 2.0 RFC 6749 §5.1, the refresh_token field in a token endpoint response is optional. If an authorization server omits it (allowed by spec), line 312 hard-casts data.refresh_token as string, resulting in undefined being assigned to the required refreshToken: string field. This breaks the next refresh cycle.

Pass the current refresh token as a fallback to parseTokenResponse() and use it when the response omits the field, preserving session continuity:

♻️ Suggested patch
-  async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
+  async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
     return this.postForTokens(
       {
         client_id: this.config.clientId,
         grant_type: "refresh_token",
         refresh_token: refreshToken,
       },
       "Token refresh",
+      refreshToken,
     );
   }

   private async postForTokens(
     params: Record<string, string>,
     operationName: string,
+    fallbackRefreshToken?: string,
   ): Promise<OAuthTokens> {
@@
-    return this.parseTokenResponse(data);
+    return this.parseTokenResponse(data, fallbackRefreshToken);
   }

-  private parseTokenResponse(data: Record<string, unknown>): OAuthTokens {
+  private parseTokenResponse(
+    data: Record<string, unknown>,
+    fallbackRefreshToken?: string,
+  ): OAuthTokens {
+    const accessToken =
+      typeof data.access_token === "string" ? data.access_token : undefined;
+    const refreshToken =
+      typeof data.refresh_token === "string"
+        ? data.refresh_token
+        : fallbackRefreshToken;
+    if (!accessToken || !refreshToken) {
+      throw new Error("Token response missing required fields");
+    }
     const expiresIn = (data.expires_in as number) || 3600;
     return {
-      accessToken: data.access_token as string,
+      accessToken,
       expiresAt: Date.now() + expiresIn * 1000,
-      refreshToken: data.refresh_token as string,
+      refreshToken,
       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),
     };
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/oauth-client.ts` around lines 81 - 89, The refreshAccessToken
flow calls postForTokens without providing the existing refresh token as a
fallback, so when the token endpoint omits refresh_token the code later casts
data.refresh_token and ends up with undefined; update refreshAccessToken to pass
the current refreshToken into postForTokens (so postForTokens ->
parseTokenResponse can accept an optional fallbackRefreshToken) and modify
parseTokenResponse to use the provided fallbackRefreshToken when
data.refresh_token is missing, ensuring the returned OAuthTokens.refreshToken
remains the existing value if the server omits a new one; target the functions
refreshAccessToken, postForTokens and parseTokenResponse to add the fallback
parameter and use it as described.

Comment on lines +95 to +106
async revokeToken(token: string): Promise<void> {
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",
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, get the file and check the specific line ranges mentioned
wc -l src/services/oauth-client.ts

Repository: ably/ably-cli

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Read the specific line ranges mentioned in the review
echo "=== Lines 95-106 (revokeToken) ==="
sed -n '95,106p' src/services/oauth-client.ts
echo ""
echo "=== Lines 290-294 (token exchange/refresh) ==="
sed -n '280,300p' src/services/oauth-client.ts

Repository: ably/ably-cli

Length of output: 1068


🏁 Script executed:

#!/bin/bash
# Search for all fetch calls in the file to understand the scope
rg -n 'fetch\(' src/services/oauth-client.ts -A 8 -B 2

Repository: ably/ably-cli

Length of output: 768


🏁 Script executed:

#!/bin/bash
# Check if any signal/AbortController is used in the file
rg -n 'AbortController|signal\s*:' src/services/oauth-client.ts -B 2 -A 2

Repository: ably/ably-cli

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Run ESLint on the file to check for compliance
pnpm exec eslint src/services/oauth-client.ts 2>&1 | head -50

Repository: ably/ably-cli

Length of output: 864


Add timeouts to OAuth HTTP calls to avoid indefinite hangs.

Lines 102 and 290 perform network requests without an abort/timeout mechanism. A stalled network can leave login/refresh stuck indefinitely.

Expected result: each fetch call should have a signal tied to an AbortController with a timeout. Apply this pattern to both revokeToken() (line 102) and postForTokens() (line 290).

🛡️ Suggested patch pattern
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 15_000);
     const response = await fetch(this.config.tokenEndpoint, {
       body: body.toString(),
       headers: { "Content-Type": "application/x-www-form-urlencoded" },
       method: "POST",
+      signal: controller.signal,
     });
+    clearTimeout(timeout);

Apply the same timeout/abort pattern to revokeToken().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/oauth-client.ts` around lines 95 - 106, Add a timeout/abort
mechanism to the OAuth HTTP calls so fetch doesn't hang: in revokeToken() and
postForTokens() create an AbortController, start a setTimeout that calls
controller.abort() after a sensible timeout (e.g. configurable constant), pass
controller.signal to fetch, and clear the timeout on success/failure to avoid
leaks; ensure fetch errors due to abort are handled or rethrown consistently by
the existing error path in revokeToken and postForTokens so behavior remains
unchanged except for enforced timeouts.

@lmars
Copy link
Member

lmars commented Mar 3, 2026

@umair-ably this looks great, but OOI, did you consider OAuth 2.0 Device Authorization Grant?

@umair-ably
Copy link
Contributor Author

@umair-ably this looks great, but OOI, did you consider OAuth 2.0 Device Authorization Grant?

@lmars I didn't initially but looking into it now... I'm on the fence...

Granted, it is the solution for a headless environment but can you foresee substantial usage with that scenario? I'd expect devs are using the CLI locally, in which a 1-click authorise button in a browser is a significantly better experience than needing to enter an auth code yourself imo (which is also what we wanted to solve by getting rid of the need to copy the Control API Access Token).

Unless we already know there's headless usage of the CLI (or foresee there will be a significant amount), I suggest we stick with this. We can quite easily introduce an additional login mode e.g. ably login --use-device-code (or check if the user can open a browser and then switch between the 2 login modes).

WDYT?

@lmars
Copy link
Member

lmars commented Mar 3, 2026

@umair-ably the flow would be the same as what's in the video, but rather than a user going to the regular ably.com login page, being redirected to a random localhost port, and the local server exchanging the code for an access token, the flow would be:

  • CLI requests an auth code
  • CLI constructs a URL including the auth code (e.g. https://ably.com/cli/login?code=WXYZ-ABCD)
  • User visits the URL which says something like "The Ably CLI is requesting access to your account, do you accept?"
  • The user accepts and is told to return to the CLI
  • CLI polls to retrieve the access token

It's also simpler to implement (i.e. make an auth request, print the URL, poll until an access token is granted).

Up to you, though 🙂

@umair-ably
Copy link
Contributor Author

CLI constructs a URL including the auth code (e.g. https://ably.com/cli/login?code=WXYZ-ABCD)

@lmars Ahh of course - nice, I like it!

I don't see any reason not to do this now - I'll rework this and have something up for tomorrow 🙂

Replace Authorization Code + PKCE flow with Device Authorization Grant.
No localhost callback server needed, works in headless/SSH environments.
@umair-ably
Copy link
Contributor Author

@lmars updated 🙂 added a new vid in the PR description

char: "a",
description: "Alias for this account (default account if not specified)",
}),
legacy: Flags.boolean({
Copy link
Member

Choose a reason for hiding this comment

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

Why are we keeping the legacy approach?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Claude always thinking we need backwards compatibility... removed!

Device flow handles both browser and no-browser cases.
Direct token argument still works for CI/automation.
@mattheworiordan
Copy link
Member

Thanks, looks great.

You'll see I added a comment to say that I'm not sure we should continue to support legacy authentication methods. I don't really understand why we would do that. It just seems like extra maintenance and code. However, saying that, what's going to happen the moment you start using this CLI and you're already set up an OAuth, the server control access token previously? What experience will a user now have?

@umair-ably
Copy link
Contributor Author

Thanks, looks great.

You'll see I added a comment to say that I'm not sure we should continue to support legacy authentication methods. I don't really understand why we would do that. It just seems like extra maintenance and code. However, saying that, what's going to happen the moment you start using this CLI and you're already set up an OAuth, the server control access token previously? What experience will a user now have?

@mattheworiordan

If you've already auth'd with a Control API Access Token, then everything continues to work as usual. The only thing that doesn't is switching accounts. The experience here is quite nice though, it just tells you that you're not OAuth'd and to login again

@umair-ably
Copy link
Contributor Author

@mattheworiordan

Logging in when a user has multiple accounts

multi.account.mov

  After authenticating via device flow, the CLI now fetches all accounts
  the user belongs to and prompts them to choose one. Account switching
  reuses the existing OAuth token to discover and add remote accounts
  without re-authenticating.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Free Tier Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Crash when getAccounts returns empty array
    • accounts:login now falls back to the /me account when /me/accounts is empty so selectedAccountInfo is always defined.
  • ✅ Fixed: OAuth login doesn't store user email from API
    • The OAuth login path now persists user.email from /me when storing OAuth tokens if the token response omits userEmail.
  • ✅ Fixed: Token refresh ignores stored custom control host
    • OAuth refresh and logout revocation now use --control-host or fall back to the account’s stored controlHost when no flag is provided.

Create PR

Or push these changes by commenting:

@cursor push 8f8f070526
Preview (8f8f070526)
diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts
--- a/src/commands/accounts/login.ts
+++ b/src/commands/accounts/login.ts
@@ -98,21 +98,23 @@
         controlHost: flags["control-host"],
       });
 
-      const [{ user }, accounts] = await Promise.all([
+      const [{ account, user }, accounts] = await Promise.all([
         controlApi.getMe(),
         controlApi.getAccounts(),
       ]);
 
       let selectedAccountInfo: { id: string; name: string };
 
-      if (accounts.length === 1) {
+      if (accounts.length === 0) {
+        selectedAccountInfo = { id: account.id, name: account.name };
+      } else 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)
+        // Multiple accounts in JSON mode (backward compat: use first)
         selectedAccountInfo = accounts[0];
       }
 
@@ -126,10 +128,14 @@
 
       // Store based on auth method
       if (oauthTokens) {
-        this.configManager.storeOAuthTokens(alias, oauthTokens, {
-          accountId: selectedAccountInfo.id,
-          accountName: selectedAccountInfo.name,
-        });
+        this.configManager.storeOAuthTokens(
+          alias,
+          { ...oauthTokens, userEmail: oauthTokens.userEmail ?? user.email },
+          {
+            accountId: selectedAccountInfo.id,
+            accountName: selectedAccountInfo.name,
+          },
+        );
       } else {
         this.configManager.storeAccount(accessToken, alias, {
           accountId: selectedAccountInfo.id,

diff --git a/src/commands/accounts/logout.ts b/src/commands/accounts/logout.ts
--- a/src/commands/accounts/logout.ts
+++ b/src/commands/accounts/logout.ts
@@ -58,9 +58,10 @@
     }
 
     const accounts = this.configManager.listAccounts();
-    const accountExists = accounts.some(
+    const targetAccount = accounts.find(
       (account) => account.alias === targetAlias,
-    );
+    )?.account;
+    const accountExists = Boolean(targetAccount);
 
     if (!accountExists) {
       const error = `Account with alias "${targetAlias}" not found. Use "ably accounts list" to see available accounts.`;
@@ -94,7 +95,9 @@
       const oauthTokens = this.configManager.getOAuthTokens(targetAlias);
       if (oauthTokens) {
         const oauthClient = new OAuthClient({
-          controlHost: flags["control-host"],
+          controlHost:
+            (flags["control-host"] as string | undefined) ||
+            targetAccount?.controlHost,
         });
         // Best-effort revocation -- don't block on failure
         await Promise.all([

diff --git a/src/control-base-command.ts b/src/control-base-command.ts
--- a/src/control-base-command.ts
+++ b/src/control-base-command.ts
@@ -19,21 +19,23 @@
   protected createControlApi(flags: BaseFlags): ControlApi {
     let accessToken = flags["access-token"] || process.env.ABLY_ACCESS_TOKEN;
     let tokenRefreshMiddleware: TokenRefreshMiddleware | undefined;
+    let currentAccount = this.configManager.getCurrentAccount();
 
     if (!accessToken) {
-      const account = this.configManager.getCurrentAccount();
-      if (!account) {
+      if (!currentAccount) {
         this.error(
           `No access token provided. Please specify --access-token or configure an account with "ably accounts login".`,
         );
       }
 
-      accessToken = account.accessToken;
+      accessToken = currentAccount.accessToken;
 
       // Set up token refresh middleware for OAuth accounts
       if (this.configManager.getAuthMethod() === "oauth") {
         const oauthClient = new OAuthClient({
-          controlHost: flags["control-host"],
+          controlHost:
+            (flags["control-host"] as string | undefined) ||
+            currentAccount?.controlHost,
         });
         tokenRefreshMiddleware = new TokenRefreshMiddleware(
           this.configManager,

diff --git a/test/unit/base/control-base-command.test.ts b/test/unit/base/control-base-command.test.ts
new file mode 100644
--- /dev/null
+++ b/test/unit/base/control-base-command.test.ts
@@ -1,0 +1,103 @@
+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
+import fs from "node:fs";
+import nock from "nock";
+import { Config } from "@oclif/core";
+
+import { ControlBaseCommand } from "../../../src/control-base-command.js";
+import {
+  ConfigManager,
+  TomlConfigManager,
+} from "../../../src/services/config-manager.js";
+import { BaseFlags } from "../../../src/types/cli.js";
+
+class TestControlCommand extends ControlBaseCommand {
+  public testCreateControlApi(flags: BaseFlags) {
+    return this.createControlApi(flags);
+  }
+
+  public set testConfigManager(value: ConfigManager) {
+    this.configManager = value;
+  }
+
+  async run(): Promise<void> {
+    // No-op
+  }
+}
+
+describe("ControlBaseCommand", () => {
+  let command: TestControlCommand;
+  let originalEnv: NodeJS.ProcessEnv;
+
+  beforeEach(() => {
+    originalEnv = { ...process.env };
+    delete process.env.ABLY_ACCESS_TOKEN;
+
+    vi.spyOn(fs, "existsSync").mockReturnValue(true);
+    vi.spyOn(fs, "readFileSync").mockReturnValue("");
+    vi.spyOn(
+      TomlConfigManager.prototype as unknown as {
+        ensureConfigDirExists: () => void;
+      },
+      "ensureConfigDirExists",
+    ).mockImplementation(() => {});
+    vi.spyOn(
+      TomlConfigManager.prototype as unknown as {
+        saveConfig: () => void;
+      },
+      "saveConfig",
+    ).mockImplementation(() => {});
+
+    command = new TestControlCommand([], {} as Config);
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+    nock.cleanAll();
+    vi.restoreAllMocks();
+  });
+
+  it("uses stored control host for OAuth token refresh when flag is not provided", async () => {
+    const customControlHost = "custom.ably.net";
+    const configManagerStub = {
+      getAccessToken: vi.fn().mockReturnValue("expired_access_token"),
+      getAuthMethod: vi.fn().mockReturnValue("oauth"),
+      getCurrentAccount: vi.fn().mockReturnValue({
+        accessToken: "expired_access_token",
+        controlHost: customControlHost,
+      }),
+      getCurrentAccountAlias: vi.fn().mockReturnValue("default"),
+      getOAuthTokens: vi.fn().mockReturnValue({
+        accessToken: "expired_access_token",
+        expiresAt: Date.now() - 1000,
+        refreshToken: "refresh_token",
+      }),
+      isAccessTokenExpired: vi.fn().mockReturnValue(true),
+      storeOAuthTokens: vi.fn(),
+    } as unknown as ConfigManager;
+    command.testConfigManager = configManagerStub;
+
+    const refreshScope = nock(`https://${customControlHost}`)
+      .post("/oauth/token")
+      .reply(200, {
+        access_token: "refreshed_access_token",
+        expires_in: 3600,
+        refresh_token: "refreshed_refresh_token",
+        token_type: "Bearer",
+      });
+
+    nock("https://control.ably.net")
+      .matchHeader("authorization", "Bearer refreshed_access_token")
+      .get("/v1/me")
+      .reply(200, {
+        account: { id: "acc-123", name: "Test Account" },
+        user: { email: "test@example.com" },
+      });
+
+    const controlApi = command.testCreateControlApi({});
+    const me = await controlApi.getMe();
+
+    expect(me.account.id).toBe("acc-123");
+    expect(refreshScope.isDone()).toBe(true);
+    expect(configManagerStub.storeOAuthTokens).toHaveBeenCalled();
+  });
+});

diff --git a/test/unit/commands/accounts/login.test.ts b/test/unit/commands/accounts/login.test.ts
--- a/test/unit/commands/accounts/login.test.ts
+++ b/test/unit/commands/accounts/login.test.ts
@@ -128,6 +128,48 @@
       expect(config.accounts[customAlias].userEmail).toBe("test@example.com");
     });
 
+    it("should fall back to /me account when /me/accounts is empty", async () => {
+      const fallbackAccountId = "fallback-account-id";
+      const fallbackAccountName = "Fallback Account";
+
+      // Mock the /me endpoint twice - once for initial login, once for listApps
+      nock("https://control.ably.net")
+        .get("/v1/me")
+        .twice()
+        .reply(200, {
+          account: { id: fallbackAccountId, name: fallbackAccountName },
+          user: { email: "fallback@example.com" },
+        });
+
+      // Mock an empty /me/accounts response
+      nock("https://control.ably.net").get("/v1/me/accounts").reply(200, []);
+
+      // Mock the apps list endpoint
+      nock("https://control.ably.net")
+        .get(`/v1/accounts/${fallbackAccountId}/apps`)
+        .reply(200, []);
+
+      const { stdout } = await runCommand(
+        ["accounts:login", mockAccessToken, "--json"],
+        import.meta.url,
+      );
+
+      const result = JSON.parse(stdout);
+      expect(result).toHaveProperty("success", true);
+      expect(result.account).toHaveProperty("id", fallbackAccountId);
+      expect(result.account).toHaveProperty("name", fallbackAccountName);
+
+      const mock = getMockConfigManager();
+      const config = mock.getConfig();
+      expect(config.accounts["fallback-account"]).toBeDefined();
+      expect(config.accounts["fallback-account"].accountId).toBe(
+        fallbackAccountId,
+      );
+      expect(config.accounts["fallback-account"].accountName).toBe(
+        fallbackAccountName,
+      );
+    });
+
     it("should include app info when single app is auto-selected", async () => {
       const mockAppId = "app-123";
       const mockAppName = "My Only App";
@@ -401,5 +443,53 @@
         config.accounts["test-account"].accessTokenExpiresAt,
       ).toBeUndefined();
     });
+
+    it("should store user email from /me for OAuth login when token response omits user_email", async () => {
+      const oauthAccountId = "oauth-account-id";
+      const oauthEmail = "oauth-user@example.com";
+
+      nock("https://ably.com").post("/oauth/authorize_device").reply(200, {
+        device_code: "device-code-123",
+        expires_in: 300,
+        interval: 0.01,
+        user_code: "ABC123",
+        verification_uri: "https://ably.com/verify",
+        verification_uri_complete: "https://ably.com/verify?user_code=ABC123",
+      });
+
+      nock("https://ably.com").post("/oauth/token").reply(200, {
+        access_token: "oauth_access_token",
+        expires_in: 3600,
+        refresh_token: "oauth_refresh_token",
+        token_type: "Bearer",
+      });
+
+      // Mock the /me endpoint twice - once for initial login, once for listApps
+      nock("https://control.ably.net")
+        .get("/v1/me")
+        .twice()
+        .reply(200, {
+          account: { id: oauthAccountId, name: "OAuth Account" },
+          user: { email: oauthEmail },
+        });
+
+      nock("https://control.ably.net")
+        .get("/v1/me/accounts")
+        .reply(200, [{ id: oauthAccountId, name: "OAuth Account" }]);
+
+      nock("https://control.ably.net")
+        .get(`/v1/accounts/${oauthAccountId}/apps`)
+        .reply(200, []);
+
+      await runCommand(
+        ["accounts:login", "--no-browser", "--json"],
+        import.meta.url,
+      );
+
+      const mock = getMockConfigManager();
+      const config = mock.getConfig();
+      expect(config.accounts["oauth-account"].authMethod).toBe("oauth");
+      expect(config.accounts["oauth-account"].userEmail).toBe(oauthEmail);
+    });
   });
 });

diff --git a/test/unit/commands/accounts/logout.test.ts b/test/unit/commands/accounts/logout.test.ts
--- a/test/unit/commands/accounts/logout.test.ts
+++ b/test/unit/commands/accounts/logout.test.ts
@@ -270,6 +270,40 @@
       expect(config.accounts["testaccount"]).toBeUndefined();
     });
 
+    it("should use stored control host for revocation when flag is not provided", async () => {
+      const customControlHost = "custom.ably.net";
+      const mock = getMockConfigManager();
+      mock.setConfig({
+        current: { account: "testaccount" },
+        accounts: {
+          testaccount: {
+            accessToken: "oauth_access_token",
+            accessTokenExpiresAt: Date.now() + 3600000,
+            accountId: "acc-123",
+            accountName: "Test Account",
+            authMethod: "oauth",
+            controlHost: customControlHost,
+            refreshToken: "oauth_refresh_token",
+            userEmail: "test@example.com",
+          },
+        },
+      });
+
+      const revokeScope = nock(`https://${customControlHost}`)
+        .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 not call revocation endpoint for non-OAuth account logout", async () => {
       const mock = getMockConfigManager();
       mock.setConfig({
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

} else {
// Multiple accounts in JSON mode or empty (backward compat: use first)
selectedAccountInfo = accounts[0];
}
Copy link

Choose a reason for hiding this comment

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

Crash when getAccounts returns empty array

Medium Severity

The else branch on line 114-116 handles both "multiple accounts in JSON mode" and "empty accounts list" (as the comment acknowledges). If getAccounts() returns a 200 with an empty array, accounts[0] is undefined, causing a crash when the code subsequently accesses selectedAccountInfo.id and selectedAccountInfo.name. The getAccounts fallback only covers a 404 error, not an empty successful response.

Fix in Cursor Fix in Web

this.configManager.storeOAuthTokens(alias, oauthTokens, {
accountId: selectedAccountInfo.id,
accountName: selectedAccountInfo.name,
});
Copy link

Choose a reason for hiding this comment

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

OAuth login doesn't store user email from API

Medium Severity

When storing OAuth tokens, user.email from the /me response is not passed to storeOAuthTokens, unlike the direct-token path which passes userEmail: user.email. The OAuth path relies on oauthTokens.userEmail from the token endpoint, which may be absent. This leaves userEmail undefined in the config for OAuth accounts, even though it's available from the /me call.

Fix in Cursor Fix in Web

tokenRefreshMiddleware = new TokenRefreshMiddleware(
this.configManager,
oauthClient,
);
Copy link

Choose a reason for hiding this comment

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

Token refresh ignores stored custom control host

Medium Severity

The OAuthClient for token refresh is created using only flags["control-host"], ignoring the controlHost stored in the account config. If a user logged in with --control-host localhost:3000, subsequent commands without that flag will send refresh requests to the default ably.com instead of the stored host. The same issue exists in the logout command for token revocation.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants