Skip to content

Commit b8b49fc

Browse files
committed
feat: add login via Reunite API
1 parent aa7780f commit b8b49fc

29 files changed

+630
-83
lines changed

.changeset/tough-apples-attend.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+
Added login flow based on [OAuth 2.0 Device authorization flow](https://datatracker.ietf.org/doc/html/rfc8628) that uses Reunite API.

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ module.exports = {
2121
statements: 64,
2222
branches: 52,
2323
functions: 63,
24-
lines: 65,
24+
lines: 64,
2525
},
2626
},
2727
testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],

packages/cli/src/__tests__/commands/push-region.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getMergedConfig } from '@redocly/openapi-core';
22
import { handlePush } from '../../commands/push';
3-
import { promptClientToken } from '../../commands/login';
3+
import { promptClientToken } from '../../commands/auth';
44
import { ConfigFixture } from '../fixtures/config';
55
import { Readable } from 'node:stream';
66

@@ -23,7 +23,7 @@ jest.mock('fs', () => ({
2323

2424
// Mock OpenAPI core
2525
jest.mock('@redocly/openapi-core');
26-
jest.mock('../../commands/login');
26+
jest.mock('../../commands/auth');
2727
jest.mock('../../utils/miscellaneous');
2828

2929
const mockPromptClientToken = promptClientToken as jest.MockedFunction<typeof promptClientToken>;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { RedoclyOAuthDeviceFlow } from '../device-flow';
2+
3+
jest.mock('child_process');
4+
5+
describe('RedoclyOAuthDeviceFlow', () => {
6+
const mockBaseUrl = 'https://test.redocly.com';
7+
const mockClientName = 'test-client';
8+
const mockVersion = '1.0.0';
9+
let flow: RedoclyOAuthDeviceFlow;
10+
11+
beforeEach(() => {
12+
flow = new RedoclyOAuthDeviceFlow(mockBaseUrl, mockClientName, mockVersion);
13+
jest.resetAllMocks();
14+
});
15+
16+
describe('verifyToken', () => {
17+
it('returns true for valid token', async () => {
18+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
19+
json: () => Promise.resolve({ user: { id: '123' } }),
20+
} as Response);
21+
22+
const result = await flow.verifyToken('valid-token');
23+
expect(result).toBe(true);
24+
});
25+
26+
it('returns false for invalid token', async () => {
27+
jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid token'));
28+
const result = await flow.verifyToken('invalid-token');
29+
expect(result).toBe(false);
30+
});
31+
});
32+
33+
describe('verifyApiKey', () => {
34+
it('returns true for valid API key', async () => {
35+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
36+
json: () => Promise.resolve({ success: true }),
37+
} as Response);
38+
39+
const result = await flow.verifyApiKey('valid-key');
40+
expect(result).toBe(true);
41+
});
42+
43+
it('returns false for invalid API key', async () => {
44+
jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid API key'));
45+
const result = await flow.verifyApiKey('invalid-key');
46+
expect(result).toBe(false);
47+
});
48+
});
49+
50+
describe('refreshToken', () => {
51+
it('successfully refreshes token', async () => {
52+
const mockResponse = {
53+
access_token: 'new-token',
54+
refresh_token: 'new-refresh',
55+
expires_in: 3600,
56+
};
57+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
58+
json: () => Promise.resolve(mockResponse),
59+
} as Response);
60+
61+
const result = await flow.refreshToken('old-refresh-token');
62+
expect(result).toEqual(mockResponse);
63+
});
64+
65+
it('throws error when refresh fails', async () => {
66+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
67+
json: () => Promise.resolve({}),
68+
} as Response);
69+
70+
await expect(flow.refreshToken('invalid-refresh')).rejects.toThrow('Failed to refresh token');
71+
});
72+
});
73+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { RedoclyOAuthClient } from '../oauth-client';
2+
import { RedoclyOAuthDeviceFlow } from '../device-flow';
3+
import * as fs from 'node:fs';
4+
import * as path from 'node:path';
5+
import * as os from 'node:os';
6+
7+
jest.mock('node:fs');
8+
jest.mock('node:os');
9+
jest.mock('../device-flow');
10+
11+
describe('RedoclyOAuthClient', () => {
12+
const mockClientName = 'test-client';
13+
const mockVersion = '1.0.0';
14+
const mockBaseUrl = 'https://test.redocly.com';
15+
const mockHomeDir = '/mock/home/dir';
16+
const mockRedoclyDir = path.join(mockHomeDir, '.redocly');
17+
let client: RedoclyOAuthClient;
18+
19+
beforeEach(() => {
20+
jest.resetAllMocks();
21+
(os.homedir as jest.Mock).mockReturnValue(mockHomeDir);
22+
process.env.HOME = mockHomeDir;
23+
client = new RedoclyOAuthClient(mockClientName, mockVersion);
24+
});
25+
26+
describe('login', () => {
27+
it('successfully logs in and saves token', async () => {
28+
const mockToken = { access_token: 'test-token' };
29+
const mockDeviceFlow = {
30+
run: jest.fn().mockResolvedValue(mockToken),
31+
};
32+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
33+
34+
await client.login(mockBaseUrl);
35+
36+
expect(mockDeviceFlow.run).toHaveBeenCalled();
37+
expect(fs.writeFileSync).toHaveBeenCalled();
38+
});
39+
40+
it('throws error when login fails', async () => {
41+
const mockDeviceFlow = {
42+
run: jest.fn().mockResolvedValue(null),
43+
};
44+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
45+
46+
await expect(client.login(mockBaseUrl)).rejects.toThrow('Failed to login');
47+
});
48+
});
49+
50+
describe('logout', () => {
51+
it('removes token file if it exists', async () => {
52+
(fs.existsSync as jest.Mock).mockReturnValue(true);
53+
54+
await client.logout();
55+
56+
expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'auth.json'));
57+
});
58+
59+
it('silently fails if token file does not exist', async () => {
60+
(fs.existsSync as jest.Mock).mockReturnValue(false);
61+
62+
await expect(client.logout()).resolves.not.toThrow();
63+
expect(fs.rmSync).not.toHaveBeenCalled();
64+
});
65+
});
66+
67+
describe('isAuthorized', () => {
68+
it('verifies API key if provided', async () => {
69+
const mockDeviceFlow = {
70+
verifyApiKey: jest.fn().mockResolvedValue(true),
71+
};
72+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
73+
74+
const result = await client.isAuthorized(mockBaseUrl, 'test-api-key');
75+
76+
expect(result).toBe(true);
77+
expect(mockDeviceFlow.verifyApiKey).toHaveBeenCalledWith('test-api-key');
78+
});
79+
80+
it('verifies access token if no API key provided', async () => {
81+
const mockToken = { access_token: 'test-token' };
82+
const mockDeviceFlow = {
83+
verifyToken: jest.fn().mockResolvedValue(true),
84+
};
85+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
86+
(fs.readFileSync as jest.Mock).mockReturnValue(
87+
client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
88+
client['cipher'].final('hex')
89+
);
90+
91+
const result = await client.isAuthorized(mockBaseUrl);
92+
93+
expect(result).toBe(true);
94+
expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token');
95+
});
96+
97+
it('returns false if token refresh fails', async () => {
98+
const mockToken = {
99+
access_token: 'old-token',
100+
refresh_token: 'refresh-token',
101+
};
102+
const mockDeviceFlow = {
103+
verifyToken: jest.fn().mockResolvedValue(false),
104+
refreshToken: jest.fn().mockRejectedValue(new Error('Refresh failed')),
105+
};
106+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
107+
(fs.readFileSync as jest.Mock).mockReturnValue(
108+
client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
109+
client['cipher'].final('hex')
110+
);
111+
112+
const result = await client.isAuthorized(mockBaseUrl);
113+
114+
expect(result).toBe(false);
115+
});
116+
});
117+
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { blue, green } from 'colorette';
2+
import * as childProcess from 'child_process';
3+
import { ReuniteApiClient } from '../reunite/api/api-client';
4+
5+
export type AuthToken = {
6+
access_token: string;
7+
refresh_token?: string;
8+
token_type?: string;
9+
expires_in?: number;
10+
};
11+
12+
export class RedoclyOAuthDeviceFlow {
13+
private apiClient: ReuniteApiClient;
14+
15+
constructor(private baseUrl: string, private clientName: string, private version: string) {
16+
this.apiClient = new ReuniteApiClient(this.version, 'login');
17+
}
18+
19+
async run() {
20+
const code = await this.getDeviceCode();
21+
process.stdout.write(
22+
'Attempting to automatically open the SSO authorization page in your default browser.\n'
23+
);
24+
process.stdout.write(
25+
'If the browser does not open or you wish to use a different device to authorize this request, open the following URL:\n\n'
26+
);
27+
process.stdout.write(blue(code.verificationUri));
28+
process.stdout.write(`\n\n`);
29+
process.stdout.write(`Then enter the code:\n\n`);
30+
process.stdout.write(blue(code.userCode));
31+
process.stdout.write(`\n\n`);
32+
33+
this.openBrowser(code.verificationUriComplete);
34+
35+
const accessToken = await this.pollingAccessToken(
36+
code.deviceCode,
37+
code.interval,
38+
code.expiresIn
39+
);
40+
process.stdout.write(green('✅ Logged in\n\n'));
41+
42+
return accessToken;
43+
}
44+
45+
private openBrowser(url: string) {
46+
try {
47+
const cmd =
48+
process.platform === 'win32'
49+
? `start ${url}`
50+
: process.platform === 'darwin'
51+
? `open ${url}`
52+
: `xdg-open ${url}`;
53+
54+
childProcess.execSync(cmd);
55+
} catch {
56+
// silently fail if browser cannot be opened
57+
}
58+
}
59+
60+
async verifyToken(accessToken: string) {
61+
try {
62+
const response = await this.sendRequest('/session', 'GET', undefined, {
63+
Cookie: `accessToken=${accessToken};`,
64+
});
65+
66+
return !!response.user;
67+
} catch {
68+
return false;
69+
}
70+
}
71+
72+
async verifyApiKey(apiKey: string) {
73+
try {
74+
const response = await this.sendRequest('/api-keys-verify', 'POST', {
75+
apiKey,
76+
});
77+
return !!response.success;
78+
} catch {
79+
return false;
80+
}
81+
}
82+
83+
async refreshToken(refreshToken: string) {
84+
const response = await this.sendRequest(`/device-rotate-token`, 'POST', {
85+
grant_type: 'refresh_token',
86+
client_name: this.clientName,
87+
refresh_token: refreshToken,
88+
});
89+
90+
if (!response.access_token) {
91+
throw new Error('Failed to refresh token');
92+
}
93+
return {
94+
access_token: response.access_token,
95+
refresh_token: response.refresh_token,
96+
expires_in: response.expires_in,
97+
};
98+
}
99+
100+
private async pollingAccessToken(
101+
deviceCode: string,
102+
interval: number,
103+
expiresIn: number
104+
): Promise<AuthToken> {
105+
return new Promise((resolve, reject) => {
106+
const intervalId = setInterval(async () => {
107+
const response = await this.getAccessToken(deviceCode);
108+
if (response.access_token) {
109+
clearInterval(intervalId);
110+
clearTimeout(timeoutId);
111+
resolve(response);
112+
}
113+
if (response.error && response.error !== 'authorization_pending') {
114+
clearInterval(intervalId);
115+
clearTimeout(timeoutId);
116+
reject(response.error_description);
117+
}
118+
}, interval * 1000);
119+
120+
const timeoutId = setTimeout(async () => {
121+
clearInterval(intervalId);
122+
clearTimeout(timeoutId);
123+
reject('Authorization has expired. Please try again.');
124+
}, expiresIn * 1000);
125+
});
126+
}
127+
128+
private async getAccessToken(deviceCode: string) {
129+
return await this.sendRequest('/device-token', 'POST', {
130+
client_name: this.clientName,
131+
device_code: deviceCode,
132+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
133+
});
134+
}
135+
136+
private async getDeviceCode() {
137+
const {
138+
device_code: deviceCode,
139+
user_code: userCode,
140+
verification_uri: verificationUri,
141+
verification_uri_complete: verificationUriComplete,
142+
interval = 10,
143+
expires_in: expiresIn = 300,
144+
} = await this.sendRequest('/device-authorize', 'POST', {
145+
client_name: this.clientName,
146+
});
147+
148+
return {
149+
deviceCode,
150+
userCode,
151+
verificationUri,
152+
verificationUriComplete,
153+
interval,
154+
expiresIn,
155+
};
156+
}
157+
158+
private async sendRequest(
159+
url: string,
160+
method: string = 'GET',
161+
body: Record<string, unknown> | undefined = undefined,
162+
headers: Record<string, string> = {}
163+
) {
164+
url = `${this.baseUrl}${url}`;
165+
const response = await this.apiClient.request(url, {
166+
body: body ? JSON.stringify(body) : body,
167+
method,
168+
headers: { 'Content-Type': 'application/json', ...headers },
169+
});
170+
if (response.status === 204) {
171+
return { success: true };
172+
}
173+
return await response.json();
174+
}
175+
}

0 commit comments

Comments
 (0)