Skip to content

Commit c8e37d1

Browse files
authored
feat: update authentication to be in sync with the VS Code extension (#2281)
1 parent 3d05d7b commit c8e37d1

File tree

14 files changed

+234
-143
lines changed

14 files changed

+234
-143
lines changed

.changeset/easy-wolves-visit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@redocly/cli": minor
3+
---
4+
5+
Updated authentication logic to ensure consistency with the VS Code extension's behavior.

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: 18 additions & 27 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 () => {
@@ -76,40 +74,33 @@ describe('RedoclyOAuthClient', () => {
7674
});
7775

7876
it('verifies access token if no API key provided', async () => {
79-
const mockToken = { access_token: 'test-token' };
80-
const mockDeviceFlow = {
81-
verifyToken: vi.fn().mockResolvedValue(true),
82-
};
83-
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-
);
77+
// Mock getAccessToken to return a valid token
78+
const getAccessTokenSpy = vi.spyOn(client, 'getAccessToken').mockResolvedValue('test-token');
8879

8980
const result = await client.isAuthorized(mockBaseUrl);
9081

9182
expect(result).toBe(true);
92-
expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token');
83+
expect(getAccessTokenSpy).toHaveBeenCalledWith(mockBaseUrl);
9384
});
9485

9586
it('returns false if token refresh fails', async () => {
96-
const mockToken = {
97-
access_token: 'old-token',
98-
refresh_token: 'refresh-token',
99-
};
100-
const mockDeviceFlow = {
101-
verifyToken: vi.fn().mockResolvedValue(false),
102-
refreshToken: vi.fn().mockRejectedValue(new Error('Refresh failed')),
103-
};
104-
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-
);
87+
// Mock getAccessToken to return null (indicating refresh failed)
88+
const getAccessTokenSpy = vi.spyOn(client, 'getAccessToken').mockResolvedValue(null);
89+
90+
const result = await client.isAuthorized(mockBaseUrl);
91+
92+
expect(result).toBe(false);
93+
expect(getAccessTokenSpy).toHaveBeenCalledWith(mockBaseUrl);
94+
});
95+
96+
it('returns false if no token exists', async () => {
97+
// Mock getAccessToken to return null (no token)
98+
const getAccessTokenSpy = vi.spyOn(client, 'getAccessToken').mockResolvedValue(null);
10999

110100
const result = await client.isAuthorized(mockBaseUrl);
111101

112102
expect(result).toBe(false);
103+
expect(getAccessTokenSpy).toHaveBeenCalledWith(mockBaseUrl);
113104
});
114105
});
115106
});

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

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@ import { logger } from '@redocly/openapi-core';
44
import { ReuniteApiClient } from '../reunite/api/api-client.js';
55
import { DEFAULT_FETCH_TIMEOUT } from '../utils/constants.js';
66

7-
export type AuthToken = {
7+
export type Credentials = {
88
access_token: string;
9-
refresh_token?: string;
10-
token_type?: string;
11-
expires_in?: number;
9+
refresh_token: string;
10+
expires_in: number;
11+
token_type?: string; // from login response
1212
};
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() {
@@ -59,7 +60,7 @@ export class RedoclyOAuthDeviceFlow {
5960
}
6061
}
6162

62-
async verifyToken(accessToken: string) {
63+
async verifyToken(accessToken: string): Promise<boolean> {
6364
try {
6465
const response = await this.sendRequest('/session', 'GET', undefined, {
6566
Cookie: `accessToken=${accessToken};`,
@@ -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,
@@ -103,7 +104,7 @@ export class RedoclyOAuthDeviceFlow {
103104
deviceCode: string,
104105
interval: number,
105106
expiresIn: number
106-
): Promise<AuthToken> {
107+
): Promise<Credentials> {
107108
return new Promise((resolve, reject) => {
108109
const intervalId = setInterval(async () => {
109110
const response = await this.getAccessToken(deviceCode);
@@ -158,12 +159,12 @@ export class RedoclyOAuthDeviceFlow {
158159
}
159160

160161
private async sendRequest(
161-
url: string,
162+
path: string,
162163
method: string = 'GET',
163164
body: Record<string, unknown> | undefined = undefined,
164165
headers: Record<string, string> = {}
165166
) {
166-
url = `${this.baseUrl}${url}`;
167+
const url = `${this.baseUrl}/api${path}`;
167168
const response = await this.apiClient.request(url, {
168169
body: body ? JSON.stringify(body) : body,
169170
method,
Lines changed: 82 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,133 @@
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';
7-
import { type AuthToken, RedoclyOAuthDeviceFlow } from './device-flow.js';
7+
import { type Credentials, RedoclyOAuthDeviceFlow } from './device-flow.js';
88

99
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+
public static readonly CREDENTIALS_FILE = 'credentials';
14+
15+
private readonly dir: string;
16+
private readonly key: Buffer;
17+
private readonly iv: Buffer;
18+
19+
constructor() {
20+
const homeDirPath = homedir();
2221

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);
22+
this.dir = path.join(homeDirPath, '.redocly');
23+
mkdirSync(this.dir, { recursive: true });
24+
25+
this.key = crypto.createHash('sha256').update(`${homeDirPath}${SALT}`).digest(); // 32-byte key
26+
this.iv = crypto.createHash('md5').update(homeDirPath).digest(); // 16-byte IV
3827
}
3928

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

43-
const token = await deviceFlow.run();
44-
if (!token) {
32+
const credentials = await deviceFlow.run();
33+
if (!credentials) {
4534
throw new Error('Failed to login');
4635
}
47-
this.saveToken(token);
36+
this.saveCredentials(credentials);
4837
}
4938

50-
async logout() {
39+
public async logout() {
5140
try {
52-
this.removeToken();
41+
this.removeCredentials();
5342
} catch (err) {
5443
// do nothing
5544
}
5645
}
5746

58-
async isAuthorized(baseUrl: string, apiKey?: string) {
59-
const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
60-
47+
public async isAuthorized(reuniteUrl: string, apiKey?: string): Promise<boolean> {
6148
if (apiKey) {
62-
return await deviceFlow.verifyApiKey(apiKey);
49+
const deviceFlow = new RedoclyOAuthDeviceFlow(reuniteUrl);
50+
return deviceFlow.verifyApiKey(apiKey);
6351
}
6452

65-
const token = await this.readToken();
66-
if (!token) {
67-
return false;
53+
const accessToken = await this.getAccessToken(reuniteUrl);
54+
55+
return Boolean(accessToken);
56+
}
57+
58+
public getAccessToken = async (reuniteUrl: string): Promise<string | null> => {
59+
const deviceFlow = new RedoclyOAuthDeviceFlow(reuniteUrl);
60+
const credentials = await this.readCredentials();
61+
62+
if (!credentials) {
63+
return null;
6864
}
6965

70-
const isValidAccessToken = await deviceFlow.verifyToken(token.access_token);
66+
const isValid = await deviceFlow.verifyToken(credentials.access_token);
7167

72-
if (isValidAccessToken) {
73-
return true;
68+
if (isValid) {
69+
return credentials.access_token;
7470
}
7571

7672
try {
77-
const newToken = await deviceFlow.refreshToken(token.refresh_token);
78-
await this.saveToken(newToken);
73+
const newCredentials = await deviceFlow.refreshToken(credentials.refresh_token);
74+
await this.saveCredentials(newCredentials);
75+
76+
return newCredentials.access_token;
7977
} catch {
80-
return false;
78+
return null;
8179
}
80+
};
8281

83-
return true;
82+
private get credentialsPath() {
83+
return path.join(this.dir, RedoclyOAuthClient.CREDENTIALS_FILE);
8484
}
8585

86-
private async saveToken(token: AuthToken) {
86+
private async saveCredentials(credentials: Credentials): Promise<void> {
8787
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);
88+
const encryptedCredentials = this.encryptCredentials(credentials);
89+
writeFileSync(this.credentialsPath, encryptedCredentials, 'utf8');
9190
} catch (error) {
92-
logger.error(`Error saving tokens: ${error}`);
91+
logger.error(`Failed to save credentials: ${error.message}`);
9392
}
9493
}
9594

96-
private async readToken() {
95+
private async readCredentials(): Promise<Credentials | null> {
96+
if (!existsSync(this.credentialsPath)) {
97+
return null;
98+
}
99+
97100
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;
101+
const encryptedCredentials = readFileSync(this.credentialsPath, 'utf8');
102+
return this.decryptCredentials(encryptedCredentials);
101103
} catch {
102104
return null;
103105
}
104106
}
105107

106-
private async removeToken() {
107-
const tokenPath = path.join(this.dir, 'auth.json');
108-
if (existsSync(tokenPath)) {
109-
rmSync(tokenPath);
108+
private async removeCredentials(): Promise<void> {
109+
if (existsSync(this.credentialsPath)) {
110+
rmSync(this.credentialsPath);
110111
}
111112
}
113+
114+
private encryptCredentials(credentials: Credentials): string {
115+
const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, this.key, this.iv);
116+
const encrypted = Buffer.concat([
117+
cipher.update(JSON.stringify(credentials), 'utf8'),
118+
cipher.final(),
119+
]);
120+
121+
return encrypted.toString('hex');
122+
}
123+
124+
private decryptCredentials(encryptedCredentials: string): Credentials {
125+
const decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, this.key, this.iv);
126+
const decrypted = Buffer.concat([
127+
decipher.update(Buffer.from(encryptedCredentials, 'hex')),
128+
decipher.final(),
129+
]);
130+
131+
return JSON.parse(decrypted.toString('utf8'));
132+
}
112133
}

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');

0 commit comments

Comments
 (0)