Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add login via Reunite API #1878

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-apples-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@redocly/cli": minor
---

Added [OAuth 2.0 Device authorization flow](https://datatracker.ietf.org/doc/html/rfc8628) that enables users to authenticate through Reunite API.
4 changes: 2 additions & 2 deletions docs/commands/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 16 additions & 7 deletions docs/commands/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,24 @@ redocly login [--help] [--verbose] [--version]
redocly login --verbose
```

To authenticate using **Reunite** API, use the `--next` option.

```bash
redocly login --next
```

Note that logging in 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. |
| --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

Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ module.exports = {
statements: 64,
branches: 52,
functions: 63,
lines: 65,
lines: 64,
},
},
testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/__tests__/commands/push-region.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<typeof promptClientToken>;
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/auth/__tests__/device-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
117 changes: 117 additions & 0 deletions packages/cli/src/auth/__tests__/oauth-client.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading