Skip to content

Commit 8f8f070

Browse files
committed
Fix account login OAuth metadata and control-host fallback
1 parent 9e8402d commit 8f8f070

File tree

6 files changed

+252
-14
lines changed

6 files changed

+252
-14
lines changed

src/commands/accounts/login.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,23 @@ export default class AccountsLogin extends ControlBaseCommand {
9898
controlHost: flags["control-host"],
9999
});
100100

101-
const [{ user }, accounts] = await Promise.all([
101+
const [{ account, user }, accounts] = await Promise.all([
102102
controlApi.getMe(),
103103
controlApi.getAccounts(),
104104
]);
105105

106106
let selectedAccountInfo: { id: string; name: string };
107107

108-
if (accounts.length === 1) {
108+
if (accounts.length === 0) {
109+
selectedAccountInfo = { id: account.id, name: account.name };
110+
} else if (accounts.length === 1) {
109111
selectedAccountInfo = accounts[0];
110112
} else if (accounts.length > 1 && !this.shouldOutputJson(flags)) {
111113
const picked =
112114
await this.interactiveHelper.selectAccountFromApi(accounts);
113115
selectedAccountInfo = picked ?? accounts[0];
114116
} else {
115-
// Multiple accounts in JSON mode or empty (backward compat: use first)
117+
// Multiple accounts in JSON mode (backward compat: use first)
116118
selectedAccountInfo = accounts[0];
117119
}
118120

@@ -126,10 +128,14 @@ export default class AccountsLogin extends ControlBaseCommand {
126128

127129
// Store based on auth method
128130
if (oauthTokens) {
129-
this.configManager.storeOAuthTokens(alias, oauthTokens, {
130-
accountId: selectedAccountInfo.id,
131-
accountName: selectedAccountInfo.name,
132-
});
131+
this.configManager.storeOAuthTokens(
132+
alias,
133+
{ ...oauthTokens, userEmail: oauthTokens.userEmail ?? user.email },
134+
{
135+
accountId: selectedAccountInfo.id,
136+
accountName: selectedAccountInfo.name,
137+
},
138+
);
133139
} else {
134140
this.configManager.storeAccount(accessToken, alias, {
135141
accountId: selectedAccountInfo.id,

src/commands/accounts/logout.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ export default class AccountsLogout extends ControlBaseCommand {
5858
}
5959

6060
const accounts = this.configManager.listAccounts();
61-
const accountExists = accounts.some(
61+
const targetAccount = accounts.find(
6262
(account) => account.alias === targetAlias,
63-
);
63+
)?.account;
64+
const accountExists = Boolean(targetAccount);
6465

6566
if (!accountExists) {
6667
const error = `Account with alias "${targetAlias}" not found. Use "ably accounts list" to see available accounts.`;
@@ -94,7 +95,9 @@ export default class AccountsLogout extends ControlBaseCommand {
9495
const oauthTokens = this.configManager.getOAuthTokens(targetAlias);
9596
if (oauthTokens) {
9697
const oauthClient = new OAuthClient({
97-
controlHost: flags["control-host"],
98+
controlHost:
99+
(flags["control-host"] as string | undefined) ||
100+
targetAccount?.controlHost,
98101
});
99102
// Best-effort revocation -- don't block on failure
100103
await Promise.all([

src/control-base-command.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ export abstract class ControlBaseCommand extends AblyBaseCommand {
1919
protected createControlApi(flags: BaseFlags): ControlApi {
2020
let accessToken = flags["access-token"] || process.env.ABLY_ACCESS_TOKEN;
2121
let tokenRefreshMiddleware: TokenRefreshMiddleware | undefined;
22+
let currentAccount = this.configManager.getCurrentAccount();
2223

2324
if (!accessToken) {
24-
const account = this.configManager.getCurrentAccount();
25-
if (!account) {
25+
if (!currentAccount) {
2626
this.error(
2727
`No access token provided. Please specify --access-token or configure an account with "ably accounts login".`,
2828
);
2929
}
3030

31-
accessToken = account.accessToken;
31+
accessToken = currentAccount.accessToken;
3232

3333
// Set up token refresh middleware for OAuth accounts
3434
if (this.configManager.getAuthMethod() === "oauth") {
3535
const oauthClient = new OAuthClient({
36-
controlHost: flags["control-host"],
36+
controlHost:
37+
(flags["control-host"] as string | undefined) ||
38+
currentAccount?.controlHost,
3739
});
3840
tokenRefreshMiddleware = new TokenRefreshMiddleware(
3941
this.configManager,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
2+
import fs from "node:fs";
3+
import nock from "nock";
4+
import { Config } from "@oclif/core";
5+
6+
import { ControlBaseCommand } from "../../../src/control-base-command.js";
7+
import {
8+
ConfigManager,
9+
TomlConfigManager,
10+
} from "../../../src/services/config-manager.js";
11+
import { BaseFlags } from "../../../src/types/cli.js";
12+
13+
class TestControlCommand extends ControlBaseCommand {
14+
public testCreateControlApi(flags: BaseFlags) {
15+
return this.createControlApi(flags);
16+
}
17+
18+
public set testConfigManager(value: ConfigManager) {
19+
this.configManager = value;
20+
}
21+
22+
async run(): Promise<void> {
23+
// No-op
24+
}
25+
}
26+
27+
describe("ControlBaseCommand", () => {
28+
let command: TestControlCommand;
29+
let originalEnv: NodeJS.ProcessEnv;
30+
31+
beforeEach(() => {
32+
originalEnv = { ...process.env };
33+
delete process.env.ABLY_ACCESS_TOKEN;
34+
35+
vi.spyOn(fs, "existsSync").mockReturnValue(true);
36+
vi.spyOn(fs, "readFileSync").mockReturnValue("");
37+
vi.spyOn(
38+
TomlConfigManager.prototype as unknown as {
39+
ensureConfigDirExists: () => void;
40+
},
41+
"ensureConfigDirExists",
42+
).mockImplementation(() => {});
43+
vi.spyOn(
44+
TomlConfigManager.prototype as unknown as {
45+
saveConfig: () => void;
46+
},
47+
"saveConfig",
48+
).mockImplementation(() => {});
49+
50+
command = new TestControlCommand([], {} as Config);
51+
});
52+
53+
afterEach(() => {
54+
process.env = originalEnv;
55+
nock.cleanAll();
56+
vi.restoreAllMocks();
57+
});
58+
59+
it("uses stored control host for OAuth token refresh when flag is not provided", async () => {
60+
const customControlHost = "custom.ably.net";
61+
const configManagerStub = {
62+
getAccessToken: vi.fn().mockReturnValue("expired_access_token"),
63+
getAuthMethod: vi.fn().mockReturnValue("oauth"),
64+
getCurrentAccount: vi.fn().mockReturnValue({
65+
accessToken: "expired_access_token",
66+
controlHost: customControlHost,
67+
}),
68+
getCurrentAccountAlias: vi.fn().mockReturnValue("default"),
69+
getOAuthTokens: vi.fn().mockReturnValue({
70+
accessToken: "expired_access_token",
71+
expiresAt: Date.now() - 1000,
72+
refreshToken: "refresh_token",
73+
}),
74+
isAccessTokenExpired: vi.fn().mockReturnValue(true),
75+
storeOAuthTokens: vi.fn(),
76+
} as unknown as ConfigManager;
77+
command.testConfigManager = configManagerStub;
78+
79+
const refreshScope = nock(`https://${customControlHost}`)
80+
.post("/oauth/token")
81+
.reply(200, {
82+
access_token: "refreshed_access_token",
83+
expires_in: 3600,
84+
refresh_token: "refreshed_refresh_token",
85+
token_type: "Bearer",
86+
});
87+
88+
nock("https://control.ably.net")
89+
.matchHeader("authorization", "Bearer refreshed_access_token")
90+
.get("/v1/me")
91+
.reply(200, {
92+
account: { id: "acc-123", name: "Test Account" },
93+
user: { email: "test@example.com" },
94+
});
95+
96+
const controlApi = command.testCreateControlApi({});
97+
const me = await controlApi.getMe();
98+
99+
expect(me.account.id).toBe("acc-123");
100+
expect(refreshScope.isDone()).toBe(true);
101+
expect(configManagerStub.storeOAuthTokens).toHaveBeenCalled();
102+
});
103+
});

test/unit/commands/accounts/login.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,48 @@ describe("accounts:login command", () => {
128128
expect(config.accounts[customAlias].userEmail).toBe("test@example.com");
129129
});
130130

131+
it("should fall back to /me account when /me/accounts is empty", async () => {
132+
const fallbackAccountId = "fallback-account-id";
133+
const fallbackAccountName = "Fallback Account";
134+
135+
// Mock the /me endpoint twice - once for initial login, once for listApps
136+
nock("https://control.ably.net")
137+
.get("/v1/me")
138+
.twice()
139+
.reply(200, {
140+
account: { id: fallbackAccountId, name: fallbackAccountName },
141+
user: { email: "fallback@example.com" },
142+
});
143+
144+
// Mock an empty /me/accounts response
145+
nock("https://control.ably.net").get("/v1/me/accounts").reply(200, []);
146+
147+
// Mock the apps list endpoint
148+
nock("https://control.ably.net")
149+
.get(`/v1/accounts/${fallbackAccountId}/apps`)
150+
.reply(200, []);
151+
152+
const { stdout } = await runCommand(
153+
["accounts:login", mockAccessToken, "--json"],
154+
import.meta.url,
155+
);
156+
157+
const result = JSON.parse(stdout);
158+
expect(result).toHaveProperty("success", true);
159+
expect(result.account).toHaveProperty("id", fallbackAccountId);
160+
expect(result.account).toHaveProperty("name", fallbackAccountName);
161+
162+
const mock = getMockConfigManager();
163+
const config = mock.getConfig();
164+
expect(config.accounts["fallback-account"]).toBeDefined();
165+
expect(config.accounts["fallback-account"].accountId).toBe(
166+
fallbackAccountId,
167+
);
168+
expect(config.accounts["fallback-account"].accountName).toBe(
169+
fallbackAccountName,
170+
);
171+
});
172+
131173
it("should include app info when single app is auto-selected", async () => {
132174
const mockAppId = "app-123";
133175
const mockAppName = "My Only App";
@@ -401,5 +443,53 @@ describe("accounts:login command", () => {
401443
config.accounts["test-account"].accessTokenExpiresAt,
402444
).toBeUndefined();
403445
});
446+
447+
it("should store user email from /me for OAuth login when token response omits user_email", async () => {
448+
const oauthAccountId = "oauth-account-id";
449+
const oauthEmail = "oauth-user@example.com";
450+
451+
nock("https://ably.com").post("/oauth/authorize_device").reply(200, {
452+
device_code: "device-code-123",
453+
expires_in: 300,
454+
interval: 0.01,
455+
user_code: "ABC123",
456+
verification_uri: "https://ably.com/verify",
457+
verification_uri_complete: "https://ably.com/verify?user_code=ABC123",
458+
});
459+
460+
nock("https://ably.com").post("/oauth/token").reply(200, {
461+
access_token: "oauth_access_token",
462+
expires_in: 3600,
463+
refresh_token: "oauth_refresh_token",
464+
token_type: "Bearer",
465+
});
466+
467+
// Mock the /me endpoint twice - once for initial login, once for listApps
468+
nock("https://control.ably.net")
469+
.get("/v1/me")
470+
.twice()
471+
.reply(200, {
472+
account: { id: oauthAccountId, name: "OAuth Account" },
473+
user: { email: oauthEmail },
474+
});
475+
476+
nock("https://control.ably.net")
477+
.get("/v1/me/accounts")
478+
.reply(200, [{ id: oauthAccountId, name: "OAuth Account" }]);
479+
480+
nock("https://control.ably.net")
481+
.get(`/v1/accounts/${oauthAccountId}/apps`)
482+
.reply(200, []);
483+
484+
await runCommand(
485+
["accounts:login", "--no-browser", "--json"],
486+
import.meta.url,
487+
);
488+
489+
const mock = getMockConfigManager();
490+
const config = mock.getConfig();
491+
expect(config.accounts["oauth-account"].authMethod).toBe("oauth");
492+
expect(config.accounts["oauth-account"].userEmail).toBe(oauthEmail);
493+
});
404494
});
405495
});

test/unit/commands/accounts/logout.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,40 @@ describe("accounts:logout command", () => {
270270
expect(config.accounts["testaccount"]).toBeUndefined();
271271
});
272272

273+
it("should use stored control host for revocation when flag is not provided", async () => {
274+
const customControlHost = "custom.ably.net";
275+
const mock = getMockConfigManager();
276+
mock.setConfig({
277+
current: { account: "testaccount" },
278+
accounts: {
279+
testaccount: {
280+
accessToken: "oauth_access_token",
281+
accessTokenExpiresAt: Date.now() + 3600000,
282+
accountId: "acc-123",
283+
accountName: "Test Account",
284+
authMethod: "oauth",
285+
controlHost: customControlHost,
286+
refreshToken: "oauth_refresh_token",
287+
userEmail: "test@example.com",
288+
},
289+
},
290+
});
291+
292+
const revokeScope = nock(`https://${customControlHost}`)
293+
.post("/oauth/revoke")
294+
.twice()
295+
.reply(200);
296+
297+
const { stdout } = await runCommand(
298+
["accounts:logout", "--force", "--json"],
299+
import.meta.url,
300+
);
301+
302+
const result = JSON.parse(stdout);
303+
expect(result).toHaveProperty("success", true);
304+
expect(revokeScope.isDone()).toBe(true);
305+
});
306+
273307
it("should not call revocation endpoint for non-OAuth account logout", async () => {
274308
const mock = getMockConfigManager();
275309
mock.setConfig({

0 commit comments

Comments
 (0)