neonctl 2.8.0 → 2.9.1
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/analytics.js +5 -0
- package/commands/auth.js +92 -40
- package/commands/auth.test.js +134 -4
- package/commands/index.js +0 -2
- package/index.js +0 -2
- package/package.json +2 -3
- package/commands/bootstrap/authjs-secret.js +0 -6
- package/commands/bootstrap/index.js +0 -842
- package/commands/bootstrap/index.test.js +0 -93
- package/commands/bootstrap/is-folder-empty.js +0 -56
- package/commands/bootstrap/validate-pkg.js +0 -15
package/analytics.js
CHANGED
|
@@ -66,6 +66,10 @@ export const sendError = (err, errCode) => {
|
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
const axiosError = isAxiosError(err) ? err : undefined;
|
|
69
|
+
const requestId = axiosError?.response?.headers['x-neon-ret-request-id'];
|
|
70
|
+
if (requestId) {
|
|
71
|
+
log.debug('Failed request ID: %s', requestId);
|
|
72
|
+
}
|
|
69
73
|
client.track({
|
|
70
74
|
event: 'CLI Error',
|
|
71
75
|
userId: userId ?? 'anonymous',
|
|
@@ -74,6 +78,7 @@ export const sendError = (err, errCode) => {
|
|
|
74
78
|
stack: err.stack,
|
|
75
79
|
errCode,
|
|
76
80
|
statusCode: axiosError?.response?.status,
|
|
81
|
+
requestId: requestId,
|
|
77
82
|
},
|
|
78
83
|
});
|
|
79
84
|
log.debug('Sent CLI error event: %s', errCode);
|
package/commands/auth.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
2
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { TokenSet } from 'openid-client';
|
|
4
|
-
import { auth, refreshToken } from '../auth.js';
|
|
5
|
-
import { log } from '../log.js';
|
|
6
5
|
import { getApiClient } from '../api.js';
|
|
7
|
-
import {
|
|
6
|
+
import { auth, refreshToken } from '../auth.js';
|
|
8
7
|
import { CREDENTIALS_FILE } from '../config.js';
|
|
8
|
+
import { isCi } from '../env.js';
|
|
9
|
+
import { log } from '../log.js';
|
|
9
10
|
export const command = 'auth';
|
|
10
11
|
export const aliases = ['login'];
|
|
11
12
|
export const describe = 'Authenticate';
|
|
@@ -24,11 +25,16 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
|
|
|
24
25
|
clientId: clientId,
|
|
25
26
|
});
|
|
26
27
|
const credentialsPath = join(configDir, CREDENTIALS_FILE);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
try {
|
|
29
|
+
await preserveCredentials(credentialsPath, tokenSet, getApiClient({
|
|
30
|
+
apiKey: tokenSet.access_token || '',
|
|
31
|
+
apiHost,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
log.error('Failed to save credentials');
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
32
38
|
log.info('Auth complete');
|
|
33
39
|
return tokenSet.access_token || '';
|
|
34
40
|
};
|
|
@@ -42,12 +48,65 @@ const preserveCredentials = async (path, credentials, apiClient) => {
|
|
|
42
48
|
writeFileSync(path, contents, {
|
|
43
49
|
mode: 0o700,
|
|
44
50
|
});
|
|
51
|
+
log.info('Saved credentials to %s', path);
|
|
52
|
+
log.debug('Credentials MD5 hash: %s', md5hash(contents));
|
|
53
|
+
};
|
|
54
|
+
const isCompleteTokenSet = (tokenSet) => {
|
|
55
|
+
return !!(tokenSet.access_token &&
|
|
56
|
+
tokenSet.refresh_token &&
|
|
57
|
+
tokenSet.expires_at);
|
|
58
|
+
};
|
|
59
|
+
const handleExistingToken = async (tokenSet, props, credentialsPath) => {
|
|
60
|
+
// Use existing access_token, if present and valid
|
|
61
|
+
if (!!tokenSet.access_token && !tokenSet.expired()) {
|
|
62
|
+
const apiClient = getApiClient({
|
|
63
|
+
apiKey: tokenSet.access_token,
|
|
64
|
+
apiHost: props.apiHost,
|
|
65
|
+
});
|
|
66
|
+
return { apiKey: tokenSet.access_token, apiClient };
|
|
67
|
+
}
|
|
68
|
+
// Either access_token is missing or its expired. Refresh the token
|
|
69
|
+
log.debug(tokenSet.expired()
|
|
70
|
+
? 'Token is expired, attempting refresh'
|
|
71
|
+
: 'Token is missing access_token, attempting refresh');
|
|
72
|
+
if (!tokenSet.refresh_token) {
|
|
73
|
+
log.debug('TokenSet is missing refresh_token, starting authentication');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const refreshedTokenSet = await refreshToken({
|
|
78
|
+
oauthHost: props.oauthHost,
|
|
79
|
+
clientId: props.clientId,
|
|
80
|
+
}, tokenSet);
|
|
81
|
+
if (!isCompleteTokenSet(refreshedTokenSet)) {
|
|
82
|
+
log.debug('Refreshed token is invalid or missing access_token');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const apiKey = refreshedTokenSet.access_token;
|
|
86
|
+
const apiClient = getApiClient({
|
|
87
|
+
apiKey,
|
|
88
|
+
apiHost: props.apiHost,
|
|
89
|
+
});
|
|
90
|
+
await preserveCredentials(credentialsPath, refreshedTokenSet, apiClient);
|
|
91
|
+
log.debug('Token refresh successful');
|
|
92
|
+
return { apiKey, apiClient };
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const typedErr = err instanceof Error ? err : new Error('Unknown error');
|
|
96
|
+
log.debug('Failed to refresh token: %s', typedErr.message);
|
|
97
|
+
throw new Error('AUTH_REFRESH_FAILED');
|
|
98
|
+
}
|
|
45
99
|
};
|
|
46
100
|
export const ensureAuth = async (props) => {
|
|
101
|
+
// Skip auth for help command or no command
|
|
47
102
|
if (props._.length === 0 || props.help) {
|
|
48
103
|
return;
|
|
49
104
|
}
|
|
105
|
+
// Use existing API key or handle auth command
|
|
50
106
|
if (props.apiKey || props._[0] === 'auth') {
|
|
107
|
+
if (props.apiKey) {
|
|
108
|
+
log.debug('Using an API key to authorize requests');
|
|
109
|
+
}
|
|
51
110
|
props.apiClient = getApiClient({
|
|
52
111
|
apiKey: props.apiKey,
|
|
53
112
|
apiHost: props.apiHost,
|
|
@@ -55,49 +114,42 @@ export const ensureAuth = async (props) => {
|
|
|
55
114
|
return;
|
|
56
115
|
}
|
|
57
116
|
const credentialsPath = join(props.configDir, CREDENTIALS_FILE);
|
|
117
|
+
// Handle case when credentials file exists
|
|
58
118
|
if (existsSync(credentialsPath)) {
|
|
119
|
+
log.debug('Trying to read credentials from %s', credentialsPath);
|
|
59
120
|
try {
|
|
60
|
-
const
|
|
121
|
+
const contents = readFileSync(credentialsPath, 'utf8');
|
|
122
|
+
log.debug('Credentials MD5 hash: %s', md5hash(contents));
|
|
123
|
+
const tokenSetContents = JSON.parse(contents);
|
|
61
124
|
const tokenSet = new TokenSet(tokenSetContents);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}, tokenSet).catch((err) => {
|
|
68
|
-
const typedErr = err && err instanceof Error ? err : undefined;
|
|
69
|
-
log.error('failed to refresh token\n%s', typedErr?.message);
|
|
70
|
-
process.exit(1);
|
|
71
|
-
});
|
|
72
|
-
props.apiKey = refreshedTokenSet.access_token || 'UNKNOWN';
|
|
73
|
-
props.apiClient = getApiClient({
|
|
74
|
-
apiKey: props.apiKey,
|
|
75
|
-
apiHost: props.apiHost,
|
|
76
|
-
});
|
|
77
|
-
await preserveCredentials(credentialsPath, refreshedTokenSet, props.apiClient);
|
|
125
|
+
// Try to use existing token or refresh it
|
|
126
|
+
const result = await handleExistingToken(tokenSet, props, credentialsPath);
|
|
127
|
+
if (result) {
|
|
128
|
+
props.apiKey = result.apiKey;
|
|
129
|
+
props.apiClient = result.apiClient;
|
|
78
130
|
return;
|
|
79
131
|
}
|
|
80
|
-
const token = tokenSet.access_token || 'UNKNOWN';
|
|
81
|
-
props.apiKey = token;
|
|
82
|
-
props.apiClient = getApiClient({
|
|
83
|
-
apiKey: props.apiKey,
|
|
84
|
-
apiHost: props.apiHost,
|
|
85
|
-
});
|
|
86
|
-
return;
|
|
87
132
|
}
|
|
88
|
-
catch (
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (!(err instanceof Error && err.message === 'AUTH_REFRESH_FAILED') &&
|
|
135
|
+
err.code !== 'ENOENT' &&
|
|
136
|
+
!(err instanceof SyntaxError)) {
|
|
137
|
+
// Throw for any errors except auth refresh failure, missing file, or invalid credentials file
|
|
138
|
+
throw err;
|
|
92
139
|
}
|
|
93
|
-
|
|
140
|
+
// Fall through to new auth flow for auth failures
|
|
141
|
+
log.debug('Ensure auth failed, starting authentication', err);
|
|
94
142
|
}
|
|
95
143
|
}
|
|
96
144
|
else {
|
|
97
|
-
|
|
145
|
+
log.debug('Credentials file %s does not exist, starting authentication', credentialsPath);
|
|
98
146
|
}
|
|
147
|
+
// Start new auth flow if no valid token exists or refresh failed
|
|
148
|
+
const apiKey = await authFlow(props);
|
|
149
|
+
props.apiKey = apiKey;
|
|
99
150
|
props.apiClient = getApiClient({
|
|
100
|
-
apiKey
|
|
151
|
+
apiKey,
|
|
101
152
|
apiHost: props.apiHost,
|
|
102
153
|
});
|
|
103
154
|
};
|
|
155
|
+
const md5hash = (s) => createHash('md5').update(s).digest('hex');
|
package/commands/auth.test.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { TokenSet } from 'openid-client';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, vi } from 'vitest';
|
|
6
|
+
import * as authModule from '../auth';
|
|
5
7
|
import { test } from '../test_utils/fixtures';
|
|
6
|
-
import {
|
|
8
|
+
import { startOauthServer } from '../test_utils/oauth_server';
|
|
9
|
+
import { authFlow, ensureAuth } from './auth';
|
|
7
10
|
vi.mock('open', () => ({ default: vi.fn((url) => axios.get(url)) }));
|
|
8
11
|
vi.mock('../pkg.ts', () => ({ default: { version: '0.0.0' } }));
|
|
9
12
|
describe('auth', () => {
|
|
@@ -33,3 +36,130 @@ describe('auth', () => {
|
|
|
33
36
|
expect(credentials.user_id).toEqual(expect.any(String));
|
|
34
37
|
});
|
|
35
38
|
});
|
|
39
|
+
describe('ensureAuth', () => {
|
|
40
|
+
let configDir = '';
|
|
41
|
+
let oauthServer;
|
|
42
|
+
let mockApiClient;
|
|
43
|
+
let authSpy;
|
|
44
|
+
let refreshTokenSpy;
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
configDir = mkdtempSync('test-config');
|
|
47
|
+
oauthServer = await startOauthServer();
|
|
48
|
+
mockApiClient = {};
|
|
49
|
+
authSpy = vi.spyOn(authModule, 'auth');
|
|
50
|
+
refreshTokenSpy = vi.spyOn(authModule, 'refreshToken');
|
|
51
|
+
});
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
rmSync(configDir, { recursive: true });
|
|
54
|
+
await oauthServer.stop();
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
});
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
authSpy.mockClear();
|
|
59
|
+
refreshTokenSpy.mockClear();
|
|
60
|
+
});
|
|
61
|
+
const setupTestProps = (server) => ({
|
|
62
|
+
_: ['some-command'],
|
|
63
|
+
configDir,
|
|
64
|
+
oauthHost: `http://localhost:${oauthServer.address().port}`,
|
|
65
|
+
clientId: 'test-client-id',
|
|
66
|
+
forceAuth: true,
|
|
67
|
+
apiKey: '',
|
|
68
|
+
apiHost: `http://localhost:${server.address().port}`,
|
|
69
|
+
help: false,
|
|
70
|
+
apiClient: mockApiClient,
|
|
71
|
+
});
|
|
72
|
+
test('should start new auth flow when refresh token fails', async ({ runMockServer, }) => {
|
|
73
|
+
refreshTokenSpy.mockImplementationOnce(() => Promise.reject(new Error('AUTH_REFRESH_FAILED')));
|
|
74
|
+
authSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
|
|
75
|
+
access_token: 'new-auth-token',
|
|
76
|
+
refresh_token: 'new-refresh-token',
|
|
77
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
78
|
+
})));
|
|
79
|
+
const server = await runMockServer('main');
|
|
80
|
+
const expiredTokenSet = new TokenSet({
|
|
81
|
+
access_token: 'expired-token',
|
|
82
|
+
refresh_token: 'refresh-token',
|
|
83
|
+
expires_at: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
|
84
|
+
});
|
|
85
|
+
writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
|
|
86
|
+
const props = setupTestProps(server);
|
|
87
|
+
await ensureAuth(props);
|
|
88
|
+
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(authSpy).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(props.apiKey).toBe('new-auth-token');
|
|
91
|
+
});
|
|
92
|
+
test('should trigger auth flow when credentials.json does not exist', async ({ runMockServer, }) => {
|
|
93
|
+
const server = await runMockServer('main');
|
|
94
|
+
// Ensure the credentials file does not exist
|
|
95
|
+
const credentialsPath = join(configDir, 'credentials.json');
|
|
96
|
+
if (existsSync(credentialsPath)) {
|
|
97
|
+
rmSync(credentialsPath);
|
|
98
|
+
}
|
|
99
|
+
const props = setupTestProps(server);
|
|
100
|
+
await ensureAuth(props);
|
|
101
|
+
expect(authSpy).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(refreshTokenSpy).not.toHaveBeenCalled();
|
|
103
|
+
expect(props.apiKey).toEqual(expect.any(String));
|
|
104
|
+
});
|
|
105
|
+
test('should trigger auth flow when credentials.json is invalid', async ({ runMockServer, }) => {
|
|
106
|
+
const server = await runMockServer('main');
|
|
107
|
+
// Write an empty credentials file
|
|
108
|
+
writeFileSync(join(configDir, 'credentials.json'), '', { mode: 0o700 });
|
|
109
|
+
const props = setupTestProps(server);
|
|
110
|
+
await ensureAuth(props);
|
|
111
|
+
expect(authSpy).toHaveBeenCalledTimes(1);
|
|
112
|
+
expect(refreshTokenSpy).not.toHaveBeenCalled();
|
|
113
|
+
expect(props.apiKey).toEqual(expect.any(String));
|
|
114
|
+
});
|
|
115
|
+
test('should try refresh when token is missing access_token but has refresh_token', async ({ runMockServer, }) => {
|
|
116
|
+
const server = await runMockServer('main');
|
|
117
|
+
const tokenWithoutAccess = new TokenSet({
|
|
118
|
+
refresh_token: 'refresh-token',
|
|
119
|
+
});
|
|
120
|
+
writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(tokenWithoutAccess), { mode: 0o700 });
|
|
121
|
+
refreshTokenSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
|
|
122
|
+
access_token: 'refreshed-token',
|
|
123
|
+
refresh_token: 'new-refresh-token',
|
|
124
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
125
|
+
})));
|
|
126
|
+
const props = setupTestProps(server);
|
|
127
|
+
await ensureAuth(props);
|
|
128
|
+
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(authSpy).not.toHaveBeenCalled();
|
|
130
|
+
expect(props.apiKey).toBe('refreshed-token');
|
|
131
|
+
});
|
|
132
|
+
test('should use existing valid token', async ({ runMockServer }) => {
|
|
133
|
+
const server = await runMockServer('main');
|
|
134
|
+
const validTokenSet = new TokenSet({
|
|
135
|
+
access_token: 'valid-token',
|
|
136
|
+
refresh_token: 'refresh-token',
|
|
137
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
|
138
|
+
});
|
|
139
|
+
writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(validTokenSet), { mode: 0o700 });
|
|
140
|
+
const props = setupTestProps(server);
|
|
141
|
+
await ensureAuth(props);
|
|
142
|
+
expect(authSpy).not.toHaveBeenCalled();
|
|
143
|
+
expect(refreshTokenSpy).not.toHaveBeenCalled();
|
|
144
|
+
expect(props.apiKey).toBe('valid-token');
|
|
145
|
+
});
|
|
146
|
+
test('should successfully refresh expired token', async ({ runMockServer, }) => {
|
|
147
|
+
refreshTokenSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
|
|
148
|
+
access_token: 'new-token',
|
|
149
|
+
refresh_token: 'new-refresh-token',
|
|
150
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
151
|
+
})));
|
|
152
|
+
const server = await runMockServer('main');
|
|
153
|
+
const expiredTokenSet = new TokenSet({
|
|
154
|
+
access_token: 'expired-token',
|
|
155
|
+
refresh_token: 'refresh-token',
|
|
156
|
+
expires_at: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
|
157
|
+
});
|
|
158
|
+
writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
|
|
159
|
+
const props = setupTestProps(server);
|
|
160
|
+
await ensureAuth(props);
|
|
161
|
+
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(authSpy).not.toHaveBeenCalled();
|
|
163
|
+
expect(props.apiKey).toBe('new-token');
|
|
164
|
+
});
|
|
165
|
+
});
|
package/commands/index.js
CHANGED
|
@@ -10,7 +10,6 @@ import * as roles from './roles.js';
|
|
|
10
10
|
import * as operations from './operations.js';
|
|
11
11
|
import * as cs from './connection_string.js';
|
|
12
12
|
import * as setContext from './set_context.js';
|
|
13
|
-
import * as bootstrap from './bootstrap/index.js';
|
|
14
13
|
export default [
|
|
15
14
|
auth,
|
|
16
15
|
users,
|
|
@@ -24,5 +23,4 @@ export default [
|
|
|
24
23
|
operations,
|
|
25
24
|
cs,
|
|
26
25
|
setContext,
|
|
27
|
-
bootstrap,
|
|
28
26
|
];
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "git+ssh://git@github.com/neondatabase/neonctl.git"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"version": "2.
|
|
8
|
+
"version": "2.9.1",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"strip-ansi": "^7.1.0",
|
|
52
52
|
"typescript": "^4.7.4",
|
|
53
53
|
"typescript-eslint": "v8.0.0-alpha.41",
|
|
54
|
-
"vitest": "^1.6.
|
|
54
|
+
"vitest": "^1.6.1"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@neondatabase/api-client": "1.12.0",
|
|
@@ -62,7 +62,6 @@
|
|
|
62
62
|
"cli-table": "^0.3.11",
|
|
63
63
|
"crypto-random-string": "^5.0.0",
|
|
64
64
|
"diff": "^5.2.0",
|
|
65
|
-
"inquirer": "^9.2.6",
|
|
66
65
|
"open": "^10.1.0",
|
|
67
66
|
"openid-client": "^5.6.5",
|
|
68
67
|
"prompts": "2.4.2",
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// See https://github.com/nextauthjs/cli/blob/8443988fe7e7f078ead32288dcd1b01b9443f13a/commands/secret.js#L9
|
|
2
|
-
// for reference.
|
|
3
|
-
export function getAuthjsSecret() {
|
|
4
|
-
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
5
|
-
return Buffer.from(bytes.toString(), 'base64').toString('base64');
|
|
6
|
-
}
|