Skip to content

Commit 44e01ee

Browse files
committed
feat: update authentication to be in sync with the VS Code extension
1 parent b9c397a commit 44e01ee

File tree

13 files changed

+194
-111
lines changed

13 files changed

+194
-111
lines changed

packages/cli/src/auth/__tests__/device-flow.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import { RedoclyOAuthDeviceFlow } from '../device-flow.js';
22

33
describe('RedoclyOAuthDeviceFlow', () => {
44
const mockBaseUrl = 'https://test.redocly.com';
5-
const mockClientName = 'test-client';
6-
const mockVersion = '1.0.0';
75
let flow: RedoclyOAuthDeviceFlow;
86

97
beforeEach(() => {
10-
flow = new RedoclyOAuthDeviceFlow(mockBaseUrl, mockClientName, mockVersion);
8+
flow = new RedoclyOAuthDeviceFlow(mockBaseUrl);
119
});
1210

1311
describe('verifyToken', () => {

packages/cli/src/auth/__tests__/oauth-client.test.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import * as path from 'node:path';
55
import * as os from 'node:os';
66

77
describe('RedoclyOAuthClient', () => {
8-
const mockClientName = 'test-client';
9-
const mockVersion = '1.0.0';
108
const mockBaseUrl = 'https://test.redocly.com';
119
const mockHomeDir = '/mock/home/dir';
1210
const mockRedoclyDir = path.join(mockHomeDir, '.redocly');
@@ -18,7 +16,7 @@ describe('RedoclyOAuthClient', () => {
1816
vi.mock('node:os');
1917
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
2018
process.env.HOME = mockHomeDir;
21-
client = new RedoclyOAuthClient(mockClientName, mockVersion);
19+
client = new RedoclyOAuthClient();
2220
});
2321

2422
describe('login', () => {
@@ -51,7 +49,7 @@ describe('RedoclyOAuthClient', () => {
5149

5250
await client.logout();
5351

54-
expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'auth.json'));
52+
expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'credentials'));
5553
});
5654

5755
it('silently fails if token file does not exist', async () => {
@@ -81,15 +79,15 @@ describe('RedoclyOAuthClient', () => {
8179
verifyToken: vi.fn().mockResolvedValue(true),
8280
};
8381
vi.mocked(RedoclyOAuthDeviceFlow).mockImplementation(() => mockDeviceFlow as any);
84-
vi.mocked(fs.readFileSync).mockReturnValue(
85-
client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
86-
client['cipher'].final('hex')
87-
);
82+
83+
// Mock the readToken method to return a valid token
84+
const readTokenSpy = vi.spyOn(client as any, 'readToken').mockResolvedValue(mockToken);
8885

8986
const result = await client.isAuthorized(mockBaseUrl);
9087

9188
expect(result).toBe(true);
9289
expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token');
90+
expect(readTokenSpy).toHaveBeenCalled();
9391
});
9492

9593
it('returns false if token refresh fails', async () => {
@@ -102,14 +100,34 @@ describe('RedoclyOAuthClient', () => {
102100
refreshToken: vi.fn().mockRejectedValue(new Error('Refresh failed')),
103101
};
104102
vi.mocked(RedoclyOAuthDeviceFlow).mockImplementation(() => mockDeviceFlow as any);
105-
vi.mocked(fs.readFileSync).mockReturnValue(
106-
client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
107-
client['cipher'].final('hex')
108-
);
103+
104+
// Mock the readToken method to return a token that needs refresh
105+
vi.spyOn(client as any, 'readToken').mockResolvedValue(mockToken);
106+
// Mock removeToken to be called when refresh fails
107+
const removeTokenSpy = vi.spyOn(client as any, 'removeToken').mockResolvedValue(undefined);
108+
109+
const result = await client.isAuthorized(mockBaseUrl);
110+
111+
expect(result).toBe(false);
112+
expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('old-token');
113+
expect(mockDeviceFlow.refreshToken).toHaveBeenCalledWith('refresh-token');
114+
expect(removeTokenSpy).toHaveBeenCalled();
115+
});
116+
117+
it('returns false if no token exists', async () => {
118+
const mockDeviceFlow = {
119+
verifyToken: vi.fn(),
120+
};
121+
vi.mocked(RedoclyOAuthDeviceFlow).mockImplementation(() => mockDeviceFlow as any);
122+
123+
// Mock the readToken method to return null (no token)
124+
const readTokenSpy = vi.spyOn(client as any, 'readToken').mockResolvedValue(null);
109125

110126
const result = await client.isAuthorized(mockBaseUrl);
111127

112128
expect(result).toBe(false);
129+
expect(readTokenSpy).toHaveBeenCalled();
130+
expect(mockDeviceFlow.verifyToken).not.toHaveBeenCalled();
113131
});
114132
});
115133
});

packages/cli/src/auth/device-flow.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export type AuthToken = {
1313

1414
export class RedoclyOAuthDeviceFlow {
1515
private apiClient: ReuniteApiClient;
16+
private clientName = 'redocly-cli';
1617

17-
constructor(private baseUrl: string, private clientName: string, private version: string) {
18-
this.apiClient = new ReuniteApiClient(this.version, 'login');
18+
constructor(private baseUrl: string) {
19+
this.apiClient = new ReuniteApiClient('login');
1920
}
2021

2122
async run() {
@@ -82,7 +83,7 @@ export class RedoclyOAuthDeviceFlow {
8283
}
8384
}
8485

85-
async refreshToken(refreshToken: string) {
86+
async refreshToken(refreshToken?: string) {
8687
const response = await this.sendRequest(`/device-rotate-token`, 'POST', {
8788
grant_type: 'refresh_token',
8889
client_name: this.clientName,
@@ -163,7 +164,7 @@ export class RedoclyOAuthDeviceFlow {
163164
body: Record<string, unknown> | undefined = undefined,
164165
headers: Record<string, string> = {}
165166
) {
166-
url = `${this.baseUrl}${url}`;
167+
url = `${this.baseUrl}/api${url}`;
167168
const response = await this.apiClient.request(url, {
168169
body: body ? JSON.stringify(body) : body,
169170
method,
Lines changed: 77 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { homedir } from 'node:os';
2-
import * as path from 'node:path';
2+
import path from 'node:path';
33
import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
4-
import * as crypto from 'node:crypto';
4+
import crypto from 'node:crypto';
55
import { Buffer } from 'node:buffer';
66
import { logger } from '@redocly/openapi-core';
77
import { type AuthToken, RedoclyOAuthDeviceFlow } from './device-flow.js';
@@ -10,35 +10,32 @@ const SALT = '4618dbc9-8aed-4e27-aaf0-225f4603e5a4';
1010
const CRYPTO_ALGORITHM = 'aes-256-cbc';
1111

1212
export class RedoclyOAuthClient {
13-
private dir: string;
14-
private cipher: crypto.Cipher;
15-
private decipher: crypto.Decipher;
16-
17-
constructor(private clientName: string, private version: string) {
18-
this.dir = path.join(homedir(), '.redocly');
19-
if (!existsSync(this.dir)) {
20-
mkdirSync(this.dir);
21-
}
13+
private static readonly TOKEN_FILE = 'credentials';
14+
private static readonly LEGACY_TOKEN_FILE = 'auth.json';
15+
16+
private readonly dir: string;
17+
private readonly key: Buffer;
18+
private readonly iv: Buffer;
19+
20+
constructor() {
21+
const homeDirPath = homedir();
22+
23+
this.dir = path.join(homeDirPath, '.redocly');
24+
mkdirSync(this.dir, { recursive: true });
25+
26+
this.key = crypto.createHash('sha256').update(`${homeDirPath}${SALT}`).digest(); // 32-byte key
27+
this.iv = crypto.createHash('md5').update(homeDirPath).digest(); // 16-byte IV
28+
29+
// TODO: Remove this after few months
30+
const legacyTokenPath = path.join(this.dir, RedoclyOAuthClient.LEGACY_TOKEN_FILE);
2231

23-
const homeDirPath = process.env.HOME as string;
24-
const hash = crypto.createHash('sha256');
25-
hash.update(`${homeDirPath}${SALT}`);
26-
const hashHex = hash.digest('hex');
27-
28-
const key = Buffer.alloc(
29-
32,
30-
Buffer.from(hashHex).toString('base64')
31-
).toString() as crypto.CipherKey;
32-
const iv = Buffer.alloc(
33-
16,
34-
Buffer.from(process.env.HOME as string).toString('base64')
35-
).toString() as crypto.BinaryLike;
36-
this.cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, key, iv);
37-
this.decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, key, iv);
32+
if (existsSync(legacyTokenPath)) {
33+
rmSync(legacyTokenPath);
34+
}
3835
}
3936

40-
async login(baseUrl: string) {
41-
const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
37+
public async login(baseUrl: string) {
38+
const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl);
4239

4340
const token = await deviceFlow.run();
4441
if (!token) {
@@ -47,66 +44,95 @@ export class RedoclyOAuthClient {
4744
this.saveToken(token);
4845
}
4946

50-
async logout() {
47+
public async logout() {
5148
try {
5249
this.removeToken();
5350
} catch (err) {
5451
// do nothing
5552
}
5653
}
5754

58-
async isAuthorized(baseUrl: string, apiKey?: string) {
59-
const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
60-
55+
public async isAuthorized(reuniteUrl: string, apiKey?: string): Promise<boolean> {
6156
if (apiKey) {
62-
return await deviceFlow.verifyApiKey(apiKey);
57+
const deviceFlow = new RedoclyOAuthDeviceFlow(reuniteUrl);
58+
return deviceFlow.verifyApiKey(apiKey);
6359
}
6460

61+
const token = await this.getToken(reuniteUrl);
62+
63+
return Boolean(token);
64+
}
65+
66+
public getToken = async (reuniteUrl: string): Promise<AuthToken | null> => {
67+
const deviceFlow = new RedoclyOAuthDeviceFlow(reuniteUrl);
6568
const token = await this.readToken();
69+
6670
if (!token) {
67-
return false;
71+
return null;
6872
}
6973

70-
const isValidAccessToken = await deviceFlow.verifyToken(token.access_token);
74+
const isValid = await deviceFlow.verifyToken(token.access_token);
7175

72-
if (isValidAccessToken) {
73-
return true;
76+
if (isValid) {
77+
return token;
7478
}
7579

7680
try {
7781
const newToken = await deviceFlow.refreshToken(token.refresh_token);
7882
await this.saveToken(newToken);
83+
return newToken;
7984
} catch {
80-
return false;
85+
await this.removeToken();
86+
return null;
8187
}
88+
};
8289

83-
return true;
90+
private get tokenPath() {
91+
return path.join(this.dir, RedoclyOAuthClient.TOKEN_FILE);
8492
}
8593

86-
private async saveToken(token: AuthToken) {
94+
private async saveToken(token: AuthToken): Promise<void> {
8795
try {
88-
const encrypted =
89-
this.cipher.update(JSON.stringify(token), 'utf8', 'hex') + this.cipher.final('hex');
90-
writeFileSync(path.join(this.dir, 'auth.json'), encrypted);
96+
const encrypted = this.encryptToken(token);
97+
writeFileSync(this.tokenPath, encrypted, 'utf8');
9198
} catch (error) {
9299
logger.error(`Error saving tokens: ${error}`);
93100
}
94101
}
95102

96-
private async readToken() {
103+
private async readToken(): Promise<AuthToken | null> {
104+
if (!existsSync(this.tokenPath)) {
105+
return null;
106+
}
107+
97108
try {
98-
const token = readFileSync(path.join(this.dir, 'auth.json'), 'utf8');
99-
const decrypted = this.decipher.update(token, 'hex', 'utf8') + this.decipher.final('utf8');
100-
return decrypted ? JSON.parse(decrypted) : null;
109+
const encrypted = readFileSync(this.tokenPath, 'utf8');
110+
return this.decryptToken(encrypted);
101111
} catch {
102112
return null;
103113
}
104114
}
105115

106-
private async removeToken() {
107-
const tokenPath = path.join(this.dir, 'auth.json');
108-
if (existsSync(tokenPath)) {
109-
rmSync(tokenPath);
116+
private async removeToken(): Promise<void> {
117+
if (existsSync(this.tokenPath)) {
118+
rmSync(this.tokenPath);
110119
}
111120
}
121+
122+
private encryptToken(token: AuthToken): string {
123+
const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, this.key, this.iv);
124+
const encrypted = Buffer.concat([cipher.update(JSON.stringify(token), 'utf8'), cipher.final()]);
125+
126+
return encrypted.toString('hex');
127+
}
128+
129+
private decryptToken(encryptedToken: string): AuthToken {
130+
const decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, this.key, this.iv);
131+
const decrypted = Buffer.concat([
132+
decipher.update(Buffer.from(encryptedToken, 'hex')),
133+
decipher.final(),
134+
]);
135+
136+
return JSON.parse(decrypted.toString('utf8'));
137+
}
112138
}

packages/cli/src/commands/auth.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,10 @@ export type LoginArgv = {
1010
config?: string;
1111
};
1212

13-
export async function handleLogin({ argv, version, config }: CommandArgs<LoginArgv>) {
14-
const residency = argv.residency || config?.resolvedConfig?.residency;
15-
const reuniteUrl = getReuniteUrl(residency);
13+
export async function handleLogin({ argv, config }: CommandArgs<LoginArgv>) {
14+
const reuniteUrl = getReuniteUrl(config, argv.residency);
1615
try {
17-
const oauthClient = new RedoclyOAuthClient('redocly-cli', version);
16+
const oauthClient = new RedoclyOAuthClient();
1817
await oauthClient.login(reuniteUrl);
1918
} catch {
2019
if (argv.residency) {
@@ -29,8 +28,8 @@ export type LogoutArgv = {
2928
config?: string;
3029
};
3130

32-
export async function handleLogout({ version }: CommandArgs<LogoutArgv>) {
33-
const oauthClient = new RedoclyOAuthClient('redocly-cli', version);
31+
export async function handleLogout() {
32+
const oauthClient = new RedoclyOAuthClient();
3433
oauthClient.logout();
3534

3635
logger.output('Logged out from the Redocly account. ✋ \n');

packages/cli/src/reunite/api/__tests__/api.client.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { red, yellow } from 'colorette';
22
import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client.js';
3+
import { version } from '../../../utils/package.js';
34

45
const originalFetch = global.fetch;
56

@@ -22,15 +23,14 @@ describe('ApiClient', () => {
2223
const testDomain = 'test-domain.com';
2324
const testOrg = 'test-org';
2425
const testProject = 'test-project';
25-
const version = '1.0.0';
2626
const command = 'push';
2727
const expectedUserAgent = `redocly-cli/${version} ${command}`;
2828

2929
describe('getDefaultBranch()', () => {
3030
let apiClient: ReuniteApi;
3131

3232
beforeEach(() => {
33-
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
33+
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, command });
3434
});
3535

3636
it('should get default project branch', async () => {
@@ -111,7 +111,7 @@ describe('ApiClient', () => {
111111
let apiClient: ReuniteApi;
112112

113113
beforeEach(() => {
114-
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
114+
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, command });
115115
});
116116

117117
it('should upsert remote', async () => {
@@ -216,7 +216,7 @@ describe('ApiClient', () => {
216216
let apiClient: ReuniteApi;
217217

218218
beforeEach(() => {
219-
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
219+
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, command });
220220
});
221221

222222
it('should push to remote', async () => {
@@ -352,7 +352,7 @@ describe('ApiClient', () => {
352352
let apiClient: ReuniteApi;
353353

354354
beforeEach(() => {
355-
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
355+
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, command });
356356
});
357357

358358
it.each(endpointMocks)(

0 commit comments

Comments
 (0)