dbdocs 0.19.0 → 1.0.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/README.md CHANGED
@@ -119,10 +119,13 @@ login to dbdocs
119
119
 
120
120
  ```bash
121
121
  USAGE
122
- $ dbdocs login
122
+ $ dbdocs login [--legacy]
123
+
124
+ FLAGS
125
+ --legacy Use legacy login methods: Email OTP or Google/GitHub with manual token copy
123
126
 
124
127
  DESCRIPTION
125
- login with your dbdocs credentials
128
+ login to dbdocs
126
129
  ```
127
130
 
128
131
  ## `dbdocs logout`
@@ -123,8 +123,15 @@
123
123
  "login": {
124
124
  "aliases": [],
125
125
  "args": {},
126
- "description": "login to dbdocs\nlogin with your dbdocs credentials\n",
127
- "flags": {},
126
+ "description": "login to dbdocs",
127
+ "flags": {
128
+ "legacy": {
129
+ "description": "Use legacy login methods: Email OTP or Google/GitHub with manual token copy",
130
+ "name": "legacy",
131
+ "allowNo": false,
132
+ "type": "boolean"
133
+ }
134
+ },
128
135
  "hasDynamicHelp": false,
129
136
  "hiddenAliases": [],
130
137
  "id": "login",
@@ -379,5 +386,5 @@
379
386
  ]
380
387
  }
381
388
  },
382
- "version": "0.19.0"
389
+ "version": "1.0.0"
383
390
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "dbdocs",
3
- "version": "0.19.0",
3
+ "version": "1.0.0",
4
4
  "author": "@holistics",
5
5
  "bin": {
6
6
  "dbdocs": "./bin/run"
7
7
  },
8
8
  "dependencies": {
9
- "@dbml/connector": "5.3.0",
10
- "@dbml/core": "5.3.0",
9
+ "@dbml/connector": "5.3.1",
10
+ "@dbml/core": "5.3.1",
11
11
  "@oclif/core": "1.26.2",
12
12
  "@oclif/plugin-help": "6.2.32",
13
13
  "axios": "^1.12.0",
@@ -0,0 +1,109 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>dbdocs CLI - Authentication Failed</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ min-height: 100vh;
20
+ background: #f9fafb;
21
+ }
22
+
23
+ .container {
24
+ background: white;
25
+ border-radius: 6px;
26
+ border: 1px solid #eff0f3;
27
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
28
+ padding: 32px;
29
+ width: 100%;
30
+ max-width: 400px;
31
+ text-align: center;
32
+ }
33
+
34
+ .logo-container {
35
+ margin-bottom: 16px;
36
+ }
37
+
38
+ .logo {
39
+ height: 48px;
40
+ }
41
+
42
+ h1 {
43
+ color: #ff4f46;
44
+ font-size: 24px;
45
+ font-weight: 600;
46
+ margin-bottom: 20px;
47
+ }
48
+
49
+ .error-box {
50
+ background: #ffeef1;
51
+ border: 1px solid #fea5a6;
52
+ border-radius: 6px;
53
+ padding: 16px;
54
+ text-align: left;
55
+ }
56
+
57
+ .error-title {
58
+ font-weight: 600;
59
+ color: #ff4f46;
60
+ margin-bottom: 8px;
61
+ font-size: 16px;
62
+ }
63
+
64
+ .error-message {
65
+ font-size: 14px;
66
+ color: #ff4f46;
67
+ line-height: 1.5;
68
+ }
69
+
70
+ p {
71
+ color: #718096;
72
+ line-height: 1.6;
73
+ font-size: 15px;
74
+ }
75
+
76
+ strong {
77
+ color: #2d3748;
78
+ }
79
+
80
+ .footer {
81
+ margin-top: 24px;
82
+ padding-top: 24px;
83
+ border-top: 1px solid #e2e8f0;
84
+ color: #a0aec0;
85
+ font-size: 13px;
86
+ }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <div class="container">
91
+ <div class="logo-container">
92
+ <svg class="logo" width="155" height="155" viewBox="0 0 155 155" fill="none" xmlns="http://www.w3.org/2000/svg">
93
+ <path d="M155 29C155 12.9837 142.016 0 126 0H29C12.9837 0 0 12.9837 0 29V126C0 142.016 12.9837 155 29 155H126C142.016 155 155 142.016 155 126V29Z" fill="#0246CC"/>
94
+ <path d="M77.5 54.6521C102.065 54.6521 121.978 47.8098 121.978 39.3694C121.978 30.929 102.065 24.0867 77.5 24.0867C52.9354 24.0867 33.0218 30.929 33.0218 39.3694C33.0218 47.8098 52.9354 54.6521 77.5 54.6521Z" fill="white"/>
95
+ <path d="M33.0399 76.4867V91.6759C33.0399 96.6868 50.3603 103.965 77.5001 103.965C104.64 103.965 121.96 96.6868 121.96 91.6759V76.4867C112.817 82.4469 95.0968 85.5314 77.5001 85.5314C59.9034 85.5314 42.1828 82.4469 33.0399 76.4867Z" fill="#287EFF"/>
96
+ <path d="M33.0399 50.5847V67.5282C33.0399 72.5391 50.3603 79.8172 77.5001 79.8172C104.64 79.8172 121.96 72.5391 121.96 67.5282V50.5847C113.453 57.1317 97.1229 61.3837 77.5001 61.3837C57.8772 61.3837 41.5476 57.1317 33.0399 50.5847Z" fill="#96C0FF"/>
97
+ <path d="M33.0399 101.065V116.254C33.0399 121.265 50.3603 128.543 77.5001 128.543C104.64 128.543 121.96 121.265 121.96 116.254V101.065C112.817 107.025 95.0968 110.11 77.5001 110.11C59.9034 110.11 42.1828 107.025 33.0399 101.065Z" fill="#0258ED"/>
98
+ </svg>
99
+ </div>
100
+
101
+ <h1>Authentication Failed</h1>
102
+
103
+ <div class="error-box">
104
+ <p class="error-title">Invalid redirect URL</p>
105
+ <p class="error-message">The authentication callback could not be completed.</p>
106
+ </div>
107
+ </div>
108
+ </body>
109
+ </html>
@@ -0,0 +1,73 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>dbdocs CLI - Completing Authentication</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ min-height: 100vh;
20
+ background: #f9fafb;
21
+ }
22
+
23
+ .container {
24
+ background: white;
25
+ border-radius: 6px;
26
+ border: 1px solid #eff0f3;
27
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
28
+ padding: 32px;
29
+ width: 100%;
30
+ max-width: 400px;
31
+ text-align: center;
32
+ }
33
+
34
+ .logo-container {
35
+ margin-bottom: 16px;
36
+ }
37
+
38
+ .logo {
39
+ height: 48px;
40
+ }
41
+
42
+ h1 {
43
+ color: #048cff;
44
+ font-size: 24px;
45
+ font-weight: 600;
46
+ margin-bottom: 16px;
47
+ }
48
+
49
+ p {
50
+ color: #323740;
51
+ line-height: 1.6;
52
+ font-size: 14px;
53
+ }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="container">
58
+ <div class="logo-container">
59
+ <svg class="logo" width="155" height="155" viewBox="0 0 155 155" fill="none" xmlns="http://www.w3.org/2000/svg">
60
+ <path d="M155 29C155 12.9837 142.016 0 126 0H29C12.9837 0 0 12.9837 0 29V126C0 142.016 12.9837 155 29 155H126C142.016 155 155 142.016 155 126V29Z" fill="#0246CC"/>
61
+ <path d="M77.5 54.6521C102.065 54.6521 121.978 47.8098 121.978 39.3694C121.978 30.929 102.065 24.0867 77.5 24.0867C52.9354 24.0867 33.0218 30.929 33.0218 39.3694C33.0218 47.8098 52.9354 54.6521 77.5 54.6521Z" fill="white"/>
62
+ <path d="M33.0399 76.4867V91.6759C33.0399 96.6868 50.3603 103.965 77.5001 103.965C104.64 103.965 121.96 96.6868 121.96 91.6759V76.4867C112.817 82.4469 95.0968 85.5314 77.5001 85.5314C59.9034 85.5314 42.1828 82.4469 33.0399 76.4867Z" fill="#287EFF"/>
63
+ <path d="M33.0399 50.5847V67.5282C33.0399 72.5391 50.3603 79.8172 77.5001 79.8172C104.64 79.8172 121.96 72.5391 121.96 67.5282V50.5847C113.453 57.1317 97.1229 61.3837 77.5001 61.3837C57.8772 61.3837 41.5476 57.1317 33.0399 50.5847Z" fill="#96C0FF"/>
64
+ <path d="M33.0399 101.065V116.254C33.0399 121.265 50.3603 128.543 77.5001 128.543C104.64 128.543 121.96 121.265 121.96 116.254V101.065C112.817 107.025 95.0968 110.11 77.5001 110.11C59.9034 110.11 42.1828 107.025 33.0399 101.065Z" fill="#0258ED"/>
65
+ </svg>
66
+ </div>
67
+
68
+ <h1>Completing Authentication</h1>
69
+
70
+ <p>You can close this window and return to your terminal to continue.</p>
71
+ </div>
72
+ </body>
73
+ </html>
@@ -1,4 +1,4 @@
1
- const { Command } = require('@oclif/core');
1
+ const { Command, Flags } = require('@oclif/core');
2
2
  const open = require('open');
3
3
  const inquirer = require('inquirer').default;
4
4
  const axios = require('axios');
@@ -7,7 +7,12 @@ const netrc = require('netrc-parser').default;
7
7
  const { vars } = require('../vars');
8
8
  const { isValidEmail } = require('../validators/email');
9
9
  const { isValidOtp } = require('../validators/otp');
10
- const { LOGIN_METHODS } = require('../utils/constants');
10
+ const { LOGIN_METHODS, LOGIN_STRATEGIES, PORTAL_ERROR_CODES, PORTAL_LOGIN_CONFIGS } = require('../utils/constants');
11
+ const { initAuthClient } = require('../services/auth/portal');
12
+ const { sleep } = require('../utils/helper');
13
+ const { openUrlInExternalBrowser } = require('../utils/browser');
14
+
15
+ const TIMEOUT_BEFORE_OPEN_BROWSER_MS = 500;
11
16
 
12
17
  async function askForOtp (spinner, shortLivedToken, email) {
13
18
  const cliSpinner = spinner;
@@ -69,10 +74,106 @@ async function loginViaEmail (spinner) {
69
74
  return token;
70
75
  }
71
76
 
77
+ function getPortalErrorMessage(errorCode, errorDescription = '') {
78
+ // If we have errorDescription, use it (this is the main display)
79
+ if (errorDescription) return errorDescription;
80
+
81
+ // Otherwise, fallback to default mapped messages
82
+ const errorMessages = {
83
+ [PORTAL_ERROR_CODES.loginTimeout]: 'Login timed out after 5 minutes. Please try again.',
84
+ [PORTAL_ERROR_CODES.loginCancelled]: 'Login cancelled.',
85
+ [PORTAL_ERROR_CODES.stateMismatch]: 'Security validation failed. Please try again.',
86
+ [PORTAL_ERROR_CODES.invalidCallback]: 'Invalid authentication callback received. Please try again.',
87
+ [PORTAL_ERROR_CODES.exchangeFailed]: 'Failed to exchange authorization code. Please try again.',
88
+ [PORTAL_ERROR_CODES.loopbackServerError]: 'Failed to start callback server. Please try again.',
89
+ [PORTAL_ERROR_CODES.unknownError]: 'Login failed. Please try again.',
90
+ };
91
+
92
+ return errorMessages[errorCode] || 'An unexpected error occurred. Please try again.';
93
+ }
94
+
72
95
  class LoginCommand extends Command {
73
- async run () {
74
- const spinner = ora({});
96
+ /**
97
+ * Detect which login strategy to use
98
+ * Priority: CLI flag > env var > default (portal)
99
+ * @param {boolean} legacyFlag - The --legacy flag value
100
+ * @param {string} loginStrategy - The login strategy from environment variable
101
+ * @returns {'portal' | 'legacy'}
102
+ */
103
+ detectStrategy (legacyFlag, loginStrategy) {
104
+ if (legacyFlag) return LOGIN_STRATEGIES.legacy;
105
+
106
+ return loginStrategy;
107
+ }
108
+
109
+ /**
110
+ * Execute portal login flow (OAuth 2.0 with PKCE)
111
+ * @param {ora.Ora} spinner - The ora spinner instance
112
+ * @returns {Promise<string>} Access token
113
+ * @throws {Error} If login fails (after displaying error to user)
114
+ */
115
+ async executePortalLogin (spinner) {
116
+ let authClient = null;
117
+
118
+ try {
119
+ this.log('Preparing authentication...');
120
+
121
+ authClient = await initAuthClient({
122
+ loginUrl: vars.loginUrl,
123
+ loginApiUrl: vars.loginApiUrl,
124
+ clientId: PORTAL_LOGIN_CONFIGS.clientId,
125
+ });
126
+
127
+ const loginUrl = authClient.getLoginUrl();
128
+
129
+ spinner.text = 'Attempting to open authentication page in your browser...';
130
+ spinner.start();
131
+
132
+ try {
133
+ await sleep(TIMEOUT_BEFORE_OPEN_BROWSER_MS);
134
+ await openUrlInExternalBrowser(loginUrl);
135
+
136
+ spinner.succeed();
137
+ } catch (err) {
138
+ spinner.warn();
139
+ spinner.warn(`Could not open browser automatically. Please navigate to:\n${loginUrl}`);
140
+ }
141
+
142
+ spinner.text = 'Waiting for authentication... (Press CTRL+C to cancel)';
143
+ spinner.start();
144
+
145
+ const { accessToken } = await authClient.obtainTokens();
146
+
147
+ spinner.text = 'Authentication successful';
148
+ spinner.succeed();
149
+
150
+ return accessToken;
151
+ } catch (error) {
152
+ const errorMessage = error.code
153
+ ? getPortalErrorMessage(error.code, error.errorDescription)
154
+ : `Login failed: ${error.message || 'Unknown error'}`;
155
+
156
+ this.error(errorMessage);
157
+ } finally {
158
+ // Always cleanup
159
+ if (authClient) {
160
+ authClient.cleanup();
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Execute legacy login flow (Email OTP or Social)
167
+ * @param {ora.Ora} spinner - The ora spinner instance
168
+ * @returns {Promise<string>} Access token
169
+ * @throws {Error} If login fails (after displaying error to user)
170
+ */
171
+ async executeLegacyLogin (spinner) {
75
172
  try {
173
+ // Show deprecation warning
174
+ this.warn('⚠️ You are using the legacy login. You can remove the --legacy flag for automatic browser authentication.');
175
+
176
+ // Prompt for login method
76
177
  const loginMethodAnswer = await inquirer.prompt([
77
178
  {
78
179
  type: 'rawlist',
@@ -103,69 +204,138 @@ class LoginCommand extends Command {
103
204
  authToken = answer.authToken;
104
205
  }
105
206
 
106
- spinner.text = 'Validate token';
107
- spinner.start();
207
+ return authToken;
208
+ } catch (error) {
209
+ // Handle legacy-specific errors
210
+ let errorMessage = error.message || 'Login failed. Please try again.';
211
+
212
+ if (error.response) {
213
+ const { error: apiError } = error.response.data;
214
+ switch (apiError.name) {
215
+ case 'OtpInvalidError':
216
+ errorMessage = 'Invalid OTP. Please login again.';
217
+ break;
218
+
219
+ case 'OtpUsedError':
220
+ errorMessage = 'OTP used recently. Please login again after couple minutes.';
221
+ break;
222
+
223
+ case 'EmailDeliveryFailedError':
224
+ errorMessage = 'Email delivery failed. Please login again.';
225
+ break;
226
+
227
+ default:
228
+ errorMessage = error.message || 'Login failed. Please try again.';
229
+ break;
230
+ }
231
+ }
232
+
233
+ this.error(errorMessage);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Verify token with API
239
+ * @param {string} token - Access token to verify
240
+ * @returns {Promise<object>} User account object
241
+ * @throws {Error} If token is invalid
242
+ */
243
+ async verifyToken (token) {
244
+ try {
108
245
  const { data: { account } } = await axios.get(`${vars.apiUrl}/account`, {
109
246
  headers: {
110
- Authorization: authToken,
247
+ Authorization: token,
111
248
  'Authorization-Method': 'login',
112
249
  },
113
250
  });
251
+
252
+ return account;
253
+ } catch (error) {
254
+ if (error.response) {
255
+ const { error: apiError } = error.response.data;
256
+ switch (apiError.name) {
257
+ case 'TokenExpiredError':
258
+ throw new Error('Your token has expired. Please login again.');
259
+
260
+ case 'InvalidAuthToken':
261
+ throw new Error('Invalid token. Please login again.');
262
+
263
+ default:
264
+ throw new Error('Failed to verify token. Please try again.');
265
+ }
266
+ }
267
+
268
+ throw error;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Store token to ~/.netrc file
274
+ * @param {string} token - Access token to store
275
+ * @param {object} user - User account object with email
276
+ * @returns {Promise<void>}
277
+ */
278
+ async storeToken (token, user) {
279
+ const { apiHost } = vars;
280
+ await netrc.load();
281
+
282
+ const previousEntry = netrc.machines[apiHost];
283
+ if (!previousEntry) {
284
+ netrc.machines[apiHost] = {};
285
+ }
286
+
287
+ netrc.machines[apiHost].login = user.email;
288
+ netrc.machines[apiHost].password = token;
289
+ await netrc.save();
290
+ }
291
+
292
+ async run () {
293
+ const { flags } = await this.parse(LoginCommand);
294
+ const spinner = ora({});
295
+
296
+ try {
297
+ // 1. Detect strategy
298
+ const strategy = this.detectStrategy(flags.legacy, vars.loginStrategy);
299
+
300
+ // 2. Execute strategy (returns token)
301
+ let accessToken;
302
+ if (strategy === LOGIN_STRATEGIES.legacy) {
303
+ accessToken = await this.executeLegacyLogin(spinner);
304
+ } else {
305
+ accessToken = await this.executePortalLogin(spinner);
306
+ }
307
+
308
+ // 3. Verify token
309
+ spinner.text = 'Validate token';
310
+ spinner.start();
311
+ const user = await this.verifyToken(accessToken);
114
312
  spinner.succeed();
115
313
 
314
+ // 4. Store token
116
315
  spinner.text = 'Save credential';
117
316
  spinner.start();
118
- const { apiHost } = vars;
119
- await netrc.load();
120
- const previousEntry = netrc.machines[apiHost];
121
- if (!previousEntry) {
122
- netrc.machines[apiHost] = {};
123
- }
124
- netrc.machines[apiHost].login = account.email;
125
- netrc.machines[apiHost].password = authToken;
126
- await netrc.save();
317
+ await this.storeToken(accessToken, user);
127
318
  spinner.succeed();
128
319
 
129
320
  this.log('\nDone.');
130
- } catch (err) {
321
+ } catch (error) {
322
+ // Cleanup spinner if still running
131
323
  if (spinner.isSpinning) {
132
324
  spinner.fail();
133
325
  }
134
- let message = err.message || 'Something wrong :( Please try again.';
135
- if (err.response) {
136
- const { error } = err.response.data;
137
- switch (error.name) {
138
- case 'TokenExpiredError':
139
- message = 'Your token has expired. Please login again.';
140
- break;
141
-
142
- case 'InvalidAuthToken':
143
- message = 'Invalid token. Please login again.';
144
- break;
145
326
 
146
- case 'OtpInvalidError':
147
- message = 'Invalid OTP. Please login again.';
148
- break;
149
-
150
- case 'OtpUsedError':
151
- message = 'OTP used recently. Please login again after couple minutes.';
152
- break;
153
-
154
- case 'EmailDeliveryFailedError':
155
- message = 'Email delivery failed. Please login again.';
156
- break;
157
-
158
- default:
159
- break;
160
- }
161
- }
162
- this.error(message);
327
+ this.error(error.message || 'Something wrong. Please try again.');
163
328
  }
164
329
  }
165
330
  }
166
331
 
167
- LoginCommand.description = `login to dbdocs
168
- login with your dbdocs credentials
169
- `;
332
+ LoginCommand.description = 'login to dbdocs';
333
+
334
+ LoginCommand.flags = {
335
+ legacy: Flags.boolean({
336
+ description: 'Use legacy login methods: Email OTP or Google/GitHub with manual token copy',
337
+ default: false,
338
+ }),
339
+ };
170
340
 
171
341
  module.exports = LoginCommand;
@@ -0,0 +1,10 @@
1
+ class BaseError extends Error {
2
+ constructor(message, code) {
3
+ super(message);
4
+ Error.captureStackTrace(this);
5
+
6
+ this.code = code;
7
+ }
8
+ }
9
+
10
+ module.exports = BaseError;
@@ -0,0 +1,12 @@
1
+ const BaseError = require('./BaseError');
2
+
3
+ class PortalAuthenticationError extends BaseError {
4
+ constructor(code, errorDescription = '') {
5
+ super(code, code); // Base message is just the code (internal use)
6
+ this.errorDescription = errorDescription; // This is the main display message
7
+ }
8
+ }
9
+
10
+ module.exports = {
11
+ PortalAuthenticationError,
12
+ };
@@ -0,0 +1,206 @@
1
+ const { randomBytes, createHash } = require('crypto');
2
+ const axios = require('axios');
3
+ const { startLoopbackServer } = require('./server');
4
+ const { PORTAL_ERROR_CODES, PORTAL_LOGIN_CONFIGS } = require('../../../utils/constants');
5
+ const { PortalAuthenticationError } = require('../../../errors/common-errors');
6
+
7
+ const STATE_BYTES_LENGTH = 32;
8
+ const CODE_VERIFIER_BYTES_LENGTH = 32;
9
+
10
+ const generateLoginState = () => randomBytes(STATE_BYTES_LENGTH).toString('base64url');
11
+
12
+ const generateCodeVerifier = () => randomBytes(CODE_VERIFIER_BYTES_LENGTH).toString('base64url');
13
+
14
+ /**
15
+ *
16
+ * @param {string} codeVerifier
17
+ * @returns {string}
18
+ */
19
+ const generateCodeChallenge = (codeVerifier) => {
20
+ const hash = createHash('sha256');
21
+ hash.update(codeVerifier);
22
+
23
+ return hash.digest().toString('base64url');
24
+ };
25
+
26
+ /**
27
+ * Build portal login URL with OAuth parameters
28
+ * @param {{ baseUrl: string, redirectUri: string, codeChallenge: string, state: string, clientId: string }} params
29
+ * @returns {URL}
30
+ */
31
+ const generateLoginUrl = (params) => {
32
+ const {
33
+ baseUrl,
34
+ redirectUri,
35
+ codeChallenge,
36
+ state,
37
+ clientId,
38
+ } = params;
39
+
40
+ const url = new URL('/login', baseUrl);
41
+ url.searchParams.set('client_id', clientId);
42
+ url.searchParams.set('redirect_uri', redirectUri);
43
+ url.searchParams.set('response_type', 'code');
44
+ url.searchParams.set('code_challenge', codeChallenge);
45
+ url.searchParams.set('state', state);
46
+ url.searchParams.set('prompt', 'select_account');
47
+
48
+ return url;
49
+ };
50
+
51
+ /**
52
+ * Exchange authorization code for access token
53
+ * @param {{ apiHost: string, code: string, codeVerifier: string, redirectUri: string, clientId: string }} params
54
+ * @returns {Promise<{ accessToken: string }>}
55
+ * @throws {PortalAuthenticationError} with code 'EXCHANGE_FAILED'
56
+ */
57
+ async function exchangeCodeForToken(params) {
58
+ const { apiHost, code, codeVerifier, redirectUri, clientId } = params;
59
+
60
+ try {
61
+ const { data } = await axios.post(`${apiHost}/portal/auth/token`, {
62
+ grantType: 'authorization_code',
63
+ redirectUri,
64
+ clientId,
65
+ code,
66
+ codeVerifier,
67
+ });
68
+
69
+ return data;
70
+ } catch (error) {
71
+ if (axios.isAxiosError(error)) {
72
+ // We let the description be empty since there is a mapping function that has a fallback for it
73
+ let description = '';
74
+ if (axios.isAxiosError(error)) {
75
+ if (error.response) {
76
+ description = error.response.data?.message || '';
77
+ } else {
78
+ description = error.message || '';
79
+ }
80
+ } else {
81
+ description = error.message || '';
82
+ }
83
+
84
+ throw new PortalAuthenticationError(
85
+ PORTAL_ERROR_CODES.exchangeFailed,
86
+ description,
87
+ );
88
+ }
89
+
90
+ throw new PortalAuthenticationError(
91
+ PORTAL_ERROR_CODES.exchangeFailed,
92
+ error.message || '',
93
+ );
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Initialize OAuth client for portal login
99
+ * Returns a client object with methods to get login URL and exchange token
100
+ *
101
+ * @param {{ loginUrl: string, loginApiUrl: string, clientId: string }} options
102
+ * @returns {Promise<{
103
+ * getLoginUrl: () => string,
104
+ * obtainTokens: () => Promise<{ accessToken: string }>,
105
+ * cleanup: () => void
106
+ * }>}
107
+ */
108
+ async function initAuthClient(options) {
109
+ const { loginUrl, loginApiUrl, clientId } = options;
110
+
111
+ const codeVerifier = generateCodeVerifier();
112
+ const codeChallenge = generateCodeChallenge(codeVerifier);
113
+ const state = generateLoginState();
114
+
115
+ // Start loopback server with configuration
116
+ const { port, close: closeLoopbackServer, loginCompletePromise } = await startLoopbackServer({
117
+ address: PORTAL_LOGIN_CONFIGS.loopbackServer.address,
118
+ port: PORTAL_LOGIN_CONFIGS.loopbackServer.autoAssignedPort,
119
+ });
120
+
121
+ const redirectUri = `http://${PORTAL_LOGIN_CONFIGS.loopbackServer.address}:${port}/callback`;
122
+
123
+ const portalLoginUrl = generateLoginUrl({
124
+ baseUrl: loginUrl,
125
+ redirectUri,
126
+ codeChallenge,
127
+ state,
128
+ clientId,
129
+ });
130
+
131
+ const getLoginUrl = () => portalLoginUrl.toString();
132
+
133
+ const obtainTokens = async () => {
134
+ let timeoutId;
135
+ let sigintHandler;
136
+
137
+ try {
138
+ // Prevent infinite waiting for the browser response
139
+ const timeoutPromise = new Promise((_, reject) => {
140
+ timeoutId = setTimeout(() => {
141
+ reject(
142
+ new PortalAuthenticationError(PORTAL_ERROR_CODES.loginTimeout)
143
+ )
144
+ }, PORTAL_LOGIN_CONFIGS.loginTimeoutMs);
145
+ });
146
+
147
+ // Create cancellation promise for user termination (Ctrl+C)
148
+ const cancellationPromise = new Promise((_, reject) => {
149
+ sigintHandler = () => {
150
+ reject(
151
+ new PortalAuthenticationError(PORTAL_ERROR_CODES.loginCancelled)
152
+ );
153
+ };
154
+ process.once('SIGINT', sigintHandler);
155
+ });
156
+
157
+ const { code, state: returnedState } = await Promise.race([
158
+ loginCompletePromise,
159
+ timeoutPromise,
160
+ cancellationPromise,
161
+ ]);
162
+
163
+ // Validate state to prevent CSRF attacks
164
+ if (state !== returnedState) {
165
+ throw new PortalAuthenticationError(PORTAL_ERROR_CODES.stateMismatch);
166
+ }
167
+
168
+ // Exchange authorization code for access token
169
+ const { accessToken } = await exchangeCodeForToken({
170
+ apiHost: loginApiUrl,
171
+ code,
172
+ codeVerifier,
173
+ redirectUri,
174
+ clientId,
175
+ });
176
+
177
+ return { accessToken };
178
+ } catch (error) {
179
+ if (error instanceof PortalAuthenticationError) throw error;
180
+
181
+ throw new PortalAuthenticationError(
182
+ PORTAL_ERROR_CODES.unknownError,
183
+ error.message || '',
184
+ );
185
+ } finally {
186
+ // Cleanup resources
187
+ if (timeoutId) {
188
+ clearTimeout(timeoutId);
189
+ }
190
+ if (sigintHandler) {
191
+ process.removeListener('SIGINT', sigintHandler);
192
+ }
193
+ closeLoopbackServer();
194
+ }
195
+ };
196
+
197
+ return {
198
+ getLoginUrl,
199
+ obtainTokens,
200
+ cleanup: () => { closeLoopbackServer() },
201
+ };
202
+ }
203
+
204
+ module.exports = {
205
+ initAuthClient,
206
+ };
@@ -0,0 +1,106 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { createServer } = require('http');
4
+
5
+ const { PortalAuthenticationError } = require('../../../errors/common-errors');
6
+ const { PORTAL_ERROR_CODES } = require('../../../utils/constants');
7
+
8
+ // Load HTML templates once at module load
9
+ const errorHtml = fs.readFileSync(path.join(__dirname, '../../../assets', 'callback-error.html'), 'utf-8');
10
+ const successHtml = fs.readFileSync(path.join(__dirname, '../../../assets', 'callback-success.html'), 'utf-8');
11
+
12
+ const DEFAULT_HTTP_HEADERS = {
13
+ 'Content-Type': 'text/html; charset=utf-8',
14
+ };
15
+
16
+ /**
17
+ * Start a local HTTP loopback server to receive OAuth callback
18
+ * @param {Object} options - Server configuration options
19
+ * @param {string} options.address - Address to bind to (default: '127.0.0.1')
20
+ * @param {number} options.port - Port to listen on (0 = auto-assign)
21
+ * @returns {Promise<{ port: number, loginCompletePromise: Promise<{code: string, state: string}>, close: Function }>}
22
+ */
23
+ const startLoopbackServer = ({ address, port } = {}) => {
24
+ return new Promise((resolve, reject) => {
25
+ let isServerClosed = false;
26
+ let loginResolve = null;
27
+ let loginReject = null;
28
+
29
+ const loginCompletePromise = new Promise((res, rej) => {
30
+ loginResolve = res;
31
+ loginReject = rej;
32
+ });
33
+
34
+ const server = createServer();
35
+
36
+ const closeServer = () => {
37
+ if (!isServerClosed && server) {
38
+ isServerClosed = true;
39
+
40
+ // The success or error page could keep the connection alive and prevent the server from closing
41
+ // So we need to close all connections before closing the server
42
+ server.closeAllConnections();
43
+ server.close();
44
+ }
45
+ };
46
+
47
+ server.on('error', (err) => {
48
+ closeServer();
49
+ reject(
50
+ new PortalAuthenticationError(
51
+ PORTAL_ERROR_CODES.loopbackServerError,
52
+ err.message || '',
53
+ )
54
+ );
55
+ });
56
+
57
+ server.on('request', (req, res) => {
58
+ const reqUrl = new URL(req.url, `http://${req.headers.host}`);
59
+
60
+ const { pathname } = reqUrl;
61
+ switch (pathname) {
62
+ case '/callback': {
63
+ const code = reqUrl.searchParams.get('code');
64
+ const state = reqUrl.searchParams.get('state');
65
+
66
+ if (!code || !state) {
67
+ res.writeHead(400, DEFAULT_HTTP_HEADERS);
68
+ res.end(errorHtml);
69
+
70
+ closeServer();
71
+ loginReject(new PortalAuthenticationError(PORTAL_ERROR_CODES.invalidCallback));
72
+ return;
73
+ }
74
+
75
+ res.writeHead(200, DEFAULT_HTTP_HEADERS);
76
+ res.end(successHtml);
77
+
78
+ loginResolve({ code, state });
79
+ break;
80
+ }
81
+ default: {
82
+ res.writeHead(404, DEFAULT_HTTP_HEADERS);
83
+ res.end();
84
+ closeServer();
85
+ loginReject(new PortalAuthenticationError(
86
+ PORTAL_ERROR_CODES.invalidCallback,
87
+ ));
88
+ return;
89
+ }
90
+ }
91
+ });
92
+
93
+ // Listen on specified address and port
94
+ server.listen(port, address, () => {
95
+ resolve({
96
+ port: server.address().port,
97
+ loginCompletePromise,
98
+ close: closeServer,
99
+ });
100
+ });
101
+ });
102
+ };
103
+
104
+ module.exports = {
105
+ startLoopbackServer,
106
+ };
@@ -1 +1 @@
1
- {"BUILD_COUNT":1}
1
+ {"BUILD_COUNT":2}
@@ -0,0 +1,13 @@
1
+ const open = require('open');
2
+ /**
3
+ * Open URL in default browser
4
+ * @param {string} url - URL to open
5
+ * @returns {Promise<void>}
6
+ */
7
+ const openUrlInExternalBrowser = async (url) => {
8
+ await open(url, { wait: false });
9
+ };
10
+
11
+ module.exports = {
12
+ openUrlInExternalBrowser,
13
+ };
@@ -43,6 +43,37 @@ const HOST_CONFIG_STATUS = {
43
43
  inactive: 'inactive',
44
44
  };
45
45
 
46
+ const LOGIN_STRATEGIES = {
47
+ legacy: 'legacy',
48
+ portal: 'portal',
49
+ };
50
+
51
+ /**
52
+ * Portal authentication error codes
53
+ * Used for OAuth 2.0 flow errors
54
+ */
55
+ const PORTAL_ERROR_CODES = {
56
+ loginTimeout: 'LOGIN_TIMEOUT',
57
+ loginCancelled: 'LOGIN_CANCELLED',
58
+ stateMismatch: 'STATE_MISMATCH',
59
+ invalidCallback: 'INVALID_CALLBACK',
60
+ exchangeFailed: 'EXCHANGE_FAILED',
61
+ loopbackServerError: 'LOOPBACK_SERVER_ERROR',
62
+ unknownError: 'UNKNOWN_ERROR',
63
+ };
64
+
65
+ const PORTAL_LOGIN_CONFIGS = {
66
+ clientId: 'dbdocs-cli',
67
+ loginTimeoutMs: 5 * 60 * 1000, // 5 minutes
68
+ loopbackServer: {
69
+ // It's recommended to use 127.0.0.1 instead of localhost since the localhost can be overriden on the host machine
70
+ // See https://www.rfc-editor.org/rfc/rfc8252#section-8.3
71
+ address: '127.0.0.1',
72
+ // For node http server, port 0 means the OS will assign a random one
73
+ autoAssignedPort: 0,
74
+ },
75
+ };
76
+
46
77
  module.exports = {
47
78
  PROJECT_GENERAL_ACCESS_TYPE,
48
79
  PROJECT_SHARING_TEXT,
@@ -52,4 +83,7 @@ module.exports = {
52
83
  WINDOW_FILE_PATH_REGEX,
53
84
  UNIX_FILE_PATH_REGEX,
54
85
  HOST_CONFIG_STATUS,
86
+ LOGIN_STRATEGIES,
87
+ PORTAL_ERROR_CODES,
88
+ PORTAL_LOGIN_CONFIGS,
55
89
  };
@@ -15,8 +15,15 @@ const parseProjectName = (projectName) => {
15
15
 
16
16
  const getProjectUrl = (hostUrl, orgName, projectUrl) => `${hostUrl}/${encodeURIComponent(orgName)}/${projectUrl}`;
17
17
 
18
+ const sleep = async (miliseconds) => new Promise((resolve) => {
19
+ setTimeout(() => {
20
+ resolve(undefined);
21
+ }, miliseconds);
22
+ });
23
+
18
24
  module.exports = {
19
25
  getIsPublicValueFromBuildFlag,
20
26
  getProjectUrl,
21
27
  parseProjectName,
28
+ sleep,
22
29
  };
package/src/vars.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { LOGIN_STRATEGIES } = require("./utils/constants");
2
+
1
3
  /* eslint-disable class-methods-use-this */
2
4
  class Vars {
3
5
  get host () {
@@ -23,6 +25,19 @@ class Vars {
23
25
  get envApiHost () {
24
26
  return process.env.DBDOCS_API_HOST;
25
27
  }
28
+
29
+ get loginStrategy () {
30
+ const envLoginStrategy = process.env.DBDOCS_LOGIN_STRATEGY || '';
31
+ return Object.values(LOGIN_STRATEGIES).includes(envLoginStrategy) ? envLoginStrategy : LOGIN_STRATEGIES.portal;
32
+ }
33
+
34
+ get loginUrl () {
35
+ return process.env.DBDOCS_LOGIN_URL || 'https://dbdiagram.io';
36
+ }
37
+
38
+ get loginApiUrl () {
39
+ return process.env.DBDOCS_LOGIN_API_URL || this.apiUrl;
40
+ }
26
41
  }
27
42
 
28
43
  module.exports.Vars = Vars;
@@ -1,3 +1,5 @@
1
+ const { LOGIN_STRATEGIES } = require("./utils/constants");
2
+
1
3
  /* eslint-disable class-methods-use-this */
2
4
  // Copied from `vars.js` and replace the 5th and 9th lines with staging's endpoints
3
5
  class Vars {
@@ -24,6 +26,19 @@ class Vars {
24
26
  get envApiHost () {
25
27
  return process.env.DBDOCS_API_HOST;
26
28
  }
29
+
30
+ get loginStrategy () {
31
+ const envLoginStrategy = process.env.DBDOCS_LOGIN_STRATEGY || '';
32
+ return Object.values(LOGIN_STRATEGIES).includes(envLoginStrategy) ? envLoginStrategy : LOGIN_STRATEGIES.portal;
33
+ }
34
+
35
+ get loginUrl () {
36
+ return process.env.DBDOCS_LOGIN_URL || 'https://staging.dbdiagram.io';
37
+ }
38
+
39
+ get loginApiUrl () {
40
+ return process.env.DBDOCS_LOGIN_API_URL || this.apiUrl;
41
+ }
27
42
  }
28
43
 
29
44
  module.exports.Vars = Vars;