From ac40c90004ea7b0f4777599863fe9fd5b89e8cf2 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Mon, 3 Feb 2025 22:46:14 +0200 Subject: [PATCH 1/7] feat: add login via Reunite API --- .changeset/tough-apples-attend.md | 5 + jest.config.js | 2 +- .../__tests__/commands/push-region.test.ts | 4 +- .../src/auth/__tests__/device-flow.test.ts | 73 ++++++++ .../src/auth/__tests__/oauth-client.test.ts | 117 ++++++++++++ packages/cli/src/auth/device-flow.ts | 175 ++++++++++++++++++ packages/cli/src/auth/oauth-client.ts | 111 +++++++++++ .../cli/src/cms/api/__tests__/domains.test.ts | 15 -- packages/cli/src/cms/api/domains.ts | 11 -- packages/cli/src/commands/auth.ts | 66 +++++++ packages/cli/src/commands/login.ts | 34 ---- packages/cli/src/commands/push.ts | 6 +- packages/cli/src/index.ts | 25 ++- .../api/__tests__/api-keys.test.ts | 0 .../api/__tests__/api.client.test.ts | 0 .../src/reunite/api/__tests__/domains.test.ts | 37 ++++ .../src/{cms => reunite}/api/api-client.ts | 2 +- .../cli/src/{cms => reunite}/api/api-keys.ts | 0 packages/cli/src/reunite/api/domains.ts | 23 +++ .../cli/src/{cms => reunite}/api/index.ts | 0 .../cli/src/{cms => reunite}/api/types.ts | 0 .../commands/__tests__/push-status.test.ts | 0 .../commands/__tests__/push.test.ts | 0 .../commands/__tests__/utils.test.ts | 0 .../{cms => reunite}/commands/push-status.ts | 0 .../cli/src/{cms => reunite}/commands/push.ts | 0 .../src/{cms => reunite}/commands/utils.ts | 0 packages/cli/src/{cms => reunite}/utils.ts | 0 packages/cli/src/types.ts | 7 +- 29 files changed, 630 insertions(+), 83 deletions(-) create mode 100644 .changeset/tough-apples-attend.md create mode 100644 packages/cli/src/auth/__tests__/device-flow.test.ts create mode 100644 packages/cli/src/auth/__tests__/oauth-client.test.ts create mode 100644 packages/cli/src/auth/device-flow.ts create mode 100644 packages/cli/src/auth/oauth-client.ts delete mode 100644 packages/cli/src/cms/api/__tests__/domains.test.ts delete mode 100644 packages/cli/src/cms/api/domains.ts create mode 100644 packages/cli/src/commands/auth.ts delete mode 100644 packages/cli/src/commands/login.ts rename packages/cli/src/{cms => reunite}/api/__tests__/api-keys.test.ts (100%) rename packages/cli/src/{cms => reunite}/api/__tests__/api.client.test.ts (100%) create mode 100644 packages/cli/src/reunite/api/__tests__/domains.test.ts rename packages/cli/src/{cms => reunite}/api/api-client.ts (99%) rename packages/cli/src/{cms => reunite}/api/api-keys.ts (100%) create mode 100644 packages/cli/src/reunite/api/domains.ts rename packages/cli/src/{cms => reunite}/api/index.ts (100%) rename packages/cli/src/{cms => reunite}/api/types.ts (100%) rename packages/cli/src/{cms => reunite}/commands/__tests__/push-status.test.ts (100%) rename packages/cli/src/{cms => reunite}/commands/__tests__/push.test.ts (100%) rename packages/cli/src/{cms => reunite}/commands/__tests__/utils.test.ts (100%) rename packages/cli/src/{cms => reunite}/commands/push-status.ts (100%) rename packages/cli/src/{cms => reunite}/commands/push.ts (100%) rename packages/cli/src/{cms => reunite}/commands/utils.ts (100%) rename packages/cli/src/{cms => reunite}/utils.ts (100%) diff --git a/.changeset/tough-apples-attend.md b/.changeset/tough-apples-attend.md new file mode 100644 index 0000000000..b83a2c5503 --- /dev/null +++ b/.changeset/tough-apples-attend.md @@ -0,0 +1,5 @@ +--- +"@redocly/cli": minor +--- + +Added login flow based on [OAuth 2.0 Device authorization flow](https://datatracker.ietf.org/doc/html/rfc8628) that uses Reunite API. diff --git a/jest.config.js b/jest.config.js index 3cd29448a1..1734afd868 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,7 +21,7 @@ module.exports = { statements: 64, branches: 52, functions: 63, - lines: 65, + lines: 64, }, }, testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts index a3b5839e05..5ceddbdfd4 100644 --- a/packages/cli/src/__tests__/commands/push-region.test.ts +++ b/packages/cli/src/__tests__/commands/push-region.test.ts @@ -1,6 +1,6 @@ import { getMergedConfig } from '@redocly/openapi-core'; import { handlePush } from '../../commands/push'; -import { promptClientToken } from '../../commands/login'; +import { promptClientToken } from '../../commands/auth'; import { ConfigFixture } from '../fixtures/config'; import { Readable } from 'node:stream'; @@ -23,7 +23,7 @@ jest.mock('fs', () => ({ // Mock OpenAPI core jest.mock('@redocly/openapi-core'); -jest.mock('../../commands/login'); +jest.mock('../../commands/auth'); jest.mock('../../utils/miscellaneous'); const mockPromptClientToken = promptClientToken as jest.MockedFunction; diff --git a/packages/cli/src/auth/__tests__/device-flow.test.ts b/packages/cli/src/auth/__tests__/device-flow.test.ts new file mode 100644 index 0000000000..f619b4072a --- /dev/null +++ b/packages/cli/src/auth/__tests__/device-flow.test.ts @@ -0,0 +1,73 @@ +import { RedoclyOAuthDeviceFlow } from '../device-flow'; + +jest.mock('child_process'); + +describe('RedoclyOAuthDeviceFlow', () => { + const mockBaseUrl = 'https://test.redocly.com'; + const mockClientName = 'test-client'; + const mockVersion = '1.0.0'; + let flow: RedoclyOAuthDeviceFlow; + + beforeEach(() => { + flow = new RedoclyOAuthDeviceFlow(mockBaseUrl, mockClientName, mockVersion); + jest.resetAllMocks(); + }); + + describe('verifyToken', () => { + it('returns true for valid token', async () => { + jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({ + json: () => Promise.resolve({ user: { id: '123' } }), + } as Response); + + const result = await flow.verifyToken('valid-token'); + expect(result).toBe(true); + }); + + it('returns false for invalid token', async () => { + jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid token')); + const result = await flow.verifyToken('invalid-token'); + expect(result).toBe(false); + }); + }); + + describe('verifyApiKey', () => { + it('returns true for valid API key', async () => { + jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({ + json: () => Promise.resolve({ success: true }), + } as Response); + + const result = await flow.verifyApiKey('valid-key'); + expect(result).toBe(true); + }); + + it('returns false for invalid API key', async () => { + jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid API key')); + const result = await flow.verifyApiKey('invalid-key'); + expect(result).toBe(false); + }); + }); + + describe('refreshToken', () => { + it('successfully refreshes token', async () => { + const mockResponse = { + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 3600, + }; + jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({ + json: () => Promise.resolve(mockResponse), + } as Response); + + const result = await flow.refreshToken('old-refresh-token'); + expect(result).toEqual(mockResponse); + }); + + it('throws error when refresh fails', async () => { + jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({ + json: () => Promise.resolve({}), + } as Response); + + await expect(flow.refreshToken('invalid-refresh')).rejects.toThrow('Failed to refresh token'); + }); + }); +}); diff --git a/packages/cli/src/auth/__tests__/oauth-client.test.ts b/packages/cli/src/auth/__tests__/oauth-client.test.ts new file mode 100644 index 0000000000..e6d938a527 --- /dev/null +++ b/packages/cli/src/auth/__tests__/oauth-client.test.ts @@ -0,0 +1,117 @@ +import { RedoclyOAuthClient } from '../oauth-client'; +import { RedoclyOAuthDeviceFlow } from '../device-flow'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +jest.mock('node:fs'); +jest.mock('node:os'); +jest.mock('../device-flow'); + +describe('RedoclyOAuthClient', () => { + const mockClientName = 'test-client'; + const mockVersion = '1.0.0'; + const mockBaseUrl = 'https://test.redocly.com'; + const mockHomeDir = '/mock/home/dir'; + const mockRedoclyDir = path.join(mockHomeDir, '.redocly'); + let client: RedoclyOAuthClient; + + beforeEach(() => { + jest.resetAllMocks(); + (os.homedir as jest.Mock).mockReturnValue(mockHomeDir); + process.env.HOME = mockHomeDir; + client = new RedoclyOAuthClient(mockClientName, mockVersion); + }); + + describe('login', () => { + it('successfully logs in and saves token', async () => { + const mockToken = { access_token: 'test-token' }; + const mockDeviceFlow = { + run: jest.fn().mockResolvedValue(mockToken), + }; + (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow); + + await client.login(mockBaseUrl); + + expect(mockDeviceFlow.run).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('throws error when login fails', async () => { + const mockDeviceFlow = { + run: jest.fn().mockResolvedValue(null), + }; + (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow); + + await expect(client.login(mockBaseUrl)).rejects.toThrow('Failed to login'); + }); + }); + + describe('logout', () => { + it('removes token file if it exists', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + await client.logout(); + + expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'auth.json')); + }); + + it('silently fails if token file does not exist', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + await expect(client.logout()).resolves.not.toThrow(); + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + }); + + describe('isAuthorized', () => { + it('verifies API key if provided', async () => { + const mockDeviceFlow = { + verifyApiKey: jest.fn().mockResolvedValue(true), + }; + (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow); + + const result = await client.isAuthorized(mockBaseUrl, 'test-api-key'); + + expect(result).toBe(true); + expect(mockDeviceFlow.verifyApiKey).toHaveBeenCalledWith('test-api-key'); + }); + + it('verifies access token if no API key provided', async () => { + const mockToken = { access_token: 'test-token' }; + const mockDeviceFlow = { + verifyToken: jest.fn().mockResolvedValue(true), + }; + (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow); + (fs.readFileSync as jest.Mock).mockReturnValue( + client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') + + client['cipher'].final('hex') + ); + + const result = await client.isAuthorized(mockBaseUrl); + + expect(result).toBe(true); + expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token'); + }); + + it('returns false if token refresh fails', async () => { + const mockToken = { + access_token: 'old-token', + refresh_token: 'refresh-token', + }; + const mockDeviceFlow = { + verifyToken: jest.fn().mockResolvedValue(false), + refreshToken: jest.fn().mockRejectedValue(new Error('Refresh failed')), + }; + (RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow); + (fs.readFileSync as jest.Mock).mockReturnValue( + client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') + + client['cipher'].final('hex') + ); + + const result = await client.isAuthorized(mockBaseUrl); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/auth/device-flow.ts b/packages/cli/src/auth/device-flow.ts new file mode 100644 index 0000000000..c6eb8f5e1f --- /dev/null +++ b/packages/cli/src/auth/device-flow.ts @@ -0,0 +1,175 @@ +import { blue, green } from 'colorette'; +import * as childProcess from 'child_process'; +import { ReuniteApiClient } from '../reunite/api/api-client'; + +export type AuthToken = { + access_token: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; +}; + +export class RedoclyOAuthDeviceFlow { + private apiClient: ReuniteApiClient; + + constructor(private baseUrl: string, private clientName: string, private version: string) { + this.apiClient = new ReuniteApiClient(this.version, 'login'); + } + + async run() { + const code = await this.getDeviceCode(); + process.stdout.write( + 'Attempting to automatically open the SSO authorization page in your default browser.\n' + ); + process.stdout.write( + 'If the browser does not open or you wish to use a different device to authorize this request, open the following URL:\n\n' + ); + process.stdout.write(blue(code.verificationUri)); + process.stdout.write(`\n\n`); + process.stdout.write(`Then enter the code:\n\n`); + process.stdout.write(blue(code.userCode)); + process.stdout.write(`\n\n`); + + this.openBrowser(code.verificationUriComplete); + + const accessToken = await this.pollingAccessToken( + code.deviceCode, + code.interval, + code.expiresIn + ); + process.stdout.write(green('✅ Logged in\n\n')); + + return accessToken; + } + + private openBrowser(url: string) { + try { + const cmd = + process.platform === 'win32' + ? `start ${url}` + : process.platform === 'darwin' + ? `open ${url}` + : `xdg-open ${url}`; + + childProcess.execSync(cmd); + } catch { + // silently fail if browser cannot be opened + } + } + + async verifyToken(accessToken: string) { + try { + const response = await this.sendRequest('/session', 'GET', undefined, { + Cookie: `accessToken=${accessToken};`, + }); + + return !!response.user; + } catch { + return false; + } + } + + async verifyApiKey(apiKey: string) { + try { + const response = await this.sendRequest('/api-keys-verify', 'POST', { + apiKey, + }); + return !!response.success; + } catch { + return false; + } + } + + async refreshToken(refreshToken: string) { + const response = await this.sendRequest(`/device-rotate-token`, 'POST', { + grant_type: 'refresh_token', + client_name: this.clientName, + refresh_token: refreshToken, + }); + + if (!response.access_token) { + throw new Error('Failed to refresh token'); + } + return { + access_token: response.access_token, + refresh_token: response.refresh_token, + expires_in: response.expires_in, + }; + } + + private async pollingAccessToken( + deviceCode: string, + interval: number, + expiresIn: number + ): Promise { + return new Promise((resolve, reject) => { + const intervalId = setInterval(async () => { + const response = await this.getAccessToken(deviceCode); + if (response.access_token) { + clearInterval(intervalId); + clearTimeout(timeoutId); + resolve(response); + } + if (response.error && response.error !== 'authorization_pending') { + clearInterval(intervalId); + clearTimeout(timeoutId); + reject(response.error_description); + } + }, interval * 1000); + + const timeoutId = setTimeout(async () => { + clearInterval(intervalId); + clearTimeout(timeoutId); + reject('Authorization has expired. Please try again.'); + }, expiresIn * 1000); + }); + } + + private async getAccessToken(deviceCode: string) { + return await this.sendRequest('/device-token', 'POST', { + client_name: this.clientName, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }); + } + + private async getDeviceCode() { + const { + device_code: deviceCode, + user_code: userCode, + verification_uri: verificationUri, + verification_uri_complete: verificationUriComplete, + interval = 10, + expires_in: expiresIn = 300, + } = await this.sendRequest('/device-authorize', 'POST', { + client_name: this.clientName, + }); + + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete, + interval, + expiresIn, + }; + } + + private async sendRequest( + url: string, + method: string = 'GET', + body: Record | undefined = undefined, + headers: Record = {} + ) { + url = `${this.baseUrl}${url}`; + const response = await this.apiClient.request(url, { + body: body ? JSON.stringify(body) : body, + method, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + if (response.status === 204) { + return { success: true }; + } + return await response.json(); + } +} diff --git a/packages/cli/src/auth/oauth-client.ts b/packages/cli/src/auth/oauth-client.ts new file mode 100644 index 0000000000..5947b14354 --- /dev/null +++ b/packages/cli/src/auth/oauth-client.ts @@ -0,0 +1,111 @@ +import { homedir } from 'node:os'; +import * as path from 'node:path'; +import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import * as crypto from 'node:crypto'; +import { Buffer } from 'node:buffer'; +import { type AuthToken, RedoclyOAuthDeviceFlow } from './device-flow'; + +const SALT = '4618dbc9-8aed-4e27-aaf0-225f4603e5a4'; +const CRYPTO_ALGORITHM = 'aes-256-cbc'; + +export class RedoclyOAuthClient { + private dir: string; + private cipher: crypto.Cipher; + private decipher: crypto.Decipher; + + constructor(private clientName: string, private version: string) { + this.dir = path.join(homedir(), '.redocly'); + if (!existsSync(this.dir)) { + mkdirSync(this.dir); + } + + const homeDirPath = process.env.HOME as string; + const hash = crypto.createHash('sha256'); + hash.update(`${homeDirPath}${SALT}`); + const hashHex = hash.digest('hex'); + + const key = Buffer.alloc( + 32, + Buffer.from(hashHex).toString('base64') + ).toString() as crypto.CipherKey; + const iv = Buffer.alloc( + 16, + Buffer.from(process.env.HOME as string).toString('base64') + ).toString() as crypto.BinaryLike; + this.cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, key, iv); + this.decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, key, iv); + } + + async login(baseUrl: string) { + const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version); + + const token = await deviceFlow.run(); + if (!token) { + throw new Error('Failed to login'); + } + this.saveToken(token); + } + + async logout() { + try { + this.removeToken(); + } catch (err) { + // do nothing + } + } + + async isAuthorized(baseUrl: string, apiKey?: string) { + const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version); + + if (apiKey) { + return await deviceFlow.verifyApiKey(apiKey); + } + + const token = await this.readToken(); + if (!token) { + return false; + } + + const isValidAccessToken = await deviceFlow.verifyToken(token.access_token); + + if (isValidAccessToken) { + return true; + } + + try { + const newToken = await deviceFlow.refreshToken(token.refresh_token); + await this.saveToken(newToken); + } catch { + return false; + } + + return true; + } + + private async saveToken(token: AuthToken) { + try { + const encrypted = + this.cipher.update(JSON.stringify(token), 'utf8', 'hex') + this.cipher.final('hex'); + writeFileSync(path.join(this.dir, 'auth.json'), encrypted); + } catch (error) { + process.stderr.write('Error saving tokens:', error); + } + } + + private async readToken() { + try { + const token = readFileSync(path.join(this.dir, 'auth.json'), 'utf8'); + const decrypted = this.decipher.update(token, 'hex', 'utf8') + this.decipher.final('utf8'); + return decrypted ? JSON.parse(decrypted) : null; + } catch { + return null; + } + } + + private async removeToken() { + const tokenPath = path.join(this.dir, 'auth.json'); + if (existsSync(tokenPath)) { + rmSync(tokenPath); + } + } +} diff --git a/packages/cli/src/cms/api/__tests__/domains.test.ts b/packages/cli/src/cms/api/__tests__/domains.test.ts deleted file mode 100644 index 261b1e7f7a..0000000000 --- a/packages/cli/src/cms/api/__tests__/domains.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getDomain } from '../domains'; - -describe('getDomain()', () => { - it('should return the domain from environment variable', () => { - process.env.REDOCLY_DOMAIN = 'test-domain'; - - expect(getDomain()).toBe('test-domain'); - }); - - it('should return the default domain if no domain provided', () => { - process.env.REDOCLY_DOMAIN = ''; - - expect(getDomain()).toBe('https://app.cloud.redocly.com'); - }); -}); diff --git a/packages/cli/src/cms/api/domains.ts b/packages/cli/src/cms/api/domains.ts deleted file mode 100644 index 16e0b1aaf3..0000000000 --- a/packages/cli/src/cms/api/domains.ts +++ /dev/null @@ -1,11 +0,0 @@ -const DEFAULT_DOMAIN = 'https://app.cloud.redocly.com'; - -export function getDomain(): string { - const domain = process.env.REDOCLY_DOMAIN; - - if (domain) { - return domain; - } - - return DEFAULT_DOMAIN; -} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 0000000000..056e48d8a1 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -0,0 +1,66 @@ +import { blue, green, gray, yellow } from 'colorette'; +import { RedoclyClient } from '@redocly/openapi-core'; +import { exitWithError, promptUser } from '../utils/miscellaneous'; +import { RedoclyOAuthClient } from '../auth/oauth-client'; +import { getReuniteUrl } from '../reunite/api'; + +import type { CommandArgs } from '../wrapper'; +import type { Region } from '@redocly/openapi-core'; + +export function promptClientToken(domain: string) { + return promptUser( + green( + `\n 🔑 Copy your API key from ${blue(`https://app.${domain}/profile`)} and paste it below` + ) + yellow(' (if you want to log in with Reunite, please run `redocly login --next` instead)'), + true + ); +} + +export type LoginOptions = { + verbose?: boolean; + region?: Region; + config?: string; + next?: boolean; +}; + +export async function handleLogin({ argv, config, version }: CommandArgs) { + if (argv.next) { + try { + const reuniteUrl = getReuniteUrl(argv.region); + const oauthClient = new RedoclyOAuthClient('redocly-cli', version); + await oauthClient.login(reuniteUrl); + } catch { + if (argv.region) { + const reuniteUrl = getReuniteUrl(argv.region); + exitWithError(`❌ Connection to ${reuniteUrl} failed.`); + } else { + exitWithError(`❌ Login failed. Please check your credentials and try again.`); + } + } + } else { + try { + const region = argv.region || config.region; + const client = new RedoclyClient(region); + const clientToken = await promptClientToken(client.domain); + process.stdout.write(gray('\n Logging in...\n')); + await client.login(clientToken, argv.verbose); + process.stdout.write(green(' Authorization confirmed. ✅\n\n')); + } catch (err) { + exitWithError(' ' + err?.message); + } + } +} + +export type LogoutOptions = { + config?: string; +}; + +export async function handleLogout({ version }: CommandArgs) { + const client = new RedoclyClient(); + client.logout(); + + const oauthClient = new RedoclyOAuthClient('redocly-cli', version); + oauthClient.logout(); + + process.stdout.write('Logged out from the Redocly account. ✋ \n'); +} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts deleted file mode 100644 index 10fdc3202a..0000000000 --- a/packages/cli/src/commands/login.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { blue, green, gray } from 'colorette'; -import { RedoclyClient } from '@redocly/openapi-core'; -import { exitWithError, promptUser } from '../utils/miscellaneous'; - -import type { CommandArgs } from '../wrapper'; -import type { Region } from '@redocly/openapi-core'; - -export function promptClientToken(domain: string) { - return promptUser( - green( - `\n 🔑 Copy your API key from ${blue(`https://app.${domain}/profile`)} and paste it below` - ), - true - ); -} - -export type LoginOptions = { - verbose?: boolean; - region?: Region; - config?: string; -}; - -export async function handleLogin({ argv, config }: CommandArgs) { - try { - const region = argv.region || config.region; - const client = new RedoclyClient(region); - const clientToken = await promptClientToken(client.domain); - process.stdout.write(gray('\n Logging in...\n')); - await client.login(clientToken, argv.verbose); - process.stdout.write(green(' Authorization confirmed. ✅\n\n')); - } catch (err) { - exitWithError(' ' + err?.message); - } -} diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index bfb928669e..d726294349 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -19,9 +19,9 @@ import { getFallbackApisOrExit, dumpBundle, } from '../utils/miscellaneous'; -import { promptClientToken } from './login'; -import { handlePush as handleCMSPush } from '../cms/commands/push'; -import { streamToBuffer } from '../cms/api/api-client'; +import { promptClientToken } from './auth'; +import { handlePush as handleCMSPush } from '../reunite/commands/push'; +import { streamToBuffer } from '../reunite/api/api-client'; import type { Readable } from 'node:stream'; import type { Agent } from 'node:http'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 73b1a3f223..f79c079b58 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,16 +3,15 @@ import './utils/assert-node-version'; import * as yargs from 'yargs'; import * as colors from 'colorette'; -import { RedoclyClient } from '@redocly/openapi-core'; import { outputExtensions, regionChoices } from './types'; import { previewDocs } from './commands/preview-docs'; import { handleStats } from './commands/stats'; import { handleSplit } from './commands/split'; import { handleJoin } from './commands/join'; -import { handlePushStatus } from './cms/commands/push-status'; +import { handlePushStatus } from './reunite/commands/push-status'; import { handleLint } from './commands/lint'; import { handleBundle } from './commands/bundle'; -import { handleLogin } from './commands/login'; +import { handleLogin, handleLogout } from './commands/auth'; import { handlerBuildCommand } from './commands/build-docs'; import { cacheLatestVersion, @@ -29,7 +28,7 @@ import { commonPushHandler } from './commands/push'; import type { Arguments } from 'yargs'; import type { OutputFormat, RuleSeverity } from '@redocly/openapi-core'; import type { BuildDocsArgv } from './commands/build-docs/types'; -import type { PushStatusOptions } from './cms/commands/push-status'; +import type { PushStatusOptions } from './reunite/commands/push-status'; import type { PushArguments } from './types'; import type { EjectOptions } from './commands/eject'; @@ -605,7 +604,7 @@ yargs ) .command( 'login', - 'Login to the Redocly API registry with an access token.', + 'Log in to Redocly.', async (yargs) => yargs.options({ verbose: { @@ -613,8 +612,8 @@ yargs type: 'boolean', }, region: { - description: 'Specify a region.', - alias: 'r', + description: 'Residency of the application. Defaults to US.', + alias: ['r', 'residency'], choices: regionChoices, }, config: { @@ -622,6 +621,10 @@ yargs requiresArg: true, type: 'string', }, + next: { + description: 'Use Reunite application to login.', + type: 'boolean', + }, }), (argv) => { process.env.REDOCLY_CLI_COMMAND = 'login'; @@ -632,13 +635,9 @@ yargs 'logout', 'Clear your stored credentials for the Redocly API registry.', (yargs) => yargs, - async (argv) => { + (argv) => { process.env.REDOCLY_CLI_COMMAND = 'logout'; - await commandWrapper(async () => { - const client = new RedoclyClient(); - client.logout(); - process.stdout.write('Logged out from the Redocly account. ✋\n'); - })(argv); + commandWrapper(handleLogout)(argv); } ) .command( diff --git a/packages/cli/src/cms/api/__tests__/api-keys.test.ts b/packages/cli/src/reunite/api/__tests__/api-keys.test.ts similarity index 100% rename from packages/cli/src/cms/api/__tests__/api-keys.test.ts rename to packages/cli/src/reunite/api/__tests__/api-keys.test.ts diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/reunite/api/__tests__/api.client.test.ts similarity index 100% rename from packages/cli/src/cms/api/__tests__/api.client.test.ts rename to packages/cli/src/reunite/api/__tests__/api.client.test.ts diff --git a/packages/cli/src/reunite/api/__tests__/domains.test.ts b/packages/cli/src/reunite/api/__tests__/domains.test.ts new file mode 100644 index 0000000000..d6b984fcea --- /dev/null +++ b/packages/cli/src/reunite/api/__tests__/domains.test.ts @@ -0,0 +1,37 @@ +import { getDomain } from '../domains'; +import { getReuniteUrl } from '../domains'; + +import type { Region } from '@redocly/openapi-core'; + +describe('getDomain()', () => { + it('should return the domain from environment variable', () => { + process.env.REDOCLY_DOMAIN = 'test-domain'; + + expect(getDomain()).toBe('test-domain'); + }); + + it('should return the default domain if no domain provided', () => { + process.env.REDOCLY_DOMAIN = ''; + + expect(getDomain()).toBe('https://app.cloud.redocly.com'); + }); +}); + +describe('getReuniteUrl()', () => { + it('should return US API URL when US region specified', () => { + expect(getReuniteUrl('us')).toBe('https://app.cloud.redocly.com/api'); + }); + + it('should return EU API URL when EU region specified', () => { + expect(getReuniteUrl('eu')).toBe('https://app.cloud.eu.redocly.com/api'); + }); + + it('should return custom domain API URL when custom domain specified', () => { + const customDomain = 'https://custom.domain.com'; + expect(getReuniteUrl(customDomain as Region)).toBe('https://custom.domain.com/api'); + }); + + it('should return US API URL when no region specified', () => { + expect(getReuniteUrl()).toBe('https://app.cloud.redocly.com/api'); + }); +}); diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/reunite/api/api-client.ts similarity index 99% rename from packages/cli/src/cms/api/api-client.ts rename to packages/cli/src/reunite/api/api-client.ts index 4578ae287a..b85e19efa3 100644 --- a/packages/cli/src/cms/api/api-client.ts +++ b/packages/cli/src/reunite/api/api-client.ts @@ -26,7 +26,7 @@ export class ReuniteApiError extends Error { } } -class ReuniteApiClient implements BaseApiClient { +export class ReuniteApiClient implements BaseApiClient { public sunsetWarnings: SunsetWarningsBuffer = []; constructor(protected version: string, protected command: string) {} diff --git a/packages/cli/src/cms/api/api-keys.ts b/packages/cli/src/reunite/api/api-keys.ts similarity index 100% rename from packages/cli/src/cms/api/api-keys.ts rename to packages/cli/src/reunite/api/api-keys.ts diff --git a/packages/cli/src/reunite/api/domains.ts b/packages/cli/src/reunite/api/domains.ts new file mode 100644 index 0000000000..2aa9449de0 --- /dev/null +++ b/packages/cli/src/reunite/api/domains.ts @@ -0,0 +1,23 @@ +import type { Region } from '@redocly/openapi-core'; + +export const REUNITE_URLS: Record = { + us: 'https://app.cloud.redocly.com', + eu: 'https://app.cloud.eu.redocly.com', +} as const; + +export function getDomain(): string { + return process.env.REDOCLY_DOMAIN || REUNITE_URLS.us; +} + +export function getReuniteUrl(residency?: Region) { + if (!residency) residency = 'us'; + + let reuniteUrl: string = REUNITE_URLS[residency]; + + if (!reuniteUrl) { + reuniteUrl = residency; + } + + const url = new URL('/api', reuniteUrl).toString(); + return url; +} diff --git a/packages/cli/src/cms/api/index.ts b/packages/cli/src/reunite/api/index.ts similarity index 100% rename from packages/cli/src/cms/api/index.ts rename to packages/cli/src/reunite/api/index.ts diff --git a/packages/cli/src/cms/api/types.ts b/packages/cli/src/reunite/api/types.ts similarity index 100% rename from packages/cli/src/cms/api/types.ts rename to packages/cli/src/reunite/api/types.ts diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/reunite/commands/__tests__/push-status.test.ts similarity index 100% rename from packages/cli/src/cms/commands/__tests__/push-status.test.ts rename to packages/cli/src/reunite/commands/__tests__/push-status.test.ts diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/reunite/commands/__tests__/push.test.ts similarity index 100% rename from packages/cli/src/cms/commands/__tests__/push.test.ts rename to packages/cli/src/reunite/commands/__tests__/push.test.ts diff --git a/packages/cli/src/cms/commands/__tests__/utils.test.ts b/packages/cli/src/reunite/commands/__tests__/utils.test.ts similarity index 100% rename from packages/cli/src/cms/commands/__tests__/utils.test.ts rename to packages/cli/src/reunite/commands/__tests__/utils.test.ts diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/reunite/commands/push-status.ts similarity index 100% rename from packages/cli/src/cms/commands/push-status.ts rename to packages/cli/src/reunite/commands/push-status.ts diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/reunite/commands/push.ts similarity index 100% rename from packages/cli/src/cms/commands/push.ts rename to packages/cli/src/reunite/commands/push.ts diff --git a/packages/cli/src/cms/commands/utils.ts b/packages/cli/src/reunite/commands/utils.ts similarity index 100% rename from packages/cli/src/cms/commands/utils.ts rename to packages/cli/src/reunite/commands/utils.ts diff --git a/packages/cli/src/cms/utils.ts b/packages/cli/src/reunite/utils.ts similarity index 100% rename from packages/cli/src/cms/utils.ts rename to packages/cli/src/reunite/utils.ts diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 288916f657..3ccb8d9866 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -3,14 +3,14 @@ import type { ArgumentsCamelCase } from 'yargs'; import type { LintOptions } from './commands/lint'; import type { BundleOptions } from './commands/bundle'; import type { JoinOptions } from './commands/join'; -import type { LoginOptions } from './commands/login'; +import type { LoginOptions, LogoutOptions } from './commands/auth'; import type { PushOptions } from './commands/push'; import type { StatsOptions } from './commands/stats'; import type { SplitOptions } from './commands/split'; import type { PreviewDocsOptions } from './commands/preview-docs'; import type { BuildDocsArgv } from './commands/build-docs/types'; -import type { PushOptions as CMSPushOptions } from './cms/commands/push'; -import type { PushStatusOptions } from './cms/commands/push-status'; +import type { PushOptions as CMSPushOptions } from './reunite/commands/push'; +import type { PushStatusOptions } from './reunite/commands/push-status'; import type { PreviewProjectOptions } from './commands/preview-project/types'; import type { TranslationsOptions } from './commands/translations'; import type { EjectOptions } from './commands/eject'; @@ -37,6 +37,7 @@ export type CommandOptions = | LintOptions | BundleOptions | LoginOptions + | LogoutOptions | PreviewDocsOptions | BuildDocsArgv | PushStatusOptions From 332f83131613ab77283da573901a72df875ba46c Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 4 Feb 2025 12:11:00 +0200 Subject: [PATCH 2/7] update docs --- .changeset/tough-apples-attend.md | 2 +- docs/commands/index.md | 4 ++-- docs/commands/login.md | 23 ++++++++++++++++------- packages/cli/src/commands/auth.ts | 4 ++-- packages/cli/src/index.ts | 2 +- packages/cli/src/reunite/api/domains.ts | 4 ++-- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.changeset/tough-apples-attend.md b/.changeset/tough-apples-attend.md index b83a2c5503..a89576ecd4 100644 --- a/.changeset/tough-apples-attend.md +++ b/.changeset/tough-apples-attend.md @@ -2,4 +2,4 @@ "@redocly/cli": minor --- -Added login flow based on [OAuth 2.0 Device authorization flow](https://datatracker.ietf.org/doc/html/rfc8628) that uses Reunite API. +Added [OAuth 2.0 Device authorization flow](https://datatracker.ietf.org/doc/html/rfc8628) that enables users to authenticate through Reunite API. diff --git a/docs/commands/index.md b/docs/commands/index.md index ce2734c58a..8b927654cf 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -26,8 +26,8 @@ Linting commands: Redocly platform commands: -- [`login`](login.md) Login to the Redocly API registry with an access token. -- [`logout`](logout.md) Clear your stored credentials for the Redocly API registry. +- [`login`](login.md) Login to the Redocly API registry with an access token or to the Reunite API. +- [`logout`](logout.md) Clear your stored credentials. - [`push`](push.md) Push an API description to the Redocly API registry. - [`push-status`](push-status.md) Track an in-progress push operation to Reunite. diff --git a/docs/commands/login.md b/docs/commands/login.md index 107e739d30..f101d10578 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -22,15 +22,24 @@ redocly login [--help] [--verbose] [--version] redocly login --verbose ``` +To authenticate using **Reunite** API, use `--next` option. + +```bash +redocly login --next +``` + +Please note that login with **Reunite** API does not allow you to use the `push` command. + ## Options -| Option | Type | Description | -| ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| --config | string | Specify path to the [configuration file](../configuration/index.md). | -| --help | boolean | Show help. | -| --region, -r | string | Specify which region to use when logging in. Supported values: `us`, `eu`. The `eu` region is limited to enterprise customers. Default value is `us`. | -| --verbose | boolean | Display additional output. | -| --version | boolean | Show version number. | +| Option | Type | Description | +| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --help | boolean | Show help. | +| --region, --residency -r | string | Specify residency of the application. Supported values: `us`, `eu`. The `eu` region is limited to enterprise customers. Default value is `us`. | +| --verbose | boolean | Display additional output. | +| --version | boolean | Show version number. | +| --next | boolean | Authenticate through Reunite API. | ## Examples diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 056e48d8a1..6e40a16cce 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -18,7 +18,7 @@ export function promptClientToken(domain: string) { export type LoginOptions = { verbose?: boolean; - region?: Region; + region?: string; config?: string; next?: boolean; }; @@ -40,7 +40,7 @@ export async function handleLogin({ argv, config, version }: CommandArgs Date: Tue, 4 Feb 2025 16:48:03 +0100 Subject: [PATCH 3/7] Apply suggestions from code review --- docs/commands/login.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/commands/login.md b/docs/commands/login.md index f101d10578..d8a331b226 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -22,13 +22,13 @@ redocly login [--help] [--verbose] [--version] redocly login --verbose ``` -To authenticate using **Reunite** API, use `--next` option. +To authenticate using **Reunite** API, use the `--next` option. ```bash redocly login --next ``` -Please note that login with **Reunite** API does not allow you to use the `push` command. +Note that logging in with **Reunite** API does not allow you to use the `push` command. ## Options @@ -36,7 +36,7 @@ Please note that login with **Reunite** API does not allow you to use the `push` | ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | --config | string | Specify path to the [configuration file](../configuration/index.md). | | --help | boolean | Show help. | -| --region, --residency -r | string | Specify residency of the application. Supported values: `us`, `eu`. The `eu` region is limited to enterprise customers. Default value is `us`. | +| --region, --residency -r | string | Specify the residency of the application. Supported values: `us`, `eu`. The `eu` region is limited to enterprise customers. Default value is `us`. | | --verbose | boolean | Display additional output. | | --version | boolean | Show version number. | | --next | boolean | Authenticate through Reunite API. | From 2168529517dbaa5c45dc1bf0025525008af8c746 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 4 Feb 2025 18:16:05 +0200 Subject: [PATCH 4/7] udpate docs --- docs/commands/login.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/commands/login.md b/docs/commands/login.md index d8a331b226..16e4e5f2ff 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -32,14 +32,14 @@ Note that logging in with **Reunite** API does not allow you to use the `push` c ## Options -| Option | Type | Description | -| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| --config | string | Specify path to the [configuration file](../configuration/index.md). | -| --help | boolean | Show help. | +| Option | Type | Description | +| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --help | boolean | Show help. | | --region, --residency -r | string | Specify the residency of the application. Supported values: `us`, `eu`. The `eu` region is limited to enterprise customers. Default value is `us`. | -| --verbose | boolean | Display additional output. | -| --version | boolean | Show version number. | -| --next | boolean | Authenticate through Reunite API. | +| --verbose | boolean | Display additional output. | +| --version | boolean | Show version number. | +| --next | boolean | Authenticate through Reunite API. | ## Examples From dbea0da91df2743ede7c475ed0a8493127e78342 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Wed, 5 Feb 2025 09:36:40 +0200 Subject: [PATCH 5/7] rename region into residency --- docs/commands/login.md | 16 ++++++++-------- packages/cli/src/commands/auth.ts | 12 ++++++------ packages/cli/src/index.ts | 6 +++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/commands/login.md b/docs/commands/login.md index 16e4e5f2ff..e40b2c12c6 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -32,14 +32,14 @@ Note that logging in with **Reunite** API does not allow you to use the `push` c ## Options -| Option | Type | Description | -| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| --config | string | Specify path to the [configuration file](../configuration/index.md). | -| --help | boolean | Show help. | -| --region, --residency -r | string | Specify the residency of the application. Supported values: `us`, `eu`. The `eu` region is limited to enterprise customers. Default value is `us`. | -| --verbose | boolean | Display additional output. | -| --version | boolean | Show version number. | -| --next | boolean | Authenticate through Reunite API. | +| Option | Type | Description | +| ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --help | boolean | Show help. | +| --residency, --region, -r | string | Specify the application's residency. Supported values: `us`, `eu`, or a full URL. The `eu` region is limited to enterprise customers. Default value is `us`. | +| --verbose | boolean | Display additional output. | +| --version | boolean | Show version number. | +| --next | boolean | Authenticate through Reunite API. | ## Examples diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 6e40a16cce..29d1a779a5 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -18,7 +18,7 @@ export function promptClientToken(domain: string) { export type LoginOptions = { verbose?: boolean; - region?: string; + residency?: string; config?: string; next?: boolean; }; @@ -26,12 +26,12 @@ export type LoginOptions = { export async function handleLogin({ argv, config, version }: CommandArgs) { if (argv.next) { try { - const reuniteUrl = getReuniteUrl(argv.region); + const reuniteUrl = getReuniteUrl(argv.residency); const oauthClient = new RedoclyOAuthClient('redocly-cli', version); await oauthClient.login(reuniteUrl); } catch { - if (argv.region) { - const reuniteUrl = getReuniteUrl(argv.region); + if (argv.residency) { + const reuniteUrl = getReuniteUrl(argv.residency); exitWithError(`❌ Connection to ${reuniteUrl} failed.`); } else { exitWithError(`❌ Login failed. Please check your credentials and try again.`); @@ -39,8 +39,8 @@ export async function handleLogin({ argv, config, version }: CommandArgs Date: Wed, 5 Feb 2025 13:19:34 +0200 Subject: [PATCH 6/7] cleanup domain after tests --- packages/cli/src/reunite/api/__tests__/domains.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/reunite/api/__tests__/domains.test.ts b/packages/cli/src/reunite/api/__tests__/domains.test.ts index d6b984fcea..3882dc66fa 100644 --- a/packages/cli/src/reunite/api/__tests__/domains.test.ts +++ b/packages/cli/src/reunite/api/__tests__/domains.test.ts @@ -4,6 +4,10 @@ import { getReuniteUrl } from '../domains'; import type { Region } from '@redocly/openapi-core'; describe('getDomain()', () => { + afterEach(() => { + delete process.env.REDOCLY_DOMAIN; + }); + it('should return the domain from environment variable', () => { process.env.REDOCLY_DOMAIN = 'test-domain'; From d39db196626323a81acb4f59b2f75ab9bf6dc6a8 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 11 Feb 2025 12:25:02 +0200 Subject: [PATCH 7/7] update auth status check --- packages/cli/src/utils/miscellaneous.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts index a551a55cad..3bba1bf9dd 100644 --- a/packages/cli/src/utils/miscellaneous.ts +++ b/packages/cli/src/utils/miscellaneous.ts @@ -29,6 +29,8 @@ import { outputExtensions } from '../types'; import { version } from './update-version-notifier'; import { DESTINATION_REGEX } from '../commands/push'; import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout'; +import { RedoclyOAuthClient } from '../auth/oauth-client'; +import { getReuniteUrl } from '../reunite/api'; import type { Arguments } from 'yargs'; import type { @@ -565,7 +567,9 @@ export async function sendTelemetry( } = argv; const event_time = new Date().toISOString(); const redoclyClient = new RedoclyClient(); - const logged_in = redoclyClient.hasTokens(); + const oauthClient = new RedoclyOAuthClient('redocly-cli', version); + const reuniteUrl = getReuniteUrl(argv.residency as string | undefined); + const logged_in = redoclyClient.hasTokens() || (await oauthClient.isAuthorized(reuniteUrl)); const data: Analytics = { event: 'cli_command', event_time,