@aderis-erp/cli 1.0.15 → 1.0.17

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.
Files changed (163) hide show
  1. package/README.md +30 -8
  2. package/dist/commands/access-groups.d.ts +0 -1
  3. package/dist/commands/access-groups.js +5 -61
  4. package/dist/commands/account-transaction-allocations.d.ts +0 -1
  5. package/dist/commands/account-transaction-allocations.js +5 -61
  6. package/dist/commands/account-transactions.d.ts +0 -1
  7. package/dist/commands/account-transactions.js +9 -63
  8. package/dist/commands/accounts.d.ts +0 -1
  9. package/dist/commands/accounts.js +5 -61
  10. package/dist/commands/agent-platform.d.ts +0 -1
  11. package/dist/commands/agent-platform.js +5 -61
  12. package/dist/commands/analytics.d.ts +0 -1
  13. package/dist/commands/analytics.js +5 -61
  14. package/dist/commands/api-keys.d.ts +39 -0
  15. package/dist/commands/api-keys.js +1117 -0
  16. package/dist/commands/audit-log.d.ts +0 -1
  17. package/dist/commands/audit-log.js +5 -61
  18. package/dist/commands/auth/login.d.ts +9 -0
  19. package/dist/commands/auth/login.js +73 -20
  20. package/dist/commands/auth/logout.d.ts +4 -0
  21. package/dist/commands/auth/logout.js +4 -0
  22. package/dist/commands/auth/status.js +26 -17
  23. package/dist/commands/auth/tenant.js +3 -6
  24. package/dist/commands/auth.d.ts +0 -1
  25. package/dist/commands/auth.js +5 -34
  26. package/dist/commands/bank-accounts.d.ts +0 -1
  27. package/dist/commands/bank-accounts.js +5 -61
  28. package/dist/commands/bank-integrations.d.ts +0 -1
  29. package/dist/commands/bank-integrations.js +5 -61
  30. package/dist/commands/business-partners.d.ts +1 -1
  31. package/dist/commands/business-partners.js +14 -63
  32. package/dist/commands/chat.d.ts +0 -1
  33. package/dist/commands/chat.js +5 -61
  34. package/dist/commands/config/add.d.ts +3 -0
  35. package/dist/commands/config/add.js +34 -16
  36. package/dist/commands/config/current.js +3 -0
  37. package/dist/commands/config/list.js +3 -0
  38. package/dist/commands/credit-card-accounts.d.ts +0 -1
  39. package/dist/commands/credit-card-accounts.js +5 -61
  40. package/dist/commands/delivery-notes.d.ts +0 -1
  41. package/dist/commands/delivery-notes.js +7 -63
  42. package/dist/commands/dunning-configs.d.ts +0 -1
  43. package/dist/commands/dunning-configs.js +5 -61
  44. package/dist/commands/dunning-reminders.d.ts +0 -1
  45. package/dist/commands/dunning-reminders.js +5 -61
  46. package/dist/commands/dunning-runs.d.ts +0 -1
  47. package/dist/commands/dunning-runs.js +5 -61
  48. package/dist/commands/email-transactions.d.ts +0 -1
  49. package/dist/commands/email-transactions.js +5 -61
  50. package/dist/commands/employees.d.ts +0 -1
  51. package/dist/commands/employees.js +5 -61
  52. package/dist/commands/entity-configurations.d.ts +0 -1
  53. package/dist/commands/entity-configurations.js +5 -61
  54. package/dist/commands/event-processor-mappings.d.ts +0 -1
  55. package/dist/commands/event-processor-mappings.js +5 -61
  56. package/dist/commands/files.d.ts +0 -1
  57. package/dist/commands/files.js +5 -61
  58. package/dist/commands/health.d.ts +0 -1
  59. package/dist/commands/health.js +5 -61
  60. package/dist/commands/inbox.d.ts +0 -1
  61. package/dist/commands/inbox.js +5 -61
  62. package/dist/commands/integrations.d.ts +0 -1
  63. package/dist/commands/integrations.js +5 -61
  64. package/dist/commands/item-categories.d.ts +0 -1
  65. package/dist/commands/item-categories.js +5 -61
  66. package/dist/commands/items.d.ts +0 -1
  67. package/dist/commands/items.js +5 -61
  68. package/dist/commands/journal-lines.d.ts +0 -1
  69. package/dist/commands/journal-lines.js +5 -61
  70. package/dist/commands/journals.d.ts +1 -1
  71. package/dist/commands/journals.js +9 -62
  72. package/dist/commands/ledgers.d.ts +0 -1
  73. package/dist/commands/ledgers.js +5 -61
  74. package/dist/commands/nango-integrations.d.ts +39 -0
  75. package/dist/commands/nango-integrations.js +1115 -0
  76. package/dist/commands/office-locations.d.ts +0 -1
  77. package/dist/commands/office-locations.js +5 -61
  78. package/dist/commands/payment-methods.d.ts +0 -1
  79. package/dist/commands/payment-methods.js +5 -61
  80. package/dist/commands/payment-runs.d.ts +0 -1
  81. package/dist/commands/payment-runs.js +5 -61
  82. package/dist/commands/payment-terms.d.ts +0 -1
  83. package/dist/commands/payment-terms.js +5 -61
  84. package/dist/commands/payments.d.ts +0 -1
  85. package/dist/commands/payments.js +5 -61
  86. package/dist/commands/price-lists.d.ts +0 -1
  87. package/dist/commands/price-lists.js +5 -61
  88. package/dist/commands/processor-mappings.d.ts +0 -1
  89. package/dist/commands/processor-mappings.js +5 -61
  90. package/dist/commands/processors.d.ts +0 -1
  91. package/dist/commands/processors.js +5 -61
  92. package/dist/commands/purchase-order-types.d.ts +0 -1
  93. package/dist/commands/purchase-order-types.js +5 -61
  94. package/dist/commands/purchase-orders.d.ts +0 -1
  95. package/dist/commands/purchase-orders.js +7 -63
  96. package/dist/commands/reports.d.ts +0 -1
  97. package/dist/commands/reports.js +6 -62
  98. package/dist/commands/salary-types.d.ts +0 -1
  99. package/dist/commands/salary-types.js +5 -61
  100. package/dist/commands/sales-invoices.d.ts +0 -1
  101. package/dist/commands/sales-invoices.js +12 -65
  102. package/dist/commands/sales-orders.d.ts +0 -1
  103. package/dist/commands/sales-orders.js +7 -63
  104. package/dist/commands/saved-views.d.ts +0 -1
  105. package/dist/commands/saved-views.js +5 -61
  106. package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
  107. package/dist/commands/scheduled-processor-mappings.js +5 -61
  108. package/dist/commands/schedules.d.ts +0 -1
  109. package/dist/commands/schedules.js +5 -61
  110. package/dist/commands/switch.js +4 -4
  111. package/dist/commands/tag-configurations.d.ts +0 -1
  112. package/dist/commands/tag-configurations.js +9 -64
  113. package/dist/commands/tags.d.ts +0 -1
  114. package/dist/commands/tags.js +5 -61
  115. package/dist/commands/tax-codes.d.ts +0 -1
  116. package/dist/commands/tax-codes.js +5 -61
  117. package/dist/commands/tax-matrix.d.ts +0 -1
  118. package/dist/commands/tax-matrix.js +5 -61
  119. package/dist/commands/tenants.d.ts +0 -1
  120. package/dist/commands/tenants.js +7 -62
  121. package/dist/commands/text-templates.d.ts +0 -1
  122. package/dist/commands/text-templates.js +5 -61
  123. package/dist/commands/tools.d.ts +0 -1
  124. package/dist/commands/tools.js +5 -61
  125. package/dist/commands/vat-declarations.d.ts +1 -1
  126. package/dist/commands/vat-declarations.js +11 -62
  127. package/dist/commands/vendor-invoices.d.ts +0 -1
  128. package/dist/commands/vendor-invoices.js +10 -65
  129. package/dist/commands/webhooks.d.ts +0 -1
  130. package/dist/commands/webhooks.js +5 -61
  131. package/dist/commands/workflows.d.ts +0 -1
  132. package/dist/commands/workflows.js +5 -61
  133. package/dist/lib/api-client.js +3 -6
  134. package/dist/lib/auth/constants.d.ts +11 -0
  135. package/dist/lib/auth/constants.js +11 -0
  136. package/dist/lib/auth/errors.d.ts +17 -0
  137. package/dist/lib/auth/errors.js +22 -0
  138. package/dist/lib/auth/flow.d.ts +27 -0
  139. package/dist/lib/auth/flow.js +163 -0
  140. package/dist/lib/auth/guards.d.ts +5 -0
  141. package/dist/lib/auth/guards.js +14 -0
  142. package/dist/lib/auth/http.d.ts +16 -0
  143. package/dist/lib/auth/http.js +49 -0
  144. package/dist/lib/auth/index.d.ts +49 -0
  145. package/dist/lib/auth/index.js +217 -0
  146. package/dist/lib/auth/keycloak-provider.d.ts +75 -0
  147. package/dist/lib/auth/keycloak-provider.js +215 -0
  148. package/dist/lib/auth/pages.d.ts +25 -0
  149. package/dist/lib/auth/pages.js +123 -0
  150. package/dist/lib/auth/pkce.d.ts +11 -0
  151. package/dist/lib/auth/pkce.js +17 -0
  152. package/dist/lib/auth/tokens.d.ts +54 -0
  153. package/dist/lib/auth/tokens.js +104 -0
  154. package/dist/lib/auth/types.d.ts +57 -0
  155. package/dist/lib/auth/types.js +1 -0
  156. package/dist/lib/auth/validated-url.d.ts +6 -0
  157. package/dist/lib/auth/validated-url.js +53 -0
  158. package/dist/lib/config.d.ts +13 -3
  159. package/dist/lib/config.js +24 -19
  160. package/oclif.manifest.json +440 -71
  161. package/package.json +2 -1
  162. package/dist/lib/auth.d.ts +0 -43
  163. package/dist/lib/auth.js +0 -610
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aderis-erp/cli",
3
3
  "description": "Aderis Live Finance Platform CLI - Manage your Aderis account from the command line",
4
- "version": "1.0.15",
4
+ "version": "1.0.17",
5
5
  "author": "Aderis AG",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -38,6 +38,7 @@
38
38
  "eslint-config-oclif": "^6",
39
39
  "eslint-config-prettier": "^10",
40
40
  "mocha": "^11.7.6",
41
+ "nock": "^14.0.17",
41
42
  "oclif": "^4.23.27",
42
43
  "shx": "^0.4.0",
43
44
  "ts-node": "^10",
@@ -1,43 +0,0 @@
1
- import { type Context, type TokenData } from './config.js';
2
- export declare function login(context: Context, options?: {
3
- prompt?: 'login' | 'none';
4
- }): Promise<TokenData>;
5
- export declare function refreshTokens(contextName: string): Promise<TokenData>;
6
- export declare function getValidToken(contextName: string): Promise<string>;
7
- /**
8
- * Build the auth headers for a context: a bearer token for `oauth2` contexts
9
- * (refreshing it first if needed) or `x-api-key` for `api_key` contexts.
10
- *
11
- * This is the single place that decides how credentials reach the API — callers
12
- * should never read `tokens` or `api_key` off a context to build headers.
13
- */
14
- export declare function getAuthHeaders(contextName: string): Promise<Record<string, string>>;
15
- export declare function testAuth(contextName: string): Promise<{
16
- email: string;
17
- name?: string;
18
- }>;
19
- export interface Tenant {
20
- createdAt?: string;
21
- isDefault?: boolean;
22
- name: string;
23
- tenantId: string;
24
- }
25
- export declare function listTenants(contextName: string): Promise<Tenant[]>;
26
- export declare function getCurrentTenant(contextName: string): Promise<null | {
27
- name?: string;
28
- tenantId: string;
29
- }>;
30
- export declare function switchTenant(contextName: string, tenantId: string): Promise<TokenData>;
31
- /**
32
- * Combined login + tenant switch flow that uses a single browser tab.
33
- *
34
- * Two PKCE rounds run against one local callback server. After the first round
35
- * completes (which gives us tokens we can use to PUT the new active tenant),
36
- * the server responds with HTML that meta-refreshes the same browser tab to a
37
- * second `/oauth/authorize` URL. The second round mints tokens scoped to the
38
- * new active tenant.
39
- *
40
- * Used by `aderis switch` when the user's session is expired AND they want to
41
- * end up on a different tenant — avoids the previous two-separate-tab UX.
42
- */
43
- export declare function loginAndSwitchTenant(context: Context, tenantId: string): Promise<TokenData>;
package/dist/lib/auth.js DELETED
@@ -1,610 +0,0 @@
1
- import axios from 'axios';
2
- import jwt from 'jsonwebtoken';
3
- import * as crypto from 'node:crypto';
4
- import * as http from 'node:http';
5
- import { URL, URLSearchParams } from 'node:url';
6
- import open from 'open';
7
- import { getAuthType, getBaseUrls, getContext, missingCredentialsMessage, saveCachedTenants, saveTokens, } from './config.js';
8
- function buildValidatedUrl(baseUrl, path) {
9
- try {
10
- // Minimal path validation
11
- if (baseUrl.includes('/../') || /\/%2e%2e\//i.test(baseUrl)) {
12
- throw new Error('Invalid path');
13
- }
14
- const url = new URL(baseUrl);
15
- // Protocol + host checks
16
- const allowedDomains = [
17
- 'api.aderis.com', 'api-staging.aderis.com', 'api-dev.aderis.com',
18
- 'auth.aderis.com', 'auth-staging.aderis.com', 'auth-dev.aderis.com',
19
- 'localhost',
20
- ];
21
- if (!allowedDomains.includes(url.hostname)) {
22
- throw new Error(`Invalid host: ${url.hostname}`);
23
- }
24
- if (!['http:', 'https:'].includes(url.protocol)) {
25
- throw new Error('Invalid protocol');
26
- }
27
- // Add path if provided
28
- if (path) {
29
- if (path.includes('/../') || /\/%2e%2e\//i.test(path)) {
30
- throw new Error('Invalid path');
31
- }
32
- // Ensure path starts with / and append to pathname
33
- const cleanPath = path.startsWith('/') ? path : '/' + path;
34
- url.pathname = url.pathname.replace(/\/$/, '') + cleanPath;
35
- }
36
- return url.href;
37
- }
38
- catch {
39
- throw new Error('Invalid URL');
40
- }
41
- }
42
- const CALLBACK_PORT = 8085;
43
- const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/oauth/callback`;
44
- const ADERIS_LOGO_URL = 'https://public.aderis.com/69da47c9-274a-4103-bc3e-d9123fd13fac.png';
45
- const ADERIS_ACCENT_COLOR = '#9687f5';
46
- function renderAuthPage(type, title, message) {
47
- const isSuccess = type === 'success';
48
- const iconColor = isSuccess ? ADERIS_ACCENT_COLOR : '#dc2626';
49
- const icon = isSuccess
50
- ? `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>`
51
- : `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
52
- return `<!DOCTYPE html>
53
- <html lang="en">
54
- <head>
55
- <meta charset="UTF-8">
56
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
57
- <title>${title} - Aderis CLI</title>
58
- <style>
59
- * {
60
- margin: 0;
61
- padding: 0;
62
- box-sizing: border-box;
63
- }
64
- body {
65
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
66
- min-height: 100vh;
67
- display: flex;
68
- align-items: center;
69
- justify-content: center;
70
- background: linear-gradient(135deg, #f8f7ff 0%, #f0eeff 100%);
71
- padding: 20px;
72
- }
73
- .container {
74
- background: white;
75
- border-radius: 16px;
76
- box-shadow: 0 4px 24px rgba(150, 135, 245, 0.15);
77
- padding: 48px;
78
- text-align: center;
79
- max-width: 440px;
80
- width: 100%;
81
- }
82
- .logo {
83
- margin-bottom: 32px;
84
- }
85
- .logo img {
86
- height: 40px;
87
- width: auto;
88
- }
89
- .icon {
90
- margin-bottom: 24px;
91
- }
92
- h1 {
93
- font-size: 24px;
94
- font-weight: 600;
95
- color: #1a1a2e;
96
- margin-bottom: 12px;
97
- }
98
- .message {
99
- font-size: 16px;
100
- color: #6b7280;
101
- line-height: 1.6;
102
- margin-bottom: 8px;
103
- }
104
- .message strong {
105
- color: #1a1a2e;
106
- }
107
- .hint {
108
- font-size: 14px;
109
- color: #9ca3af;
110
- margin-top: 24px;
111
- }
112
- .divider {
113
- height: 1px;
114
- background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
115
- margin: 24px 0;
116
- }
117
- </style>
118
- </head>
119
- <body>
120
- <div class="container">
121
- <div class="logo">
122
- <img src="${ADERIS_LOGO_URL}" alt="Aderis" />
123
- </div>
124
- <div class="icon">
125
- ${icon}
126
- </div>
127
- <h1>${title}</h1>
128
- <p class="message">${message}</p>
129
- <div class="divider"></div>
130
- <p class="hint">You can close this window and return to the CLI.</p>
131
- </div>
132
- </body>
133
- </html>`;
134
- }
135
- /**
136
- * Guard for flows that only exist for OAuth contexts (browser login, token
137
- * refresh, Frontegg tenant switching). API keys are issued per tenant, so there
138
- * is nothing to log into or switch.
139
- */
140
- function requireOAuthContext(context, action) {
141
- if (getAuthType(context) === 'api_key') {
142
- throw new Error(`${action} requires OAuth. Context "${context.name}" authenticates with an API key, which is already scoped to one tenant.`);
143
- }
144
- }
145
- /** `client_id` is optional on Context (API-key contexts have none), so OAuth flows must assert it. */
146
- function requireClientId(context) {
147
- if (!context.client_id) {
148
- throw new Error(`Context "${context.name}" has no OAuth client ID. Recreate it with: aderis config add`);
149
- }
150
- return context.client_id;
151
- }
152
- function generatePKCE() {
153
- const verifier = crypto.randomBytes(32).toString('base64url');
154
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
155
- return { challenge, verifier };
156
- }
157
- function generateState() {
158
- return crypto.randomBytes(16).toString('hex');
159
- }
160
- function buildAuthorizeUrl(context, pkce, state, options = {}) {
161
- const { auth: authUrl } = getBaseUrls(context.environment);
162
- const url = new URL(`${authUrl}/oauth/authorize`);
163
- url.searchParams.set('client_id', requireClientId(context));
164
- url.searchParams.set('redirect_uri', REDIRECT_URI);
165
- url.searchParams.set('response_type', 'code');
166
- url.searchParams.set('state', state);
167
- url.searchParams.set('code_challenge', pkce.challenge);
168
- url.searchParams.set('code_challenge_method', 'S256');
169
- url.searchParams.set('scope', 'openid profile email offline_access');
170
- if (options.prompt)
171
- url.searchParams.set('prompt', options.prompt);
172
- return url.toString();
173
- }
174
- export async function login(context, options = {}) {
175
- requireOAuthContext(context, 'Browser login');
176
- const pkce = generatePKCE();
177
- const state = generateState();
178
- const authorizationUrl = buildAuthorizeUrl(context, pkce, state, options);
179
- return new Promise((resolve, reject) => {
180
- const connections = new Set();
181
- // eslint-disable-next-line prefer-const -- assigned after server creation; closure captures the reference safely
182
- let timeoutId;
183
- const server = http.createServer(async (req, res) => {
184
- const url = new URL(req.url || '', `http://localhost:${CALLBACK_PORT}`);
185
- if (url.pathname !== '/oauth/callback') {
186
- res.writeHead(404);
187
- res.end('Not Found');
188
- return;
189
- }
190
- const code = url.searchParams.get('code');
191
- const returnedState = url.searchParams.get('state');
192
- const error = url.searchParams.get('error');
193
- const errorDescription = url.searchParams.get('error_description');
194
- if (error) {
195
- res.writeHead(400, { 'Content-Type': 'text/html' });
196
- res.end(renderAuthPage('error', 'Authentication Failed', errorDescription || error));
197
- closeServer();
198
- reject(new Error(errorDescription || error));
199
- return;
200
- }
201
- if (!code || returnedState !== state) {
202
- res.writeHead(400, { 'Content-Type': 'text/html' });
203
- res.end(renderAuthPage('error', 'Authentication Failed', 'Invalid response from authorization server.'));
204
- closeServer();
205
- reject(new Error('Invalid authorization response'));
206
- return;
207
- }
208
- try {
209
- // Exchange code for tokens
210
- const tokens = await exchangeCodeForTokens(context, code, pkce.verifier);
211
- res.writeHead(200, { Connection: 'close', 'Content-Type': 'text/html' });
212
- res.end(renderAuthPage('success', 'Authentication Successful', `You have been logged in to <strong>${context.name}</strong> (${context.environment}).`));
213
- // Save tokens immediately
214
- saveTokens(context.name, tokens);
215
- // Use setImmediate to let the response flush, then close
216
- setImmediate(() => {
217
- closeServer();
218
- resolve(tokens);
219
- });
220
- }
221
- catch (error_) {
222
- res.writeHead(500, { 'Content-Type': 'text/html' });
223
- res.end(renderAuthPage('error', 'Authentication Failed', error_ instanceof Error ? error_.message : 'Unknown error'));
224
- closeServer();
225
- reject(error_);
226
- }
227
- });
228
- // Track connections so we can force-close them
229
- server.on('connection', (socket) => {
230
- connections.add(socket);
231
- socket.on('close', () => connections.delete(socket));
232
- });
233
- const closeServer = () => {
234
- // Clear the timeout to allow process to exit
235
- clearTimeout(timeoutId);
236
- // Destroy all open connections
237
- for (const socket of connections) {
238
- socket.destroy();
239
- }
240
- connections.clear();
241
- server.close();
242
- process.off('SIGINT', onSigint);
243
- };
244
- const onSigint = () => {
245
- closeServer();
246
- // Exit with the standard SIGINT code so the parent shell sees the
247
- // expected termination status instead of a hung process. This is a CLI;
248
- // process.exit is the correct way to bail from a SIGINT.
249
- // eslint-disable-next-line unicorn/no-process-exit, n/no-process-exit
250
- process.exit(130);
251
- };
252
- process.once('SIGINT', onSigint);
253
- server.listen(CALLBACK_PORT, () => {
254
- console.log(`Opening browser for authentication...`);
255
- console.log(`If the browser doesn't open, visit: ${authorizationUrl}`);
256
- open(authorizationUrl);
257
- });
258
- server.on('error', (err) => {
259
- closeServer();
260
- reject(new Error(`Failed to start local server: ${err.message}`));
261
- });
262
- // Timeout after 5 minutes
263
- timeoutId = setTimeout(() => {
264
- closeServer();
265
- reject(new Error('Authentication timed out'));
266
- }, 5 * 60 * 1000);
267
- });
268
- }
269
- async function exchangeCodeForTokens(context, code, codeVerifier) {
270
- const { auth: authUrl } = getBaseUrls(context.environment);
271
- const params = new URLSearchParams();
272
- params.set('grant_type', 'authorization_code');
273
- params.set('client_id', requireClientId(context));
274
- params.set('code', code);
275
- params.set('redirect_uri', REDIRECT_URI);
276
- params.set('code_verifier', codeVerifier);
277
- if (context.client_secret) {
278
- params.set('client_secret', context.client_secret);
279
- }
280
- const response = await axios.post(buildValidatedUrl(authUrl, '/oauth/token'), params.toString(), {
281
- headers: {
282
- Accept: 'application/json',
283
- 'Content-Type': 'application/x-www-form-urlencoded',
284
- },
285
- });
286
- const tokens = response.data;
287
- const decoded = jwt.decode(tokens.access_token);
288
- return {
289
- access_token: tokens.access_token,
290
- expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
291
- refresh_token: tokens.refresh_token,
292
- tenant_id: decoded?.tenantId || decoded?.tenant_id,
293
- };
294
- }
295
- export async function refreshTokens(contextName) {
296
- const context = getContext(contextName);
297
- if (!context) {
298
- throw new Error(`Context "${contextName}" not found`);
299
- }
300
- requireOAuthContext(context, 'Token refresh');
301
- if (!context.tokens?.refresh_token) {
302
- throw new Error('No refresh token available. Please login again.');
303
- }
304
- const { auth: authUrl } = getBaseUrls(context.environment);
305
- const params = new URLSearchParams();
306
- params.set('grant_type', 'refresh_token');
307
- params.set('client_id', requireClientId(context));
308
- params.set('refresh_token', context.tokens.refresh_token);
309
- if (context.client_secret) {
310
- params.set('client_secret', context.client_secret);
311
- }
312
- let response;
313
- try {
314
- response = await axios.post(buildValidatedUrl(authUrl, '/oauth/token'), params.toString(), {
315
- headers: {
316
- Accept: 'application/json',
317
- 'Content-Type': 'application/x-www-form-urlencoded',
318
- },
319
- });
320
- }
321
- catch (error) {
322
- if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 400)) {
323
- throw new Error('Session expired. Please run: aderis auth login');
324
- }
325
- throw error;
326
- }
327
- const tokens = response.data;
328
- const decoded = jwt.decode(tokens.access_token);
329
- const tokenData = {
330
- access_token: tokens.access_token,
331
- expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
332
- refresh_token: tokens.refresh_token || context.tokens.refresh_token,
333
- tenant_id: decoded?.tenantId || decoded?.tenant_id,
334
- };
335
- saveTokens(contextName, tokenData);
336
- return tokenData;
337
- }
338
- export async function getValidToken(contextName) {
339
- const context = getContext(contextName);
340
- if (!context) {
341
- throw new Error(`Context "${contextName}" not found`);
342
- }
343
- if (getAuthType(context) === 'api_key') {
344
- throw new Error(`Context "${contextName}" authenticates with an API key and has no access token. Use getAuthHeaders() instead.`);
345
- }
346
- if (!context.tokens?.access_token) {
347
- throw new Error('Not logged in. Please run: aderis auth login');
348
- }
349
- // Check if token is expired (with 60 second buffer)
350
- if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + 60_000) {
351
- if (context.tokens.refresh_token) {
352
- const newTokens = await refreshTokens(contextName);
353
- return newTokens.access_token;
354
- }
355
- throw new Error('Token expired. Please run: aderis auth login');
356
- }
357
- return context.tokens.access_token;
358
- }
359
- /**
360
- * Build the auth headers for a context: a bearer token for `oauth2` contexts
361
- * (refreshing it first if needed) or `x-api-key` for `api_key` contexts.
362
- *
363
- * This is the single place that decides how credentials reach the API — callers
364
- * should never read `tokens` or `api_key` off a context to build headers.
365
- */
366
- export async function getAuthHeaders(contextName) {
367
- const context = getContext(contextName);
368
- if (!context) {
369
- throw new Error(`Context "${contextName}" not found`);
370
- }
371
- if (getAuthType(context) === 'api_key') {
372
- if (!context.api_key) {
373
- throw new Error(missingCredentialsMessage(context));
374
- }
375
- return { 'x-api-key': context.api_key };
376
- }
377
- return { Authorization: `Bearer ${await getValidToken(contextName)}` };
378
- }
379
- export async function testAuth(contextName) {
380
- const context = getContext(contextName);
381
- if (!context) {
382
- throw new Error(`Context "${contextName}" not found`);
383
- }
384
- const { api: apiUrl } = getBaseUrls(context.environment);
385
- const response = await axios.get(buildValidatedUrl(apiUrl, '/v1/auth/me'), {
386
- headers: {
387
- Accept: 'application/json',
388
- ...(await getAuthHeaders(contextName)),
389
- },
390
- });
391
- return response.data;
392
- }
393
- export async function listTenants(contextName) {
394
- const context = getContext(contextName);
395
- if (!context) {
396
- throw new Error(`Context "${contextName}" not found`);
397
- }
398
- requireOAuthContext(context, 'Listing tenants');
399
- const token = await getValidToken(contextName);
400
- const { auth: authUrl } = getBaseUrls(context.environment);
401
- let response;
402
- try {
403
- // Frontegg endpoint to list user's tenants
404
- response = await axios.get(buildValidatedUrl(authUrl, '/identity/resources/users/v2/me/tenants'), {
405
- headers: {
406
- Accept: 'application/json',
407
- Authorization: `Bearer ${token}`,
408
- },
409
- });
410
- }
411
- catch (error) {
412
- if (axios.isAxiosError(error) && error.response?.status === 401) {
413
- throw new Error('Session expired. Please run: aderis auth login');
414
- }
415
- throw error;
416
- }
417
- // Normalize the response - Frontegg may use different field names
418
- const tenants = response.data.map((t) => ({
419
- createdAt: t.createdAt,
420
- isDefault: t.isDefault,
421
- name: t.name || t.tenantName || t.tenantId,
422
- tenantId: t.tenantId,
423
- }));
424
- // Cache for use when the access token is expired (e.g. session-expired branch
425
- // of `aderis switch`, where we want to show the picker before re-authenticating).
426
- saveCachedTenants(contextName, tenants.map((t) => ({ name: t.name, tenantId: t.tenantId })));
427
- return tenants;
428
- }
429
- export async function getCurrentTenant(contextName) {
430
- const context = getContext(contextName);
431
- if (!context) {
432
- throw new Error(`Context "${contextName}" not found`);
433
- }
434
- // API-key contexts carry no JWT, so there is no tenant to decode locally.
435
- if (getAuthType(context) === 'api_key' || !context.tokens?.access_token) {
436
- return null;
437
- }
438
- const decoded = jwt.decode(context.tokens.access_token);
439
- const tenantId = decoded?.tenantId || decoded?.tenant_id;
440
- if (!tenantId) {
441
- return null;
442
- }
443
- // Try to get tenant name from the list
444
- try {
445
- const tenants = await listTenants(contextName);
446
- const tenant = tenants.find((t) => t.tenantId === tenantId);
447
- return {
448
- name: tenant?.name,
449
- tenantId,
450
- };
451
- }
452
- catch {
453
- return { tenantId };
454
- }
455
- }
456
- export async function switchTenant(contextName, tenantId) {
457
- const context = getContext(contextName);
458
- if (!context) {
459
- throw new Error(`Context "${contextName}" not found`);
460
- }
461
- requireOAuthContext(context, 'Switching tenants');
462
- if (!context.tokens?.refresh_token) {
463
- throw new Error('No refresh token available. Please login again.');
464
- }
465
- try {
466
- // Delegate to the two-round PKCE flow. Round 1 issues a token for whatever
467
- // tenant Frontegg's session cookie currently holds (which forces Frontegg
468
- // to refresh its session state), then we PUT the requested tenant, and
469
- // round 2 — reached via meta-refresh on the same browser tab — issues a
470
- // token scoped to the new tenant. No re-authentication is required because
471
- // the user's Frontegg session cookie is still valid.
472
- return await loginAndSwitchTenant(context, tenantId);
473
- }
474
- catch (error) {
475
- if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 400)) {
476
- throw new Error('Session expired. Please run: aderis auth login');
477
- }
478
- throw error;
479
- }
480
- }
481
- /**
482
- * Combined login + tenant switch flow that uses a single browser tab.
483
- *
484
- * Two PKCE rounds run against one local callback server. After the first round
485
- * completes (which gives us tokens we can use to PUT the new active tenant),
486
- * the server responds with HTML that meta-refreshes the same browser tab to a
487
- * second `/oauth/authorize` URL. The second round mints tokens scoped to the
488
- * new active tenant.
489
- *
490
- * Used by `aderis switch` when the user's session is expired AND they want to
491
- * end up on a different tenant — avoids the previous two-separate-tab UX.
492
- */
493
- export async function loginAndSwitchTenant(context, tenantId) {
494
- const { auth: authUrl } = getBaseUrls(context.environment);
495
- const pkce1 = generatePKCE();
496
- const state1 = generateState();
497
- const pkce2 = generatePKCE();
498
- const state2 = generateState();
499
- const authorizeUrl1 = buildAuthorizeUrl(context, pkce1, state1);
500
- const authorizeUrl2 = buildAuthorizeUrl(context, pkce2, state2);
501
- return new Promise((resolve, reject) => {
502
- const connections = new Set();
503
- const timeoutRef = {};
504
- const closeServer = () => {
505
- if (timeoutRef.id)
506
- clearTimeout(timeoutRef.id);
507
- for (const socket of connections) {
508
- socket.destroy();
509
- }
510
- connections.clear();
511
- server.close();
512
- process.off('SIGINT', onSigint);
513
- };
514
- const onSigint = () => {
515
- closeServer();
516
- // eslint-disable-next-line unicorn/no-process-exit, n/no-process-exit
517
- process.exit(130);
518
- };
519
- process.once('SIGINT', onSigint);
520
- const server = http.createServer(async (req, res) => {
521
- const url = new URL(req.url || '', `http://localhost:${CALLBACK_PORT}`);
522
- if (url.pathname !== '/oauth/callback') {
523
- res.writeHead(404);
524
- res.end('Not Found');
525
- return;
526
- }
527
- const code = url.searchParams.get('code');
528
- const returnedState = url.searchParams.get('state');
529
- const error = url.searchParams.get('error');
530
- const errorDescription = url.searchParams.get('error_description');
531
- if (error || !code || !returnedState) {
532
- res.writeHead(400, { 'Content-Type': 'text/html' });
533
- res.end(renderAuthPage('error', 'Authentication Failed', errorDescription || error || 'Invalid response'));
534
- closeServer();
535
- reject(new Error(errorDescription || error || 'Invalid authorization response'));
536
- return;
537
- }
538
- try {
539
- if (returnedState === state1) {
540
- // Round 1: fresh login → tokens for whatever tenant Frontegg considers
541
- // active for this user. PUT to set the requested tenant, then redirect
542
- // the SAME browser tab to authorize URL #2 so Frontegg re-mints tokens.
543
- const tokens1 = await exchangeCodeForTokens(context, code, pkce1.verifier);
544
- saveTokens(context.name, tokens1);
545
- await axios.put(buildValidatedUrl(authUrl, '/identity/resources/users/v1/tenant'), { tenantId }, {
546
- headers: {
547
- Accept: 'application/json',
548
- Authorization: `Bearer ${tokens1.access_token}`,
549
- 'Content-Type': 'application/json',
550
- },
551
- });
552
- res.writeHead(200, { 'Content-Type': 'text/html' });
553
- res.end(`<!DOCTYPE html>
554
- <html><head>
555
- <meta charset="UTF-8">
556
- <meta http-equiv="refresh" content="0; url=${authorizeUrl2}">
557
- <title>Switching tenant…</title>
558
- </head><body><p>Switching tenant…</p></body></html>`);
559
- return;
560
- }
561
- if (returnedState === state2) {
562
- // Round 2: tokens scoped to the new tenant.
563
- const tokens2 = await exchangeCodeForTokens(context, code, pkce2.verifier);
564
- saveTokens(context.name, tokens2);
565
- if (tokens2.tenant_id !== tenantId) {
566
- res.writeHead(400, { 'Content-Type': 'text/html' });
567
- res.end(renderAuthPage('error', 'Tenant Switch Failed', `Frontegg returned a token for tenant ${tokens2.tenant_id} instead of ${tenantId}.`));
568
- closeServer();
569
- reject(new Error(`Tenant switch did not propagate. Frontegg returned tenant ${tokens2.tenant_id} instead of ${tenantId}. Please try again in a few seconds.`));
570
- return;
571
- }
572
- res.writeHead(200, { Connection: 'close', 'Content-Type': 'text/html' });
573
- res.end(renderAuthPage('success', 'Tenant Switched', `You have been logged in to <strong>${context.name}</strong> on the selected tenant.`));
574
- setImmediate(() => {
575
- closeServer();
576
- resolve(tokens2);
577
- });
578
- return;
579
- }
580
- res.writeHead(400, { 'Content-Type': 'text/html' });
581
- res.end(renderAuthPage('error', 'Authentication Failed', 'Invalid state parameter.'));
582
- closeServer();
583
- reject(new Error('Invalid state in callback'));
584
- }
585
- catch (error_) {
586
- res.writeHead(500, { 'Content-Type': 'text/html' });
587
- res.end(renderAuthPage('error', 'Authentication Failed', error_ instanceof Error ? error_.message : 'Unknown error'));
588
- closeServer();
589
- reject(error_);
590
- }
591
- });
592
- server.on('connection', (socket) => {
593
- connections.add(socket);
594
- socket.on('close', () => connections.delete(socket));
595
- });
596
- server.listen(CALLBACK_PORT, () => {
597
- console.log('Opening browser for authentication…');
598
- console.log(`If the browser doesn't open, visit: ${authorizeUrl1}`);
599
- open(authorizeUrl1);
600
- });
601
- server.on('error', (err) => {
602
- closeServer();
603
- reject(new Error(`Failed to start local server: ${err.message}`));
604
- });
605
- timeoutRef.id = setTimeout(() => {
606
- closeServer();
607
- reject(new Error('Authentication timed out'));
608
- }, 5 * 60 * 1000);
609
- });
610
- }