@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.
- package/README.md +30 -8
- package/dist/commands/access-groups.d.ts +0 -1
- package/dist/commands/access-groups.js +5 -61
- package/dist/commands/account-transaction-allocations.d.ts +0 -1
- package/dist/commands/account-transaction-allocations.js +5 -61
- package/dist/commands/account-transactions.d.ts +0 -1
- package/dist/commands/account-transactions.js +9 -63
- package/dist/commands/accounts.d.ts +0 -1
- package/dist/commands/accounts.js +5 -61
- package/dist/commands/agent-platform.d.ts +0 -1
- package/dist/commands/agent-platform.js +5 -61
- package/dist/commands/analytics.d.ts +0 -1
- package/dist/commands/analytics.js +5 -61
- package/dist/commands/api-keys.d.ts +39 -0
- package/dist/commands/api-keys.js +1117 -0
- package/dist/commands/audit-log.d.ts +0 -1
- package/dist/commands/audit-log.js +5 -61
- package/dist/commands/auth/login.d.ts +9 -0
- package/dist/commands/auth/login.js +73 -20
- package/dist/commands/auth/logout.d.ts +4 -0
- package/dist/commands/auth/logout.js +4 -0
- package/dist/commands/auth/status.js +26 -17
- package/dist/commands/auth/tenant.js +3 -6
- package/dist/commands/auth.d.ts +0 -1
- package/dist/commands/auth.js +5 -34
- package/dist/commands/bank-accounts.d.ts +0 -1
- package/dist/commands/bank-accounts.js +5 -61
- package/dist/commands/bank-integrations.d.ts +0 -1
- package/dist/commands/bank-integrations.js +5 -61
- package/dist/commands/business-partners.d.ts +1 -1
- package/dist/commands/business-partners.js +14 -63
- package/dist/commands/chat.d.ts +0 -1
- package/dist/commands/chat.js +5 -61
- package/dist/commands/config/add.d.ts +3 -0
- package/dist/commands/config/add.js +34 -16
- package/dist/commands/config/current.js +3 -0
- package/dist/commands/config/list.js +3 -0
- package/dist/commands/credit-card-accounts.d.ts +0 -1
- package/dist/commands/credit-card-accounts.js +5 -61
- package/dist/commands/delivery-notes.d.ts +0 -1
- package/dist/commands/delivery-notes.js +7 -63
- package/dist/commands/dunning-configs.d.ts +0 -1
- package/dist/commands/dunning-configs.js +5 -61
- package/dist/commands/dunning-reminders.d.ts +0 -1
- package/dist/commands/dunning-reminders.js +5 -61
- package/dist/commands/dunning-runs.d.ts +0 -1
- package/dist/commands/dunning-runs.js +5 -61
- package/dist/commands/email-transactions.d.ts +0 -1
- package/dist/commands/email-transactions.js +5 -61
- package/dist/commands/employees.d.ts +0 -1
- package/dist/commands/employees.js +5 -61
- package/dist/commands/entity-configurations.d.ts +0 -1
- package/dist/commands/entity-configurations.js +5 -61
- package/dist/commands/event-processor-mappings.d.ts +0 -1
- package/dist/commands/event-processor-mappings.js +5 -61
- package/dist/commands/files.d.ts +0 -1
- package/dist/commands/files.js +5 -61
- package/dist/commands/health.d.ts +0 -1
- package/dist/commands/health.js +5 -61
- package/dist/commands/inbox.d.ts +0 -1
- package/dist/commands/inbox.js +5 -61
- package/dist/commands/integrations.d.ts +0 -1
- package/dist/commands/integrations.js +5 -61
- package/dist/commands/item-categories.d.ts +0 -1
- package/dist/commands/item-categories.js +5 -61
- package/dist/commands/items.d.ts +0 -1
- package/dist/commands/items.js +5 -61
- package/dist/commands/journal-lines.d.ts +0 -1
- package/dist/commands/journal-lines.js +5 -61
- package/dist/commands/journals.d.ts +1 -1
- package/dist/commands/journals.js +9 -62
- package/dist/commands/ledgers.d.ts +0 -1
- package/dist/commands/ledgers.js +5 -61
- package/dist/commands/nango-integrations.d.ts +39 -0
- package/dist/commands/nango-integrations.js +1115 -0
- package/dist/commands/office-locations.d.ts +0 -1
- package/dist/commands/office-locations.js +5 -61
- package/dist/commands/payment-methods.d.ts +0 -1
- package/dist/commands/payment-methods.js +5 -61
- package/dist/commands/payment-runs.d.ts +0 -1
- package/dist/commands/payment-runs.js +5 -61
- package/dist/commands/payment-terms.d.ts +0 -1
- package/dist/commands/payment-terms.js +5 -61
- package/dist/commands/payments.d.ts +0 -1
- package/dist/commands/payments.js +5 -61
- package/dist/commands/price-lists.d.ts +0 -1
- package/dist/commands/price-lists.js +5 -61
- package/dist/commands/processor-mappings.d.ts +0 -1
- package/dist/commands/processor-mappings.js +5 -61
- package/dist/commands/processors.d.ts +0 -1
- package/dist/commands/processors.js +5 -61
- package/dist/commands/purchase-order-types.d.ts +0 -1
- package/dist/commands/purchase-order-types.js +5 -61
- package/dist/commands/purchase-orders.d.ts +0 -1
- package/dist/commands/purchase-orders.js +7 -63
- package/dist/commands/reports.d.ts +0 -1
- package/dist/commands/reports.js +6 -62
- package/dist/commands/salary-types.d.ts +0 -1
- package/dist/commands/salary-types.js +5 -61
- package/dist/commands/sales-invoices.d.ts +0 -1
- package/dist/commands/sales-invoices.js +12 -65
- package/dist/commands/sales-orders.d.ts +0 -1
- package/dist/commands/sales-orders.js +7 -63
- package/dist/commands/saved-views.d.ts +0 -1
- package/dist/commands/saved-views.js +5 -61
- package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
- package/dist/commands/scheduled-processor-mappings.js +5 -61
- package/dist/commands/schedules.d.ts +0 -1
- package/dist/commands/schedules.js +5 -61
- package/dist/commands/switch.js +4 -4
- package/dist/commands/tag-configurations.d.ts +0 -1
- package/dist/commands/tag-configurations.js +9 -64
- package/dist/commands/tags.d.ts +0 -1
- package/dist/commands/tags.js +5 -61
- package/dist/commands/tax-codes.d.ts +0 -1
- package/dist/commands/tax-codes.js +5 -61
- package/dist/commands/tax-matrix.d.ts +0 -1
- package/dist/commands/tax-matrix.js +5 -61
- package/dist/commands/tenants.d.ts +0 -1
- package/dist/commands/tenants.js +7 -62
- package/dist/commands/text-templates.d.ts +0 -1
- package/dist/commands/text-templates.js +5 -61
- package/dist/commands/tools.d.ts +0 -1
- package/dist/commands/tools.js +5 -61
- package/dist/commands/vat-declarations.d.ts +1 -1
- package/dist/commands/vat-declarations.js +11 -62
- package/dist/commands/vendor-invoices.d.ts +0 -1
- package/dist/commands/vendor-invoices.js +10 -65
- package/dist/commands/webhooks.d.ts +0 -1
- package/dist/commands/webhooks.js +5 -61
- package/dist/commands/workflows.d.ts +0 -1
- package/dist/commands/workflows.js +5 -61
- package/dist/lib/api-client.js +3 -6
- package/dist/lib/auth/constants.d.ts +11 -0
- package/dist/lib/auth/constants.js +11 -0
- package/dist/lib/auth/errors.d.ts +17 -0
- package/dist/lib/auth/errors.js +22 -0
- package/dist/lib/auth/flow.d.ts +27 -0
- package/dist/lib/auth/flow.js +163 -0
- package/dist/lib/auth/guards.d.ts +5 -0
- package/dist/lib/auth/guards.js +14 -0
- package/dist/lib/auth/http.d.ts +16 -0
- package/dist/lib/auth/http.js +49 -0
- package/dist/lib/auth/index.d.ts +49 -0
- package/dist/lib/auth/index.js +217 -0
- package/dist/lib/auth/keycloak-provider.d.ts +75 -0
- package/dist/lib/auth/keycloak-provider.js +215 -0
- package/dist/lib/auth/pages.d.ts +25 -0
- package/dist/lib/auth/pages.js +123 -0
- package/dist/lib/auth/pkce.d.ts +11 -0
- package/dist/lib/auth/pkce.js +17 -0
- package/dist/lib/auth/tokens.d.ts +54 -0
- package/dist/lib/auth/tokens.js +104 -0
- package/dist/lib/auth/types.d.ts +57 -0
- package/dist/lib/auth/types.js +1 -0
- package/dist/lib/auth/validated-url.d.ts +6 -0
- package/dist/lib/auth/validated-url.js +53 -0
- package/dist/lib/config.d.ts +13 -3
- package/dist/lib/config.js +24 -19
- package/oclif.manifest.json +440 -71
- package/package.json +2 -1
- package/dist/lib/auth.d.ts +0 -43
- 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.
|
|
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",
|
package/dist/lib/auth.d.ts
DELETED
|
@@ -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
|
-
}
|