@redocly/cli 1.29.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +36 -16
- package/lib/__tests__/commands/push-region.test.js +3 -3
- package/lib/auth/__tests__/device-flow.test.js +62 -0
- package/lib/auth/__tests__/oauth-client.test.js +93 -0
- package/lib/auth/device-flow.d.ts +26 -0
- package/lib/auth/device-flow.js +133 -0
- package/lib/auth/oauth-client.d.ts +14 -0
- package/lib/auth/oauth-client.js +93 -0
- package/lib/commands/auth.d.ts +13 -0
- package/lib/commands/auth.js +51 -0
- package/lib/commands/push.d.ts +1 -1
- package/lib/commands/push.js +4 -4
- package/lib/index.js +103 -15
- package/lib/otel.d.ts +10 -0
- package/lib/otel.js +47 -0
- package/lib/reunite/api/__tests__/domains.test.js +32 -0
- package/lib/{cms → reunite}/api/api-client.d.ts +9 -0
- package/lib/{cms → reunite}/api/api-client.js +2 -1
- package/lib/reunite/api/domains.d.ts +4 -0
- package/lib/reunite/api/domains.js +22 -0
- package/lib/reunite/commands/__tests__/push.test.d.ts +1 -0
- package/lib/reunite/commands/__tests__/utils.test.d.ts +1 -0
- package/lib/types.d.ts +5 -4
- package/lib/utils/miscellaneous.d.ts +5 -4
- package/lib/utils/miscellaneous.js +14 -14
- package/package.json +11 -4
- package/src/__tests__/commands/push-region.test.ts +2 -2
- package/src/auth/__tests__/device-flow.test.ts +73 -0
- package/src/auth/__tests__/oauth-client.test.ts +117 -0
- package/src/auth/device-flow.ts +175 -0
- package/src/auth/oauth-client.ts +111 -0
- package/src/commands/auth.ts +66 -0
- package/src/commands/push.ts +3 -3
- package/src/index.ts +115 -16
- package/src/otel.ts +59 -0
- package/src/reunite/api/__tests__/domains.test.ts +41 -0
- package/src/{cms → reunite}/api/api-client.ts +1 -1
- package/src/reunite/api/domains.ts +23 -0
- package/src/types.ts +8 -4
- package/src/utils/miscellaneous.ts +19 -18
- package/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/lib/cms/api/__tests__/domains.test.js +0 -13
- package/lib/cms/api/domains.d.ts +0 -1
- package/lib/cms/api/domains.js +0 -11
- package/lib/commands/login.d.ts +0 -9
- package/lib/commands/login.js +0 -23
- package/src/cms/api/__tests__/domains.test.ts +0 -15
- package/src/cms/api/domains.ts +0 -11
- package/src/commands/login.ts +0 -34
- /package/lib/{cms/api/__tests__/api-keys.test.d.ts → auth/__tests__/device-flow.test.d.ts} +0 -0
- /package/lib/{cms/api/__tests__/api.client.test.d.ts → auth/__tests__/oauth-client.test.d.ts} +0 -0
- /package/lib/{cms/api/__tests__/domains.test.d.ts → reunite/api/__tests__/api-keys.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/api/__tests__/api-keys.test.js +0 -0
- /package/lib/{cms/commands/__tests__/push-status.test.d.ts → reunite/api/__tests__/api.client.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/api/__tests__/api.client.test.js +0 -0
- /package/lib/{cms/commands/__tests__/push.test.d.ts → reunite/api/__tests__/domains.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/api/api-keys.d.ts +0 -0
- /package/lib/{cms → reunite}/api/api-keys.js +0 -0
- /package/lib/{cms → reunite}/api/index.d.ts +0 -0
- /package/lib/{cms → reunite}/api/index.js +0 -0
- /package/lib/{cms → reunite}/api/types.d.ts +0 -0
- /package/lib/{cms → reunite}/api/types.js +0 -0
- /package/lib/{cms/commands/__tests__/utils.test.d.ts → reunite/commands/__tests__/push-status.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/commands/__tests__/push-status.test.js +0 -0
- /package/lib/{cms → reunite}/commands/__tests__/push.test.js +0 -0
- /package/lib/{cms → reunite}/commands/__tests__/utils.test.js +0 -0
- /package/lib/{cms → reunite}/commands/push-status.d.ts +0 -0
- /package/lib/{cms → reunite}/commands/push-status.js +0 -0
- /package/lib/{cms → reunite}/commands/push.d.ts +0 -0
- /package/lib/{cms → reunite}/commands/push.js +0 -0
- /package/lib/{cms → reunite}/commands/utils.d.ts +0 -0
- /package/lib/{cms → reunite}/commands/utils.js +0 -0
- /package/lib/{cms → reunite}/utils.d.ts +0 -0
- /package/lib/{cms → reunite}/utils.js +0 -0
- /package/src/{cms → reunite}/api/__tests__/api-keys.test.ts +0 -0
- /package/src/{cms → reunite}/api/__tests__/api.client.test.ts +0 -0
- /package/src/{cms → reunite}/api/api-keys.ts +0 -0
- /package/src/{cms → reunite}/api/index.ts +0 -0
- /package/src/{cms → reunite}/api/types.ts +0 -0
- /package/src/{cms → reunite}/commands/__tests__/push-status.test.ts +0 -0
- /package/src/{cms → reunite}/commands/__tests__/push.test.ts +0 -0
- /package/src/{cms → reunite}/commands/__tests__/utils.test.ts +0 -0
- /package/src/{cms → reunite}/commands/push-status.ts +0 -0
- /package/src/{cms → reunite}/commands/push.ts +0 -0
- /package/src/{cms → reunite}/commands/utils.ts +0 -0
- /package/src/{cms → reunite}/utils.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @redocly/cli
|
|
2
2
|
|
|
3
|
+
## 1.31.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Added the `generate-arazzo` command to scaffold Arazzo description templates out of OpenAPI descriptions.
|
|
8
|
+
- Added the `respect` command to test APIs against Arazzo description files.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- Updated @redocly/openapi-core to v1.31.0.
|
|
13
|
+
|
|
14
|
+
## 1.30.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- Added [OAuth 2.0 Device authorization flow](https://datatracker.ietf.org/doc/html/rfc8628) that enables users to authenticate through Reunite API.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- Updated `operation-tag-defined` built-in rule to verify tags are defined on the operation prior to matching them to a global tag.
|
|
23
|
+
- Updated @redocly/openapi-core to v1.30.0.
|
|
24
|
+
|
|
3
25
|
## 1.29.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
|
@@ -226,7 +248,7 @@
|
|
|
226
248
|
|
|
227
249
|
### Minor Changes
|
|
228
250
|
|
|
229
|
-
- Added
|
|
251
|
+
- Added Respect and Arazzo rules: `no-criteria-xpath`, `no-actions-type-end`, `criteria-unique`.
|
|
230
252
|
|
|
231
253
|
### Patch Changes
|
|
232
254
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Redocly CLI
|
|
2
2
|
|
|
3
|
-
[@Redocly](https://redocly.com) CLI is your all-in-one OpenAPI utility.
|
|
3
|
+
[@Redocly](https://redocly.com) CLI is your all-in-one OpenAPI utility.
|
|
4
|
+
It builds, manages, improves, and quality-checks your OpenAPI descriptions, all of which comes in handy for various phases of the API Lifecycle.
|
|
5
|
+
Create your own rulesets to make API governance easy, publish beautiful API reference documentation, and more.
|
|
6
|
+
Supports OpenAPI 3.1, 3.0 and OpenAPI 2.0 (legacy Swagger), AsyncAPI 3.0 and 2.6, Arazzo 1.0.
|
|
4
7
|
|
|
5
8
|

|
|
6
9
|

|
|
@@ -32,9 +35,8 @@ The minimum required versions of Node.js and NPM are 18.17.0 and 10.8.2 respecti
|
|
|
32
35
|
|
|
33
36
|
### Docker
|
|
34
37
|
|
|
35
|
-
To give the Docker container access to the OpenAPI description files, you need to
|
|
36
|
-
|
|
37
|
-
in the current working directory, you need the following command:
|
|
38
|
+
To give the Docker container access to the OpenAPI description files, you need to mount the containing directory as a volume.
|
|
39
|
+
Assuming the API description is rooted in the current working directory, you need the following command:
|
|
38
40
|
|
|
39
41
|
```sh
|
|
40
42
|
docker run --rm -v $PWD:/spec redocly/cli lint path-to-root-file.yaml
|
|
@@ -51,25 +53,36 @@ docker run --rm -v $PWD:/spec redocly/cli lint path-to-root-file.yaml
|
|
|
51
53
|
|
|
52
54
|
### Generate API reference documentation
|
|
53
55
|
|
|
54
|
-
Redocly CLI is a great way to render API reference documentation.
|
|
56
|
+
Redocly CLI is a great way to render API reference documentation.
|
|
57
|
+
It uses open source [Redoc](https://github.com/redocly/redoc) to build your documentation.
|
|
58
|
+
Use a command like this:
|
|
55
59
|
|
|
56
60
|
```sh
|
|
57
61
|
redocly build-docs openapi.yaml
|
|
58
62
|
```
|
|
59
63
|
|
|
60
|
-
Your API reference docs are in `redoc-static.html` by default.
|
|
64
|
+
Your API reference docs are in `redoc-static.html` by default.
|
|
65
|
+
You can customize this in many ways.
|
|
66
|
+
[Read the main docs](https://redocly.com/docs/cli/commands/build-docs) for more information.
|
|
61
67
|
|
|
62
|
-
> :bulb: Redocly also has [hosted API reference docs](https://redocly.com/docs/api-registry/guides/api-registry-quickstart/), a (commercial) alternative to Redoc.
|
|
68
|
+
> :bulb: Redocly also has [hosted API reference docs](https://redocly.com/docs/api-registry/guides/api-registry-quickstart/), a (commercial) alternative to Redoc.
|
|
69
|
+
> Both Redoc and Redocly API reference docs can be worked on locally using the `preview-docs` command.
|
|
63
70
|
|
|
64
71
|
### Bundle multiple OpenAPI documents
|
|
65
72
|
|
|
66
|
-
Having one massive OpenAPI description can be annoying, so most people split them up into multiple documents via `$ref`, only to later find out some tools don't support `$ref` or don't support multiple documents.
|
|
73
|
+
Having one massive OpenAPI description can be annoying, so most people split them up into multiple documents via `$ref`, only to later find out some tools don't support `$ref` or don't support multiple documents.
|
|
74
|
+
Redocly CLI to the rescue! It has a `bundle` command you can use to recombine all of those documents back into one single document.
|
|
75
|
+
The bundled output that Redocly CLI provides is clean, tidy, and looks like a human made it.
|
|
67
76
|
|
|
68
77
|
### Automate API guidelines with Linting
|
|
69
78
|
|
|
70
|
-
Check that your API matches the expected API guidelines by using the `lint` command.
|
|
79
|
+
Check that your API matches the expected API guidelines by using the `lint` command.
|
|
80
|
+
API guidelines are an important piece of API governance. They help to keep APIs consistent by enforcing the same standards and naming conventions, and they can also guide API teams through potential security hazards and other pitfalls.
|
|
81
|
+
Automating API guidelines means you can keep APIs consistent and secure throughout their lifecycle.
|
|
82
|
+
Even better, you can shape the design of the API before it even exists by combining API linting with a design-first API workflow.
|
|
71
83
|
|
|
72
|
-
Our API linter is designed for speed on even large documents, and it's easy to run locally, in CI, or anywhere you need it.
|
|
84
|
+
Our API linter is designed for speed on even large documents, and it's easy to run locally, in CI, or anywhere you need it.
|
|
85
|
+
It's also designed for humans, with meaningful error messages to help you get your API right every time.
|
|
73
86
|
|
|
74
87
|
Try it like this:
|
|
75
88
|
|
|
@@ -77,9 +90,12 @@ Try it like this:
|
|
|
77
90
|
redocly lint openapi.yaml
|
|
78
91
|
```
|
|
79
92
|
|
|
80
|
-
**Configure the rules** as you wish.
|
|
93
|
+
**Configure the rules** as you wish.
|
|
94
|
+
Other API Linters use complicated identifiers like JSONPath, but Redocly makes life easy with simple expressions that understand the OpenAPI structure.
|
|
95
|
+
You can either use the [built-in rules](https://redocly.com/docs/cli/rules) to mix-and-match your ideal API guidelines, or break out the tools to build your own.
|
|
81
96
|
|
|
82
|
-
**Format the output** in whatever way you need.
|
|
97
|
+
**Format the output** in whatever way you need.
|
|
98
|
+
The `stylish` output is as good as it sounds, but if you need JSON or Checkstyle outputs to integrate with other tools, the `lint` command can output those too.
|
|
83
99
|
|
|
84
100
|
**Multiple files supported** so you don't need to bundle your API description to lint it; just point Redocly CLI at the "entry point" (e.g.: `openapi.yaml`) and it handles the rest.
|
|
85
101
|
|
|
@@ -87,7 +103,8 @@ redocly lint openapi.yaml
|
|
|
87
103
|
|
|
88
104
|
### Transform an OpenAPI description
|
|
89
105
|
|
|
90
|
-
If your OpenAPI description isn't everything you hoped it would be, enhance it with the Redocly [decorators](https://redocly.com/docs/cli/decorators) feature.
|
|
106
|
+
If your OpenAPI description isn't everything you hoped it would be, enhance it with the Redocly [decorators](https://redocly.com/docs/cli/decorators) feature.
|
|
107
|
+
This allows you to:
|
|
91
108
|
|
|
92
109
|
- Publish reference docs with a subset of endpoints for public use
|
|
93
110
|
- Improve the docs by adding examples and descriptions
|
|
@@ -95,11 +112,13 @@ If your OpenAPI description isn't everything you hoped it would be, enhance it w
|
|
|
95
112
|
|
|
96
113
|
## Data collection
|
|
97
114
|
|
|
98
|
-
This tool [collects data](./docs/usage-data.md) to help Redocly improve our products and services.
|
|
115
|
+
This tool [collects data](./docs/usage-data.md) to help Redocly improve our products and services.
|
|
116
|
+
You can opt out by setting the `REDOCLY_TELEMETRY` environment variable to `off`.
|
|
99
117
|
|
|
100
118
|
## Update notifications
|
|
101
119
|
|
|
102
|
-
Redocly CLI checks for updates on startup.
|
|
120
|
+
Redocly CLI checks for updates on startup.
|
|
121
|
+
You can disable this by setting the `REDOCLY_SUPPRESS_UPDATE_NOTICE` environment variable to `true`.
|
|
103
122
|
|
|
104
123
|
## More resources
|
|
105
124
|
|
|
@@ -111,4 +130,5 @@ Thanks to [graphql-js](https://github.com/graphql/graphql-js) and [eslint](https
|
|
|
111
130
|
|
|
112
131
|
## Development
|
|
113
132
|
|
|
114
|
-
Contributions are welcome!
|
|
133
|
+
Contributions are welcome!
|
|
134
|
+
All the information you need is in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const openapi_core_1 = require("@redocly/openapi-core");
|
|
4
4
|
const push_1 = require("../../commands/push");
|
|
5
|
-
const
|
|
5
|
+
const auth_1 = require("../../commands/auth");
|
|
6
6
|
const config_1 = require("../fixtures/config");
|
|
7
7
|
const node_stream_1 = require("node:stream");
|
|
8
8
|
// Mock fs operations
|
|
@@ -22,9 +22,9 @@ jest.mock('fs', () => ({
|
|
|
22
22
|
openapi_core_1.getMergedConfig.mockImplementation((config) => config);
|
|
23
23
|
// Mock OpenAPI core
|
|
24
24
|
jest.mock('@redocly/openapi-core');
|
|
25
|
-
jest.mock('../../commands/
|
|
25
|
+
jest.mock('../../commands/auth');
|
|
26
26
|
jest.mock('../../utils/miscellaneous');
|
|
27
|
-
const mockPromptClientToken =
|
|
27
|
+
const mockPromptClientToken = auth_1.promptClientToken;
|
|
28
28
|
describe('push-with-region', () => {
|
|
29
29
|
const redoclyClient = require('@redocly/openapi-core').__redoclyClient;
|
|
30
30
|
redoclyClient.isAuthorizedWithRedoclyByRegion = jest.fn().mockResolvedValue(false);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const device_flow_1 = require("../device-flow");
|
|
4
|
+
jest.mock('child_process');
|
|
5
|
+
describe('RedoclyOAuthDeviceFlow', () => {
|
|
6
|
+
const mockBaseUrl = 'https://test.redocly.com';
|
|
7
|
+
const mockClientName = 'test-client';
|
|
8
|
+
const mockVersion = '1.0.0';
|
|
9
|
+
let flow;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
flow = new device_flow_1.RedoclyOAuthDeviceFlow(mockBaseUrl, mockClientName, mockVersion);
|
|
12
|
+
jest.resetAllMocks();
|
|
13
|
+
});
|
|
14
|
+
describe('verifyToken', () => {
|
|
15
|
+
it('returns true for valid token', async () => {
|
|
16
|
+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
|
|
17
|
+
json: () => Promise.resolve({ user: { id: '123' } }),
|
|
18
|
+
});
|
|
19
|
+
const result = await flow.verifyToken('valid-token');
|
|
20
|
+
expect(result).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it('returns false for invalid token', async () => {
|
|
23
|
+
jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid token'));
|
|
24
|
+
const result = await flow.verifyToken('invalid-token');
|
|
25
|
+
expect(result).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('verifyApiKey', () => {
|
|
29
|
+
it('returns true for valid API key', async () => {
|
|
30
|
+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
|
|
31
|
+
json: () => Promise.resolve({ success: true }),
|
|
32
|
+
});
|
|
33
|
+
const result = await flow.verifyApiKey('valid-key');
|
|
34
|
+
expect(result).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
it('returns false for invalid API key', async () => {
|
|
37
|
+
jest.spyOn(flow['apiClient'], 'request').mockRejectedValue(new Error('Invalid API key'));
|
|
38
|
+
const result = await flow.verifyApiKey('invalid-key');
|
|
39
|
+
expect(result).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('refreshToken', () => {
|
|
43
|
+
it('successfully refreshes token', async () => {
|
|
44
|
+
const mockResponse = {
|
|
45
|
+
access_token: 'new-token',
|
|
46
|
+
refresh_token: 'new-refresh',
|
|
47
|
+
expires_in: 3600,
|
|
48
|
+
};
|
|
49
|
+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
|
|
50
|
+
json: () => Promise.resolve(mockResponse),
|
|
51
|
+
});
|
|
52
|
+
const result = await flow.refreshToken('old-refresh-token');
|
|
53
|
+
expect(result).toEqual(mockResponse);
|
|
54
|
+
});
|
|
55
|
+
it('throws error when refresh fails', async () => {
|
|
56
|
+
jest.spyOn(flow['apiClient'], 'request').mockResolvedValue({
|
|
57
|
+
json: () => Promise.resolve({}),
|
|
58
|
+
});
|
|
59
|
+
await expect(flow.refreshToken('invalid-refresh')).rejects.toThrow('Failed to refresh token');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const oauth_client_1 = require("../oauth-client");
|
|
4
|
+
const device_flow_1 = require("../device-flow");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
jest.mock('node:fs');
|
|
9
|
+
jest.mock('node:os');
|
|
10
|
+
jest.mock('../device-flow');
|
|
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;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.resetAllMocks();
|
|
20
|
+
os.homedir.mockReturnValue(mockHomeDir);
|
|
21
|
+
process.env.HOME = mockHomeDir;
|
|
22
|
+
client = new oauth_client_1.RedoclyOAuthClient(mockClientName, mockVersion);
|
|
23
|
+
});
|
|
24
|
+
describe('login', () => {
|
|
25
|
+
it('successfully logs in and saves token', async () => {
|
|
26
|
+
const mockToken = { access_token: 'test-token' };
|
|
27
|
+
const mockDeviceFlow = {
|
|
28
|
+
run: jest.fn().mockResolvedValue(mockToken),
|
|
29
|
+
};
|
|
30
|
+
device_flow_1.RedoclyOAuthDeviceFlow.mockImplementation(() => mockDeviceFlow);
|
|
31
|
+
await client.login(mockBaseUrl);
|
|
32
|
+
expect(mockDeviceFlow.run).toHaveBeenCalled();
|
|
33
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
it('throws error when login fails', async () => {
|
|
36
|
+
const mockDeviceFlow = {
|
|
37
|
+
run: jest.fn().mockResolvedValue(null),
|
|
38
|
+
};
|
|
39
|
+
device_flow_1.RedoclyOAuthDeviceFlow.mockImplementation(() => mockDeviceFlow);
|
|
40
|
+
await expect(client.login(mockBaseUrl)).rejects.toThrow('Failed to login');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('logout', () => {
|
|
44
|
+
it('removes token file if it exists', async () => {
|
|
45
|
+
fs.existsSync.mockReturnValue(true);
|
|
46
|
+
await client.logout();
|
|
47
|
+
expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'auth.json'));
|
|
48
|
+
});
|
|
49
|
+
it('silently fails if token file does not exist', async () => {
|
|
50
|
+
fs.existsSync.mockReturnValue(false);
|
|
51
|
+
await expect(client.logout()).resolves.not.toThrow();
|
|
52
|
+
expect(fs.rmSync).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('isAuthorized', () => {
|
|
56
|
+
it('verifies API key if provided', async () => {
|
|
57
|
+
const mockDeviceFlow = {
|
|
58
|
+
verifyApiKey: jest.fn().mockResolvedValue(true),
|
|
59
|
+
};
|
|
60
|
+
device_flow_1.RedoclyOAuthDeviceFlow.mockImplementation(() => mockDeviceFlow);
|
|
61
|
+
const result = await client.isAuthorized(mockBaseUrl, 'test-api-key');
|
|
62
|
+
expect(result).toBe(true);
|
|
63
|
+
expect(mockDeviceFlow.verifyApiKey).toHaveBeenCalledWith('test-api-key');
|
|
64
|
+
});
|
|
65
|
+
it('verifies access token if no API key provided', async () => {
|
|
66
|
+
const mockToken = { access_token: 'test-token' };
|
|
67
|
+
const mockDeviceFlow = {
|
|
68
|
+
verifyToken: jest.fn().mockResolvedValue(true),
|
|
69
|
+
};
|
|
70
|
+
device_flow_1.RedoclyOAuthDeviceFlow.mockImplementation(() => mockDeviceFlow);
|
|
71
|
+
fs.readFileSync.mockReturnValue(client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
|
|
72
|
+
client['cipher'].final('hex'));
|
|
73
|
+
const result = await client.isAuthorized(mockBaseUrl);
|
|
74
|
+
expect(result).toBe(true);
|
|
75
|
+
expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token');
|
|
76
|
+
});
|
|
77
|
+
it('returns false if token refresh fails', async () => {
|
|
78
|
+
const mockToken = {
|
|
79
|
+
access_token: 'old-token',
|
|
80
|
+
refresh_token: 'refresh-token',
|
|
81
|
+
};
|
|
82
|
+
const mockDeviceFlow = {
|
|
83
|
+
verifyToken: jest.fn().mockResolvedValue(false),
|
|
84
|
+
refreshToken: jest.fn().mockRejectedValue(new Error('Refresh failed')),
|
|
85
|
+
};
|
|
86
|
+
device_flow_1.RedoclyOAuthDeviceFlow.mockImplementation(() => mockDeviceFlow);
|
|
87
|
+
fs.readFileSync.mockReturnValue(client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
|
|
88
|
+
client['cipher'].final('hex'));
|
|
89
|
+
const result = await client.isAuthorized(mockBaseUrl);
|
|
90
|
+
expect(result).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type AuthToken = {
|
|
2
|
+
access_token: string;
|
|
3
|
+
refresh_token?: string;
|
|
4
|
+
token_type?: string;
|
|
5
|
+
expires_in?: number;
|
|
6
|
+
};
|
|
7
|
+
export declare class RedoclyOAuthDeviceFlow {
|
|
8
|
+
private baseUrl;
|
|
9
|
+
private clientName;
|
|
10
|
+
private version;
|
|
11
|
+
private apiClient;
|
|
12
|
+
constructor(baseUrl: string, clientName: string, version: string);
|
|
13
|
+
run(): Promise<AuthToken>;
|
|
14
|
+
private openBrowser;
|
|
15
|
+
verifyToken(accessToken: string): Promise<boolean>;
|
|
16
|
+
verifyApiKey(apiKey: string): Promise<boolean>;
|
|
17
|
+
refreshToken(refreshToken: string): Promise<{
|
|
18
|
+
access_token: any;
|
|
19
|
+
refresh_token: any;
|
|
20
|
+
expires_in: any;
|
|
21
|
+
}>;
|
|
22
|
+
private pollingAccessToken;
|
|
23
|
+
private getAccessToken;
|
|
24
|
+
private getDeviceCode;
|
|
25
|
+
private sendRequest;
|
|
26
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RedoclyOAuthDeviceFlow = void 0;
|
|
4
|
+
const colorette_1 = require("colorette");
|
|
5
|
+
const childProcess = require("child_process");
|
|
6
|
+
const api_client_1 = require("../reunite/api/api-client");
|
|
7
|
+
class RedoclyOAuthDeviceFlow {
|
|
8
|
+
constructor(baseUrl, clientName, version) {
|
|
9
|
+
this.baseUrl = baseUrl;
|
|
10
|
+
this.clientName = clientName;
|
|
11
|
+
this.version = version;
|
|
12
|
+
this.apiClient = new api_client_1.ReuniteApiClient(this.version, 'login');
|
|
13
|
+
}
|
|
14
|
+
async run() {
|
|
15
|
+
const code = await this.getDeviceCode();
|
|
16
|
+
process.stdout.write('Attempting to automatically open the SSO authorization page in your default browser.\n');
|
|
17
|
+
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');
|
|
18
|
+
process.stdout.write((0, colorette_1.blue)(code.verificationUri));
|
|
19
|
+
process.stdout.write(`\n\n`);
|
|
20
|
+
process.stdout.write(`Then enter the code:\n\n`);
|
|
21
|
+
process.stdout.write((0, colorette_1.blue)(code.userCode));
|
|
22
|
+
process.stdout.write(`\n\n`);
|
|
23
|
+
this.openBrowser(code.verificationUriComplete);
|
|
24
|
+
const accessToken = await this.pollingAccessToken(code.deviceCode, code.interval, code.expiresIn);
|
|
25
|
+
process.stdout.write((0, colorette_1.green)('✅ Logged in\n\n'));
|
|
26
|
+
return accessToken;
|
|
27
|
+
}
|
|
28
|
+
openBrowser(url) {
|
|
29
|
+
try {
|
|
30
|
+
const cmd = process.platform === 'win32'
|
|
31
|
+
? `start ${url}`
|
|
32
|
+
: process.platform === 'darwin'
|
|
33
|
+
? `open ${url}`
|
|
34
|
+
: `xdg-open ${url}`;
|
|
35
|
+
childProcess.execSync(cmd);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// silently fail if browser cannot be opened
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async verifyToken(accessToken) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.sendRequest('/session', 'GET', undefined, {
|
|
44
|
+
Cookie: `accessToken=${accessToken};`,
|
|
45
|
+
});
|
|
46
|
+
return !!response.user;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async verifyApiKey(apiKey) {
|
|
53
|
+
try {
|
|
54
|
+
const response = await this.sendRequest('/api-keys-verify', 'POST', {
|
|
55
|
+
apiKey,
|
|
56
|
+
});
|
|
57
|
+
return !!response.success;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async refreshToken(refreshToken) {
|
|
64
|
+
const response = await this.sendRequest(`/device-rotate-token`, 'POST', {
|
|
65
|
+
grant_type: 'refresh_token',
|
|
66
|
+
client_name: this.clientName,
|
|
67
|
+
refresh_token: refreshToken,
|
|
68
|
+
});
|
|
69
|
+
if (!response.access_token) {
|
|
70
|
+
throw new Error('Failed to refresh token');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
access_token: response.access_token,
|
|
74
|
+
refresh_token: response.refresh_token,
|
|
75
|
+
expires_in: response.expires_in,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async pollingAccessToken(deviceCode, interval, expiresIn) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const intervalId = setInterval(async () => {
|
|
81
|
+
const response = await this.getAccessToken(deviceCode);
|
|
82
|
+
if (response.access_token) {
|
|
83
|
+
clearInterval(intervalId);
|
|
84
|
+
clearTimeout(timeoutId);
|
|
85
|
+
resolve(response);
|
|
86
|
+
}
|
|
87
|
+
if (response.error && response.error !== 'authorization_pending') {
|
|
88
|
+
clearInterval(intervalId);
|
|
89
|
+
clearTimeout(timeoutId);
|
|
90
|
+
reject(response.error_description);
|
|
91
|
+
}
|
|
92
|
+
}, interval * 1000);
|
|
93
|
+
const timeoutId = setTimeout(async () => {
|
|
94
|
+
clearInterval(intervalId);
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
reject('Authorization has expired. Please try again.');
|
|
97
|
+
}, expiresIn * 1000);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async getAccessToken(deviceCode) {
|
|
101
|
+
return await this.sendRequest('/device-token', 'POST', {
|
|
102
|
+
client_name: this.clientName,
|
|
103
|
+
device_code: deviceCode,
|
|
104
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async getDeviceCode() {
|
|
108
|
+
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', {
|
|
109
|
+
client_name: this.clientName,
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
deviceCode,
|
|
113
|
+
userCode,
|
|
114
|
+
verificationUri,
|
|
115
|
+
verificationUriComplete,
|
|
116
|
+
interval,
|
|
117
|
+
expiresIn,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async sendRequest(url, method = 'GET', body = undefined, headers = {}) {
|
|
121
|
+
url = `${this.baseUrl}${url}`;
|
|
122
|
+
const response = await this.apiClient.request(url, {
|
|
123
|
+
body: body ? JSON.stringify(body) : body,
|
|
124
|
+
method,
|
|
125
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
126
|
+
});
|
|
127
|
+
if (response.status === 204) {
|
|
128
|
+
return { success: true };
|
|
129
|
+
}
|
|
130
|
+
return await response.json();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
exports.RedoclyOAuthDeviceFlow = RedoclyOAuthDeviceFlow;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class RedoclyOAuthClient {
|
|
2
|
+
private clientName;
|
|
3
|
+
private version;
|
|
4
|
+
private dir;
|
|
5
|
+
private cipher;
|
|
6
|
+
private decipher;
|
|
7
|
+
constructor(clientName: string, version: string);
|
|
8
|
+
login(baseUrl: string): Promise<void>;
|
|
9
|
+
logout(): Promise<void>;
|
|
10
|
+
isAuthorized(baseUrl: string, apiKey?: string): Promise<boolean>;
|
|
11
|
+
private saveToken;
|
|
12
|
+
private readToken;
|
|
13
|
+
private removeToken;
|
|
14
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RedoclyOAuthClient = void 0;
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const node_buffer_1 = require("node:buffer");
|
|
9
|
+
const device_flow_1 = require("./device-flow");
|
|
10
|
+
const SALT = '4618dbc9-8aed-4e27-aaf0-225f4603e5a4';
|
|
11
|
+
const CRYPTO_ALGORITHM = 'aes-256-cbc';
|
|
12
|
+
class RedoclyOAuthClient {
|
|
13
|
+
constructor(clientName, version) {
|
|
14
|
+
this.clientName = clientName;
|
|
15
|
+
this.version = version;
|
|
16
|
+
this.dir = path.join((0, node_os_1.homedir)(), '.redocly');
|
|
17
|
+
if (!(0, node_fs_1.existsSync)(this.dir)) {
|
|
18
|
+
(0, node_fs_1.mkdirSync)(this.dir);
|
|
19
|
+
}
|
|
20
|
+
const homeDirPath = process.env.HOME;
|
|
21
|
+
const hash = crypto.createHash('sha256');
|
|
22
|
+
hash.update(`${homeDirPath}${SALT}`);
|
|
23
|
+
const hashHex = hash.digest('hex');
|
|
24
|
+
const key = node_buffer_1.Buffer.alloc(32, node_buffer_1.Buffer.from(hashHex).toString('base64')).toString();
|
|
25
|
+
const iv = node_buffer_1.Buffer.alloc(16, node_buffer_1.Buffer.from(process.env.HOME).toString('base64')).toString();
|
|
26
|
+
this.cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, key, iv);
|
|
27
|
+
this.decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, key, iv);
|
|
28
|
+
}
|
|
29
|
+
async login(baseUrl) {
|
|
30
|
+
const deviceFlow = new device_flow_1.RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
|
|
31
|
+
const token = await deviceFlow.run();
|
|
32
|
+
if (!token) {
|
|
33
|
+
throw new Error('Failed to login');
|
|
34
|
+
}
|
|
35
|
+
this.saveToken(token);
|
|
36
|
+
}
|
|
37
|
+
async logout() {
|
|
38
|
+
try {
|
|
39
|
+
this.removeToken();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// do nothing
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async isAuthorized(baseUrl, apiKey) {
|
|
46
|
+
const deviceFlow = new device_flow_1.RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
|
|
47
|
+
if (apiKey) {
|
|
48
|
+
return await deviceFlow.verifyApiKey(apiKey);
|
|
49
|
+
}
|
|
50
|
+
const token = await this.readToken();
|
|
51
|
+
if (!token) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const isValidAccessToken = await deviceFlow.verifyToken(token.access_token);
|
|
55
|
+
if (isValidAccessToken) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const newToken = await deviceFlow.refreshToken(token.refresh_token);
|
|
60
|
+
await this.saveToken(newToken);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
async saveToken(token) {
|
|
68
|
+
try {
|
|
69
|
+
const encrypted = this.cipher.update(JSON.stringify(token), 'utf8', 'hex') + this.cipher.final('hex');
|
|
70
|
+
(0, node_fs_1.writeFileSync)(path.join(this.dir, 'auth.json'), encrypted);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
process.stderr.write('Error saving tokens:', error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async readToken() {
|
|
77
|
+
try {
|
|
78
|
+
const token = (0, node_fs_1.readFileSync)(path.join(this.dir, 'auth.json'), 'utf8');
|
|
79
|
+
const decrypted = this.decipher.update(token, 'hex', 'utf8') + this.decipher.final('utf8');
|
|
80
|
+
return decrypted ? JSON.parse(decrypted) : null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async removeToken() {
|
|
87
|
+
const tokenPath = path.join(this.dir, 'auth.json');
|
|
88
|
+
if ((0, node_fs_1.existsSync)(tokenPath)) {
|
|
89
|
+
(0, node_fs_1.rmSync)(tokenPath);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
exports.RedoclyOAuthClient = RedoclyOAuthClient;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CommandArgs } from '../wrapper';
|
|
2
|
+
export declare function promptClientToken(domain: string): Promise<string>;
|
|
3
|
+
export type LoginOptions = {
|
|
4
|
+
verbose?: boolean;
|
|
5
|
+
residency?: string;
|
|
6
|
+
config?: string;
|
|
7
|
+
next?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function handleLogin({ argv, config, version }: CommandArgs<LoginOptions>): Promise<void>;
|
|
10
|
+
export type LogoutOptions = {
|
|
11
|
+
config?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function handleLogout({ version }: CommandArgs<LogoutOptions>): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.promptClientToken = promptClientToken;
|
|
4
|
+
exports.handleLogin = handleLogin;
|
|
5
|
+
exports.handleLogout = handleLogout;
|
|
6
|
+
const colorette_1 = require("colorette");
|
|
7
|
+
const openapi_core_1 = require("@redocly/openapi-core");
|
|
8
|
+
const miscellaneous_1 = require("../utils/miscellaneous");
|
|
9
|
+
const oauth_client_1 = require("../auth/oauth-client");
|
|
10
|
+
const api_1 = require("../reunite/api");
|
|
11
|
+
function promptClientToken(domain) {
|
|
12
|
+
return (0, miscellaneous_1.promptUser)((0, colorette_1.green)(`\n 🔑 Copy your API key from ${(0, colorette_1.blue)(`https://app.${domain}/profile`)} and paste it below`) + (0, colorette_1.yellow)(' (if you want to log in with Reunite, please run `redocly login --next` instead)'), true);
|
|
13
|
+
}
|
|
14
|
+
async function handleLogin({ argv, config, version }) {
|
|
15
|
+
if (argv.next) {
|
|
16
|
+
try {
|
|
17
|
+
const reuniteUrl = (0, api_1.getReuniteUrl)(argv.residency);
|
|
18
|
+
const oauthClient = new oauth_client_1.RedoclyOAuthClient('redocly-cli', version);
|
|
19
|
+
await oauthClient.login(reuniteUrl);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
if (argv.residency) {
|
|
23
|
+
const reuniteUrl = (0, api_1.getReuniteUrl)(argv.residency);
|
|
24
|
+
(0, miscellaneous_1.exitWithError)(`❌ Connection to ${reuniteUrl} failed.`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
(0, miscellaneous_1.exitWithError)(`❌ Login failed. Please check your credentials and try again.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
try {
|
|
33
|
+
const region = argv.residency || config.region;
|
|
34
|
+
const client = new openapi_core_1.RedoclyClient(region);
|
|
35
|
+
const clientToken = await promptClientToken(client.domain);
|
|
36
|
+
process.stdout.write((0, colorette_1.gray)('\n Logging in...\n'));
|
|
37
|
+
await client.login(clientToken, argv.verbose);
|
|
38
|
+
process.stdout.write((0, colorette_1.green)(' Authorization confirmed. ✅\n\n'));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
(0, miscellaneous_1.exitWithError)(' ' + err?.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function handleLogout({ version }) {
|
|
46
|
+
const client = new openapi_core_1.RedoclyClient();
|
|
47
|
+
client.logout();
|
|
48
|
+
const oauthClient = new oauth_client_1.RedoclyOAuthClient('redocly-cli', version);
|
|
49
|
+
oauthClient.logout();
|
|
50
|
+
process.stdout.write('Logged out from the Redocly account. ✋ \n');
|
|
51
|
+
}
|