@inferdat/crm-auth 0.0.0-stage → 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 +282 -2
- package/dist/api.d.ts +6 -0
- package/dist/api.js +31 -0
- package/dist/commands/assume.d.ts +7 -0
- package/dist/commands/assume.js +206 -0
- package/dist/commands/console.d.ts +4 -0
- package/dist/commands/console.js +120 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +43 -0
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.js +154 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +10 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +33 -0
- package/dist/commands/workspace.d.ts +8 -0
- package/dist/commands/workspace.js +30 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +129 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +101 -0
- package/package.json +30 -4
package/README.md
CHANGED
|
@@ -1,3 +1,283 @@
|
|
|
1
|
-
#
|
|
1
|
+
# crm-auth CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line tool for assuming cross-account AWS roles through the CRM. Get temporary AWS credentials or open the AWS Console for any customer account directly from your terminal.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **Node.js 18+** (check with `node -v`)
|
|
8
|
+
- A CRM account with `aws-access:assume` permission (Admin, Manager, or Sales Rep)
|
|
9
|
+
- The customer's AWS account must have ACTIVE access status in the CRM (verified via "Verify Access")
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @inferdat/crm-auth
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node.js 18 or later. To check: `node -v`.
|
|
18
|
+
|
|
19
|
+
To update to the latest version:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm update -g @inferdat/crm-auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Building from source (contributors only)
|
|
26
|
+
|
|
27
|
+
If you're working on the CLI itself:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd cli
|
|
31
|
+
npm install
|
|
32
|
+
npm run build
|
|
33
|
+
npm link # makes `crm-auth` available globally
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 1. Authenticate (opens browser)
|
|
40
|
+
crm-auth login --url https://portal.inferdat.ai
|
|
41
|
+
|
|
42
|
+
# 2. See what accounts are available
|
|
43
|
+
crm-auth list
|
|
44
|
+
|
|
45
|
+
# 3. Get credentials for a customer account
|
|
46
|
+
crm-auth assume acme-corp-production
|
|
47
|
+
|
|
48
|
+
# 4. Use them
|
|
49
|
+
aws s3 ls --profile acme-corp-production
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
### `crm-auth login --url <CRM_URL>`
|
|
55
|
+
|
|
56
|
+
Authenticates you with the CRM. Opens your browser to the login page, you sign in, click "Authorize CLI", and the token is saved locally.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
crm-auth login --url https://portal.inferdat.ai
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The `--url` flag is required on first login. After that, the URL is saved in your config.
|
|
63
|
+
|
|
64
|
+
**Multi-workspace support:** If your account has access to multiple workspaces, the CLI prompts you to choose one. All workspaces are saved and you can switch between them without re-authenticating.
|
|
65
|
+
|
|
66
|
+
**What happens:**
|
|
67
|
+
1. CLI starts a temporary HTTP server on localhost
|
|
68
|
+
2. Opens your browser to `{CRM_URL}/auth/cli`
|
|
69
|
+
3. You log in and click "Authorize CLI"
|
|
70
|
+
4. Token is saved to `~/.crm-auth/config.json` (file permissions: 600)
|
|
71
|
+
|
|
72
|
+
Token is valid for 90 days.
|
|
73
|
+
|
|
74
|
+
### `crm-auth list`
|
|
75
|
+
|
|
76
|
+
Shows all ACTIVE customer AWS accounts you can access.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Available AWS Accounts (3)
|
|
80
|
+
|
|
81
|
+
Acme Corp
|
|
82
|
+
761018891308 (Production) [ReadOnly, Administrator] verified 7/11/2026
|
|
83
|
+
slug: acme-corp-production
|
|
84
|
+
crm-auth assume acme-corp-production
|
|
85
|
+
|
|
86
|
+
Globex Inc
|
|
87
|
+
112233445566 [ReadOnly] verified 7/10/2026
|
|
88
|
+
slug: globex-inc-112233445566
|
|
89
|
+
crm-auth assume globex-inc-112233445566
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The **slug** is the stable identifier you pass to `assume` and `console` commands. You can also use the 12-digit AWS account ID directly.
|
|
93
|
+
|
|
94
|
+
### `crm-auth assume <slug>`
|
|
95
|
+
|
|
96
|
+
Assumes a cross-account role and writes temporary credentials to `~/.aws/credentials` as a named profile.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# If only one role available — assumes it automatically
|
|
100
|
+
crm-auth assume acme-corp-production
|
|
101
|
+
|
|
102
|
+
# If multiple roles — prompts you to choose:
|
|
103
|
+
# Multiple roles available:
|
|
104
|
+
# 1. ReadOnly
|
|
105
|
+
# 2. Administrator
|
|
106
|
+
# Select role [1-2]:
|
|
107
|
+
|
|
108
|
+
# Specify role explicitly (by short name)
|
|
109
|
+
crm-auth assume acme-corp-production --role ReadOnly
|
|
110
|
+
|
|
111
|
+
# Custom session duration (in seconds, max 12 hours = 43200)
|
|
112
|
+
crm-auth assume acme-corp-production --role Administrator --duration 3600
|
|
113
|
+
|
|
114
|
+
# Output as env vars instead (for eval or piping)
|
|
115
|
+
crm-auth assume acme-corp-production --env
|
|
116
|
+
|
|
117
|
+
# Just print credentials, don't write to file
|
|
118
|
+
crm-auth assume acme-corp-production --no-profile
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
After running, use the profile:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
aws s3 ls --profile acme-corp-production
|
|
125
|
+
aws ec2 describe-instances --profile acme-corp-production --region us-east-1
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Or with `--env`:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
eval $(crm-auth assume acme-corp-production --env)
|
|
132
|
+
aws s3 ls # uses env vars directly
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Options:**
|
|
136
|
+
|
|
137
|
+
| Flag | Description | Default |
|
|
138
|
+
|------|-------------|---------|
|
|
139
|
+
| `-r, --role <name>` | Role short name (e.g. `ReadOnly`, `Administrator`) | Prompts if multiple |
|
|
140
|
+
| `-d, --duration <seconds>` | Session duration (900–43200) | Role's configured max |
|
|
141
|
+
| `--env` | Output `export` statements instead of writing profile | off |
|
|
142
|
+
| `--json` | Output credentials in AWS `credential_process` JSON format | off |
|
|
143
|
+
| `--no-profile` | Print raw credentials without writing to file | off |
|
|
144
|
+
|
|
145
|
+
**Using with `~/.aws/config` (`credential_process`)**
|
|
146
|
+
|
|
147
|
+
The `--json` flag outputs credentials in the exact format AWS expects for [`credential_process`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html), so AWS tools automatically refresh credentials without any wrapper script:
|
|
148
|
+
|
|
149
|
+
```ini
|
|
150
|
+
# ~/.aws/config
|
|
151
|
+
[profile acme-corp-production]
|
|
152
|
+
credential_process = crm-auth assume acme-corp-production --role ReadOnly --json
|
|
153
|
+
region = us-east-1
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Then use any AWS tool normally:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
aws s3 ls --profile acme-corp-production
|
|
160
|
+
terraform plan # if AWS_PROFILE=acme-corp-production is set
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Credentials are fetched on demand and refreshed automatically when they expire.
|
|
164
|
+
|
|
165
|
+
### `crm-auth console <slug>`
|
|
166
|
+
|
|
167
|
+
Opens the AWS Console in your browser for a customer account. No password needed — uses federation URL.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
crm-auth console acme-corp-production
|
|
171
|
+
crm-auth console acme-corp-production --role Administrator
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Same role selection behavior as `assume`: prompts if multiple roles are available and no `--role` flag is given.
|
|
175
|
+
|
|
176
|
+
**Note:** This opens a new AWS Console session. If you already have a different AWS session in the same browser, it will be replaced. Use a different browser profile or incognito window if you need both.
|
|
177
|
+
|
|
178
|
+
### `crm-auth status`
|
|
179
|
+
|
|
180
|
+
Shows your current auth status.
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
CRM CLI Status
|
|
184
|
+
|
|
185
|
+
CRM URL: https://portal.inferdat.ai
|
|
186
|
+
User: sujaiy@inferdat.ai
|
|
187
|
+
Role: Admin
|
|
188
|
+
Token: valid (expires 10/9/2026)
|
|
189
|
+
Workspace: inferdat (active)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### `crm-auth workspace list`
|
|
193
|
+
|
|
194
|
+
Shows all stored workspaces.
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Workspaces:
|
|
198
|
+
|
|
199
|
+
* inferdat (Inferdat)
|
|
200
|
+
acme-partner (Acme Partner Portal)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `crm-auth workspace switch <slug>`
|
|
204
|
+
|
|
205
|
+
Switches the active workspace. All subsequent commands operate against the selected workspace.
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
crm-auth workspace switch acme-partner
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `crm-auth logout`
|
|
212
|
+
|
|
213
|
+
Clears the locally stored token.
|
|
214
|
+
|
|
215
|
+
## Where Credentials Are Stored
|
|
216
|
+
|
|
217
|
+
| File | Purpose |
|
|
218
|
+
|------|---------|
|
|
219
|
+
| `~/.crm-auth/config.json` | CRM URL, API token, workspace info. Permissions: `0600` |
|
|
220
|
+
| `~/.aws/credentials` | AWS profiles written by `assume`. Permissions: `0600` |
|
|
221
|
+
|
|
222
|
+
## Roles and Permissions
|
|
223
|
+
|
|
224
|
+
You need these CRM permissions to use the CLI:
|
|
225
|
+
|
|
226
|
+
| CRM Role | Can list accounts | Can assume roles | Can open console |
|
|
227
|
+
|----------|-------------------|------------------|------------------|
|
|
228
|
+
| Admin | ✓ | ✓ | ✓ |
|
|
229
|
+
| Manager | ✓ | ✓ | ✓ |
|
|
230
|
+
| Sales Rep | ✓ | ✓ | ✓ |
|
|
231
|
+
| Read-Only | ✓ | ✗ | ✗ |
|
|
232
|
+
|
|
233
|
+
The AWS role you assume (e.g. `ReadOnly` vs `Administrator`) determines what you can do inside the customer's account. These roles are configured by your admin in Access Settings.
|
|
234
|
+
|
|
235
|
+
## Role Selection
|
|
236
|
+
|
|
237
|
+
When you have access to multiple roles on an account:
|
|
238
|
+
|
|
239
|
+
- **With `--role` flag**: The CLI resolves the role by short name (case-insensitive). `--role readonly` matches `inferdat-ReadOnly`.
|
|
240
|
+
- **Without `--role` flag**: If only one role is available, it's used automatically. If multiple roles are available, the CLI prompts you to choose.
|
|
241
|
+
|
|
242
|
+
This prevents accidentally assuming a higher-privilege role when you meant to use read-only access.
|
|
243
|
+
|
|
244
|
+
## Account Slugs
|
|
245
|
+
|
|
246
|
+
Each customer account has a stable slug generated by the server:
|
|
247
|
+
- Format: `{company-name}-{account-name}` (e.g., `acme-corp-production`)
|
|
248
|
+
- If no account name: `{company-name}-{aws-account-id}` (e.g., `globex-inc-112233445566`)
|
|
249
|
+
- Run `crm-auth list` to see all slugs
|
|
250
|
+
- You can also use the raw 12-digit AWS account ID instead of the slug
|
|
251
|
+
|
|
252
|
+
## Multi-Workspace (Multi-Tenant)
|
|
253
|
+
|
|
254
|
+
If you're on a multi-tenant platform:
|
|
255
|
+
1. `crm-auth login` shows all workspaces you belong to and prompts you to pick one
|
|
256
|
+
2. All commands operate against the active workspace
|
|
257
|
+
3. `crm-auth workspace switch <slug>` changes context without re-authenticating
|
|
258
|
+
4. Each workspace has its own set of customer accounts
|
|
259
|
+
|
|
260
|
+
## Troubleshooting
|
|
261
|
+
|
|
262
|
+
**"Not logged in" / "Token expired"**
|
|
263
|
+
```bash
|
|
264
|
+
crm-auth login --url https://portal.inferdat.ai
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**"Permission denied"**
|
|
268
|
+
Your CRM role doesn't have `aws-access:assume`. Contact your admin.
|
|
269
|
+
|
|
270
|
+
**"No active account found"**
|
|
271
|
+
The slug doesn't match any ACTIVE account. Run `crm-auth list` to see available slugs. The account might still be in PENDING status (needs verification in the CRM UI first).
|
|
272
|
+
|
|
273
|
+
**"You don't have access to any roles"**
|
|
274
|
+
Your admin has restricted access to this account. Contact them to be added to the authorization list.
|
|
275
|
+
|
|
276
|
+
**"Account is frozen"**
|
|
277
|
+
An admin froze access to this account. It won't appear in `assume`/`console` commands until unfrozen.
|
|
278
|
+
|
|
279
|
+
**Login callback not working**
|
|
280
|
+
The CLI starts a localhost server for the OAuth callback. If it times out after 5 minutes, check that nothing is blocking localhost connections. The CLI prints the auth URL so you can copy it manually.
|
|
281
|
+
|
|
282
|
+
**Multiple workspaces**
|
|
283
|
+
If you work across multiple workspaces, use `crm-auth workspace switch <slug>` to change context. Run `crm-auth workspace list` to see all options.
|
package/dist/api.d.ts
ADDED
package/dist/api.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = "ApiError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function api(config, path, options = {}) {
|
|
10
|
+
const url = `${config.url}${path}`;
|
|
11
|
+
const headers = {
|
|
12
|
+
Authorization: `Bearer ${config.token}`,
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
...options.headers,
|
|
15
|
+
};
|
|
16
|
+
// In multi-tenant mode, include the active workspace slug
|
|
17
|
+
if (config.activeWorkspace) {
|
|
18
|
+
headers["X-Tenant-Slug"] = config.activeWorkspace;
|
|
19
|
+
}
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
...options,
|
|
22
|
+
headers,
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const body = await res.json().catch(() => ({}));
|
|
26
|
+
const msg = body.error || `HTTP ${res.status}`;
|
|
27
|
+
throw new ApiError(msg, res.status);
|
|
28
|
+
}
|
|
29
|
+
const json = (await res.json());
|
|
30
|
+
return json.data;
|
|
31
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { requireConfig } from "../config.js";
|
|
6
|
+
import { api } from "../api.js";
|
|
7
|
+
/**
|
|
8
|
+
* Find a matching account by server-generated slug or AWS account ID.
|
|
9
|
+
*/
|
|
10
|
+
function findAccount(accounts, target) {
|
|
11
|
+
// Direct AWS account ID match (12 digits)
|
|
12
|
+
const byId = accounts.find((a) => a.awsAccountId === target);
|
|
13
|
+
if (byId)
|
|
14
|
+
return byId;
|
|
15
|
+
// Exact slug match (authoritative, from server)
|
|
16
|
+
const bySlug = accounts.find((a) => a.slug === target);
|
|
17
|
+
if (bySlug)
|
|
18
|
+
return bySlug;
|
|
19
|
+
// Prefix match on slug (for convenience — "acme" matches "acme-production")
|
|
20
|
+
const prefixMatches = accounts.filter((a) => a.slug.startsWith(target + "-") || a.slug === target);
|
|
21
|
+
if (prefixMatches.length === 1)
|
|
22
|
+
return prefixMatches[0];
|
|
23
|
+
if (prefixMatches.length > 1) {
|
|
24
|
+
console.error(`Multiple accounts match "${target}". Be more specific:\n`);
|
|
25
|
+
for (const a of prefixMatches) {
|
|
26
|
+
const name = a.accountName ? ` (${a.accountName})` : "";
|
|
27
|
+
console.error(` ${a.slug} → ${a.awsAccountId}${name}`);
|
|
28
|
+
}
|
|
29
|
+
console.error(`\nUsage: crm-auth assume ${prefixMatches[0].slug}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a role name from the --role flag.
|
|
36
|
+
* Accepts: short name (ReadOnly), full name (inferdat-ReadOnly), or partial match.
|
|
37
|
+
* If no flag provided and multiple roles available, prompts the user to choose
|
|
38
|
+
* (only when interactive is true). When interactive is false, uses first role silently.
|
|
39
|
+
*/
|
|
40
|
+
async function resolveRoleName(account, roleFlag, interactive) {
|
|
41
|
+
const allowedRoles = account.userAllowedRoles;
|
|
42
|
+
if (allowedRoles.length === 0) {
|
|
43
|
+
console.error("You don't have access to any roles in this account.");
|
|
44
|
+
console.error("Contact your admin to request access.");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
// If --role flag provided, resolve it
|
|
48
|
+
if (roleFlag) {
|
|
49
|
+
// Exact full name match
|
|
50
|
+
const exactMatch = allowedRoles.find((r) => r === roleFlag);
|
|
51
|
+
if (exactMatch)
|
|
52
|
+
return exactMatch;
|
|
53
|
+
// Case-insensitive short name match (strip prefix)
|
|
54
|
+
const shortMatch = allowedRoles.find((r) => {
|
|
55
|
+
const shortName = r.split("-").slice(1).join("-");
|
|
56
|
+
return shortName.toLowerCase() === roleFlag.toLowerCase();
|
|
57
|
+
});
|
|
58
|
+
if (shortMatch)
|
|
59
|
+
return shortMatch;
|
|
60
|
+
// Partial match
|
|
61
|
+
const partialMatch = allowedRoles.find((r) => r.toLowerCase().includes(roleFlag.toLowerCase()));
|
|
62
|
+
if (partialMatch)
|
|
63
|
+
return partialMatch;
|
|
64
|
+
console.error(`Role "${roleFlag}" not found. Available roles:`);
|
|
65
|
+
for (const r of allowedRoles) {
|
|
66
|
+
const shortName = r.split("-").slice(1).join("-");
|
|
67
|
+
console.error(` --role ${shortName}`);
|
|
68
|
+
}
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
// No --role flag: if only one role, use it silently
|
|
72
|
+
if (allowedRoles.length === 1) {
|
|
73
|
+
return allowedRoles[0];
|
|
74
|
+
}
|
|
75
|
+
// Multiple roles available but non-interactive — use first role silently
|
|
76
|
+
if (!interactive) {
|
|
77
|
+
const shortName = allowedRoles[0].split("-").slice(1).join("-") || allowedRoles[0];
|
|
78
|
+
process.stderr.write(`(Using first available role: ${shortName}. Specify --role to choose.)\n`);
|
|
79
|
+
return allowedRoles[0];
|
|
80
|
+
}
|
|
81
|
+
// Multiple roles available — prompt user to choose
|
|
82
|
+
console.log("\nMultiple roles available:\n");
|
|
83
|
+
for (let i = 0; i < allowedRoles.length; i++) {
|
|
84
|
+
const shortName = allowedRoles[i].split("-").slice(1).join("-") || allowedRoles[i];
|
|
85
|
+
const desc = account.userAllowedRoleDescriptions?.find((d) => d.roleName === allowedRoles[i])?.description;
|
|
86
|
+
if (desc) {
|
|
87
|
+
console.log(` ${i + 1}. ${shortName} — ${desc}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.log(` ${i + 1}. ${shortName}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
94
|
+
const answer = await new Promise((resolve) => {
|
|
95
|
+
rl.question(`\nSelect role [1-${allowedRoles.length}]: `, resolve);
|
|
96
|
+
});
|
|
97
|
+
rl.close();
|
|
98
|
+
const idx = parseInt(answer, 10) - 1;
|
|
99
|
+
if (isNaN(idx) || idx < 0 || idx >= allowedRoles.length) {
|
|
100
|
+
console.log("Invalid selection, using first role.");
|
|
101
|
+
return allowedRoles[0];
|
|
102
|
+
}
|
|
103
|
+
return allowedRoles[idx];
|
|
104
|
+
}
|
|
105
|
+
export async function assume(target, options) {
|
|
106
|
+
// Mutual exclusion: --json and --env can't both be used
|
|
107
|
+
if (options.json && options.env) {
|
|
108
|
+
console.error("Cannot use --json and --env together. Choose one output format.");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const config = requireConfig();
|
|
112
|
+
// Fetch accounts the user can access
|
|
113
|
+
const result = await api(config, "/api/aws/access-accounts?status=ACTIVE&limit=100");
|
|
114
|
+
const accounts = result.accounts.filter((a) => a.userCanAssume && !a.isFrozen);
|
|
115
|
+
if (accounts.length === 0) {
|
|
116
|
+
console.error("No accessible AWS accounts found.");
|
|
117
|
+
console.error("Run: crm-auth list");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
const account = findAccount(accounts, target);
|
|
121
|
+
if (!account) {
|
|
122
|
+
console.error(`No active account found for "${target}".`);
|
|
123
|
+
console.error("Run: crm-auth list");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// Non-interactive when --env or --json is used (output is likely piped/eval'd)
|
|
127
|
+
const interactive = !options.env && !options.json;
|
|
128
|
+
const roleName = await resolveRoleName(account, options.role, interactive);
|
|
129
|
+
const durationSeconds = options.duration ? parseInt(options.duration, 10) : undefined;
|
|
130
|
+
const creds = await api(config, "/api/aws/assume-role", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
companyAwsAccountId: account.id,
|
|
134
|
+
accessType: "cli",
|
|
135
|
+
roleName,
|
|
136
|
+
...(durationSeconds && { sessionDurationSeconds: durationSeconds }),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
// --json: output credentials in AWS credential_process format (RFC spec)
|
|
140
|
+
// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html
|
|
141
|
+
// AWS requires: Version (int), AccessKeyId, SecretAccessKey, SessionToken, Expiration — all PascalCase.
|
|
142
|
+
// Errors must go to stderr; exit 1 on failure (handled by caller).
|
|
143
|
+
if (options.json) {
|
|
144
|
+
const output = JSON.stringify({
|
|
145
|
+
Version: 1,
|
|
146
|
+
AccessKeyId: creds.accessKeyId,
|
|
147
|
+
SecretAccessKey: creds.secretAccessKey,
|
|
148
|
+
SessionToken: creds.sessionToken,
|
|
149
|
+
Expiration: creds.expiration,
|
|
150
|
+
});
|
|
151
|
+
process.stdout.write(output + "\n");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// --env: output export statements for eval
|
|
155
|
+
if (options.env) {
|
|
156
|
+
console.log(creds.envSnippet);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// --no-profile: just print raw credentials
|
|
160
|
+
if (options.profile === false) {
|
|
161
|
+
console.log(`AWS_ACCESS_KEY_ID=${creds.accessKeyId}`);
|
|
162
|
+
console.log(`AWS_SECRET_ACCESS_KEY=${creds.secretAccessKey}`);
|
|
163
|
+
console.log(`AWS_SESSION_TOKEN=${creds.sessionToken}`);
|
|
164
|
+
console.log(`# Expires: ${creds.expiration}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Default: write to ~/.aws/credentials as a named profile
|
|
168
|
+
const awsDir = path.join(os.homedir(), ".aws");
|
|
169
|
+
const credFile = path.join(awsDir, "credentials");
|
|
170
|
+
const profileName = creds.profileName;
|
|
171
|
+
if (!fs.existsSync(awsDir)) {
|
|
172
|
+
fs.mkdirSync(awsDir, { recursive: true, mode: 0o700 });
|
|
173
|
+
}
|
|
174
|
+
let content = "";
|
|
175
|
+
if (fs.existsSync(credFile)) {
|
|
176
|
+
content = fs.readFileSync(credFile, "utf-8");
|
|
177
|
+
}
|
|
178
|
+
// Remove existing profile section if present
|
|
179
|
+
const profileRegex = new RegExp(`\\[${escapeRegex(profileName)}\\][^\\[]*`, "g");
|
|
180
|
+
content = content.replace(profileRegex, "").trim();
|
|
181
|
+
// Append new profile
|
|
182
|
+
const accountLabel = account.accountName
|
|
183
|
+
? `${account.accountName} (${account.awsAccountId})`
|
|
184
|
+
: account.awsAccountId;
|
|
185
|
+
const shortRole = roleName.split("-").slice(1).join("-") || roleName;
|
|
186
|
+
const profileBlock = [
|
|
187
|
+
"",
|
|
188
|
+
`[${profileName}]`,
|
|
189
|
+
`aws_access_key_id = ${creds.accessKeyId}`,
|
|
190
|
+
`aws_secret_access_key = ${creds.secretAccessKey}`,
|
|
191
|
+
`aws_session_token = ${creds.sessionToken}`,
|
|
192
|
+
`# crm-auth: ${shortRole} for ${account.company.name} / ${accountLabel}`,
|
|
193
|
+
`# expires ${creds.expiration}`,
|
|
194
|
+
"",
|
|
195
|
+
].join("\n");
|
|
196
|
+
content = content + "\n" + profileBlock;
|
|
197
|
+
fs.writeFileSync(credFile, content.trim() + "\n", { mode: 0o600 });
|
|
198
|
+
console.log(`\n Credentials written to ~/.aws/credentials [${profileName}]`);
|
|
199
|
+
console.log(` Account: ${account.company.name} / ${accountLabel}`);
|
|
200
|
+
console.log(` Role: ${shortRole}`);
|
|
201
|
+
console.log(` Expires: ${new Date(creds.expiration).toLocaleString()}`);
|
|
202
|
+
console.log(`\n Usage: aws s3 ls --profile ${profileName}`);
|
|
203
|
+
}
|
|
204
|
+
function escapeRegex(s) {
|
|
205
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
206
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import { requireConfig } from "../config.js";
|
|
4
|
+
import { api } from "../api.js";
|
|
5
|
+
/**
|
|
6
|
+
* Find a matching account by server-generated slug or AWS account ID.
|
|
7
|
+
*/
|
|
8
|
+
function findAccount(accounts, target) {
|
|
9
|
+
const byId = accounts.find((a) => a.awsAccountId === target);
|
|
10
|
+
if (byId)
|
|
11
|
+
return byId;
|
|
12
|
+
const bySlug = accounts.find((a) => a.slug === target);
|
|
13
|
+
if (bySlug)
|
|
14
|
+
return bySlug;
|
|
15
|
+
const prefixMatches = accounts.filter((a) => a.slug.startsWith(target + "-") || a.slug === target);
|
|
16
|
+
if (prefixMatches.length === 1)
|
|
17
|
+
return prefixMatches[0];
|
|
18
|
+
if (prefixMatches.length > 1) {
|
|
19
|
+
console.error(`Multiple accounts match "${target}". Be more specific:\n`);
|
|
20
|
+
for (const a of prefixMatches) {
|
|
21
|
+
const name = a.accountName ? ` (${a.accountName})` : "";
|
|
22
|
+
console.error(` ${a.slug} → ${a.awsAccountId}${name}`);
|
|
23
|
+
}
|
|
24
|
+
console.error(`\nUsage: crm-auth console ${prefixMatches[0].slug}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a role name from the --role flag or prompt user.
|
|
31
|
+
* When stdout is not a TTY (piped/scripted), skips prompting and uses first role.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveRoleName(account, roleFlag) {
|
|
34
|
+
const allowedRoles = account.userAllowedRoles;
|
|
35
|
+
if (allowedRoles.length === 0) {
|
|
36
|
+
console.error("You don't have access to any roles in this account.");
|
|
37
|
+
console.error("Contact your admin to request access.");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (roleFlag) {
|
|
41
|
+
const exactMatch = allowedRoles.find((r) => r === roleFlag);
|
|
42
|
+
if (exactMatch)
|
|
43
|
+
return exactMatch;
|
|
44
|
+
const shortMatch = allowedRoles.find((r) => {
|
|
45
|
+
const shortName = r.split("-").slice(1).join("-");
|
|
46
|
+
return shortName.toLowerCase() === roleFlag.toLowerCase();
|
|
47
|
+
});
|
|
48
|
+
if (shortMatch)
|
|
49
|
+
return shortMatch;
|
|
50
|
+
const partialMatch = allowedRoles.find((r) => r.toLowerCase().includes(roleFlag.toLowerCase()));
|
|
51
|
+
if (partialMatch)
|
|
52
|
+
return partialMatch;
|
|
53
|
+
console.error(`Role "${roleFlag}" not found. Available roles:`);
|
|
54
|
+
for (const r of allowedRoles) {
|
|
55
|
+
const shortName = r.split("-").slice(1).join("-");
|
|
56
|
+
console.error(` --role ${shortName}`);
|
|
57
|
+
}
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (allowedRoles.length === 1) {
|
|
61
|
+
return allowedRoles[0];
|
|
62
|
+
}
|
|
63
|
+
// Multiple roles available but non-interactive (not a TTY) — use first role silently
|
|
64
|
+
if (!process.stdout.isTTY) {
|
|
65
|
+
const shortName = allowedRoles[0].split("-").slice(1).join("-") || allowedRoles[0];
|
|
66
|
+
process.stderr.write(`(Using first available role: ${shortName}. Specify --role to choose.)\n`);
|
|
67
|
+
return allowedRoles[0];
|
|
68
|
+
}
|
|
69
|
+
// Multiple roles — prompt
|
|
70
|
+
console.log("\nMultiple roles available:\n");
|
|
71
|
+
for (let i = 0; i < allowedRoles.length; i++) {
|
|
72
|
+
const shortName = allowedRoles[i].split("-").slice(1).join("-") || allowedRoles[i];
|
|
73
|
+
console.log(` ${i + 1}. ${shortName}`);
|
|
74
|
+
}
|
|
75
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
76
|
+
const answer = await new Promise((resolve) => {
|
|
77
|
+
rl.question(`\nSelect role [1-${allowedRoles.length}]: `, resolve);
|
|
78
|
+
});
|
|
79
|
+
rl.close();
|
|
80
|
+
const idx = parseInt(answer, 10) - 1;
|
|
81
|
+
if (isNaN(idx) || idx < 0 || idx >= allowedRoles.length) {
|
|
82
|
+
console.log("Invalid selection, using first role.");
|
|
83
|
+
return allowedRoles[0];
|
|
84
|
+
}
|
|
85
|
+
return allowedRoles[idx];
|
|
86
|
+
}
|
|
87
|
+
export async function openConsole(target, options) {
|
|
88
|
+
const config = requireConfig();
|
|
89
|
+
const result = await api(config, "/api/aws/access-accounts?status=ACTIVE&limit=100");
|
|
90
|
+
const accounts = result.accounts.filter((a) => a.userCanAssume && !a.isFrozen);
|
|
91
|
+
if (accounts.length === 0) {
|
|
92
|
+
console.error("No accessible AWS accounts found.");
|
|
93
|
+
console.error("Run: crm-auth list");
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
const account = findAccount(accounts, target);
|
|
97
|
+
if (!account) {
|
|
98
|
+
console.error(`No active account found for "${target}".`);
|
|
99
|
+
console.error("Run: crm-auth list");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const roleName = await resolveRoleName(account, options.role);
|
|
103
|
+
const durationSeconds = options.duration ? parseInt(options.duration, 10) : undefined;
|
|
104
|
+
const consoleResult = await api(config, "/api/aws/assume-role", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
companyAwsAccountId: account.id,
|
|
108
|
+
accessType: "console",
|
|
109
|
+
roleName,
|
|
110
|
+
...(durationSeconds && { sessionDurationSeconds: durationSeconds }),
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
const accountLabel = account.accountName
|
|
114
|
+
? `${account.accountName} (${account.awsAccountId})`
|
|
115
|
+
: account.awsAccountId;
|
|
116
|
+
const shortRole = roleName.split("-").slice(1).join("-") || roleName;
|
|
117
|
+
console.log(`\nOpening AWS Console for ${account.company.name} / ${accountLabel} (${shortRole})...`);
|
|
118
|
+
console.log(`Session expires: ${new Date(consoleResult.expiration).toLocaleString()}`);
|
|
119
|
+
await open(consoleResult.consoleUrl);
|
|
120
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { requireConfig } from "../config.js";
|
|
2
|
+
import { api } from "../api.js";
|
|
3
|
+
export async function list(options) {
|
|
4
|
+
const config = requireConfig();
|
|
5
|
+
let url = "/api/aws/access-accounts?status=ACTIVE&limit=100";
|
|
6
|
+
if (options?.company) {
|
|
7
|
+
url += `&companyName=${encodeURIComponent(options.company)}`;
|
|
8
|
+
}
|
|
9
|
+
const result = await api(config, url);
|
|
10
|
+
const accounts = result.accounts;
|
|
11
|
+
if (accounts.length === 0) {
|
|
12
|
+
console.log("\n No active AWS accounts available.");
|
|
13
|
+
console.log(" Accounts must be set up and verified in the CRM before they appear here.\n");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log(`\n Available AWS Accounts (${accounts.length})\n`);
|
|
17
|
+
// Group by company
|
|
18
|
+
const grouped = new Map();
|
|
19
|
+
for (const a of accounts) {
|
|
20
|
+
const list = grouped.get(a.company.name) || [];
|
|
21
|
+
list.push(a);
|
|
22
|
+
grouped.set(a.company.name, list);
|
|
23
|
+
}
|
|
24
|
+
for (const [company, accts] of grouped) {
|
|
25
|
+
console.log(` ${company}`);
|
|
26
|
+
for (const a of accts) {
|
|
27
|
+
// Show roles as short names (strip partner slug prefix)
|
|
28
|
+
const roles = a.userAllowedRoles
|
|
29
|
+
.map((r) => r.split("-").slice(1).join("-") || r)
|
|
30
|
+
.join(", ");
|
|
31
|
+
const name = a.accountName ? ` (${a.accountName})` : "";
|
|
32
|
+
const frozen = a.isFrozen ? " [FROZEN]" : "";
|
|
33
|
+
const verified = a.accessVerifiedAt
|
|
34
|
+
? `verified ${new Date(a.accessVerifiedAt).toLocaleDateString()}`
|
|
35
|
+
: "not verified";
|
|
36
|
+
const access = a.userCanAssume ? "" : " (no access)";
|
|
37
|
+
console.log(` ${a.awsAccountId}${name}${frozen} [${roles || "no roles"}] ${verified}${access}`);
|
|
38
|
+
console.log(` slug: ${a.slug}`);
|
|
39
|
+
console.log(` crm-auth assume ${a.slug}`);
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import open from "open";
|
|
5
|
+
import { saveWorkspace } from "../config.js";
|
|
6
|
+
/**
|
|
7
|
+
* Prompt the user to pick a workspace from a numbered list.
|
|
8
|
+
* Returns the selected workspace object.
|
|
9
|
+
*/
|
|
10
|
+
async function pickWorkspace(workspaces) {
|
|
11
|
+
if (workspaces.length === 1) {
|
|
12
|
+
return workspaces[0];
|
|
13
|
+
}
|
|
14
|
+
console.log("\nAvailable workspaces:\n");
|
|
15
|
+
for (let i = 0; i < workspaces.length; i++) {
|
|
16
|
+
console.log(` ${i + 1}. ${workspaces[i].name} (${workspaces[i].slug})`);
|
|
17
|
+
}
|
|
18
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
const answer = await new Promise((resolve) => {
|
|
20
|
+
rl.question(`\nSelect workspace [1-${workspaces.length}]: `, resolve);
|
|
21
|
+
});
|
|
22
|
+
rl.close();
|
|
23
|
+
const idx = parseInt(answer, 10) - 1;
|
|
24
|
+
if (isNaN(idx) || idx < 0 || idx >= workspaces.length) {
|
|
25
|
+
console.log("Invalid selection, using first workspace.");
|
|
26
|
+
return workspaces[0];
|
|
27
|
+
}
|
|
28
|
+
return workspaces[idx];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Start a temporary localhost HTTP server, open the CRM auth page in the
|
|
32
|
+
* browser, and wait for the redirect callback with the token.
|
|
33
|
+
*/
|
|
34
|
+
export async function login(crmUrl) {
|
|
35
|
+
// Normalise URL — strip trailing slash
|
|
36
|
+
const baseUrl = crmUrl.replace(/\/+$/, "");
|
|
37
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
38
|
+
let timeoutHandle;
|
|
39
|
+
// Find a free port by binding to 0
|
|
40
|
+
const { token, user, expiresAt, workspaces } = await new Promise((resolve, reject) => {
|
|
41
|
+
const connections = new Set();
|
|
42
|
+
const server = http.createServer((req, res) => {
|
|
43
|
+
const url = new URL(req.url || "/", `http://localhost`);
|
|
44
|
+
if (url.pathname !== "/callback") {
|
|
45
|
+
res.writeHead(404);
|
|
46
|
+
res.end("Not found");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const receivedToken = url.searchParams.get("token");
|
|
50
|
+
const receivedState = url.searchParams.get("state");
|
|
51
|
+
if (receivedState !== state) {
|
|
52
|
+
res.writeHead(400);
|
|
53
|
+
res.end("State mismatch — possible CSRF. Please try again.");
|
|
54
|
+
destroyServer();
|
|
55
|
+
reject(new Error("State mismatch"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!receivedToken) {
|
|
59
|
+
res.writeHead(400);
|
|
60
|
+
res.end("No token received. Authorization may have been denied.");
|
|
61
|
+
destroyServer();
|
|
62
|
+
reject(new Error("No token received"));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Send a nice HTML response before closing
|
|
66
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
67
|
+
res.end(`
|
|
68
|
+
<html>
|
|
69
|
+
<body style="font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;">
|
|
70
|
+
<div style="text-align: center;">
|
|
71
|
+
<h2 style="color: #16a34a;">✓ Authenticated</h2>
|
|
72
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
73
|
+
</div>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
76
|
+
`);
|
|
77
|
+
// Now validate the token by calling /api/auth/cli/me
|
|
78
|
+
fetch(`${baseUrl}/api/auth/cli/me`, {
|
|
79
|
+
headers: { Authorization: `Bearer ${receivedToken}` },
|
|
80
|
+
})
|
|
81
|
+
.then(async (meRes) => {
|
|
82
|
+
if (!meRes.ok) {
|
|
83
|
+
throw new Error("Token validation failed");
|
|
84
|
+
}
|
|
85
|
+
const json = (await meRes.json());
|
|
86
|
+
destroyServer();
|
|
87
|
+
resolve({
|
|
88
|
+
token: receivedToken,
|
|
89
|
+
user: { email: json.data.email, name: json.data.name, role: json.data.role },
|
|
90
|
+
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
|
91
|
+
workspaces: json.data.workspaces || [{ slug: "default", name: "Default Workspace" }],
|
|
92
|
+
});
|
|
93
|
+
})
|
|
94
|
+
.catch((err) => {
|
|
95
|
+
destroyServer();
|
|
96
|
+
reject(err);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
// Track connections so we can force-destroy them on close
|
|
100
|
+
server.on("connection", (socket) => {
|
|
101
|
+
connections.add(socket);
|
|
102
|
+
socket.on("close", () => connections.delete(socket));
|
|
103
|
+
});
|
|
104
|
+
function destroyServer() {
|
|
105
|
+
if (timeoutHandle)
|
|
106
|
+
clearTimeout(timeoutHandle);
|
|
107
|
+
for (const socket of connections) {
|
|
108
|
+
socket.destroy();
|
|
109
|
+
}
|
|
110
|
+
server.close();
|
|
111
|
+
}
|
|
112
|
+
// Listen on a random available port
|
|
113
|
+
server.listen(0, "127.0.0.1", () => {
|
|
114
|
+
const addr = server.address();
|
|
115
|
+
const p = typeof addr === "object" && addr ? addr.port : 0;
|
|
116
|
+
const authUrl = `${baseUrl}/auth/cli?redirect_uri=${encodeURIComponent(`http://localhost:${p}/callback`)}&state=${state}`;
|
|
117
|
+
console.log(`Opening browser to authenticate...`);
|
|
118
|
+
console.log(`If the browser doesn't open, visit:\n ${authUrl}\n`);
|
|
119
|
+
open(authUrl).catch(() => {
|
|
120
|
+
// If open fails (e.g. headless server), user can copy the URL
|
|
121
|
+
});
|
|
122
|
+
// Timeout after 5 minutes
|
|
123
|
+
timeoutHandle = setTimeout(() => {
|
|
124
|
+
destroyServer();
|
|
125
|
+
reject(new Error("Login timed out after 5 minutes"));
|
|
126
|
+
}, 5 * 60 * 1000);
|
|
127
|
+
timeoutHandle.unref();
|
|
128
|
+
});
|
|
129
|
+
server.on("error", reject);
|
|
130
|
+
});
|
|
131
|
+
// If multiple workspaces, let the user pick one
|
|
132
|
+
const selected = await pickWorkspace(workspaces);
|
|
133
|
+
// Save workspace to config
|
|
134
|
+
const wsConfig = {
|
|
135
|
+
token,
|
|
136
|
+
name: selected.name,
|
|
137
|
+
expiresAt,
|
|
138
|
+
user,
|
|
139
|
+
};
|
|
140
|
+
saveWorkspace(baseUrl, selected.slug, wsConfig, true);
|
|
141
|
+
// Also save other workspaces (with the same token — server determines access)
|
|
142
|
+
for (const ws of workspaces) {
|
|
143
|
+
if (ws.slug !== selected.slug) {
|
|
144
|
+
saveWorkspace(baseUrl, ws.slug, { token, name: ws.name, expiresAt, user }, false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log(`\nLogged in as ${user.email} (${user.role})`);
|
|
148
|
+
console.log(`Active workspace: ${selected.name} (${selected.slug})`);
|
|
149
|
+
console.log(`Token expires: ${new Date(expiresAt).toLocaleDateString()}`);
|
|
150
|
+
console.log(`CRM: ${baseUrl}`);
|
|
151
|
+
if (workspaces.length > 1) {
|
|
152
|
+
console.log(`\n${workspaces.length} workspaces available. Switch with: crm-auth workspace switch <slug>`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logout(): void;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { clearConfig, loadConfig } from "../config.js";
|
|
2
|
+
export function logout() {
|
|
3
|
+
const config = loadConfig();
|
|
4
|
+
if (!config) {
|
|
5
|
+
console.log("Not logged in.");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
clearConfig();
|
|
9
|
+
console.log(`Logged out from ${config.url} (${config.user.email})`);
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function status(): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { api, ApiError } from "../api.js";
|
|
3
|
+
export async function status() {
|
|
4
|
+
const config = loadConfig();
|
|
5
|
+
if (!config) {
|
|
6
|
+
console.log("Not logged in. Run: crm-auth login --url <CRM_URL>");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const expired = new Date(config.expiresAt) < new Date();
|
|
10
|
+
console.log(`\n CRM CLI Status\n`);
|
|
11
|
+
console.log(` CRM URL: ${config.url}`);
|
|
12
|
+
console.log(` User: ${config.user.email}`);
|
|
13
|
+
console.log(` Role: ${config.user.role}`);
|
|
14
|
+
console.log(` Token: ${expired ? "EXPIRED" : "valid"} (expires ${new Date(config.expiresAt).toLocaleDateString()})`);
|
|
15
|
+
if (expired) {
|
|
16
|
+
console.log(`\n Token expired. Run: crm-auth login --url ${config.url}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// Verify token is still valid server-side
|
|
20
|
+
try {
|
|
21
|
+
const me = await api(config, "/api/auth/cli/me");
|
|
22
|
+
console.log(` Server: connected (${me.email})`);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
26
|
+
console.log(` Server: token revoked or invalid`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.log(` Server: unreachable`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
console.log();
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { listWorkspaces, switchWorkspace } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* List all stored workspaces and indicate which is active.
|
|
4
|
+
*/
|
|
5
|
+
export function workspaceList() {
|
|
6
|
+
const workspaces = listWorkspaces();
|
|
7
|
+
if (workspaces.length === 0) {
|
|
8
|
+
console.log("No workspaces configured. Run: crm-auth login --url <CRM_URL>");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
console.log("\nWorkspaces:\n");
|
|
12
|
+
for (const ws of workspaces) {
|
|
13
|
+
const marker = ws.active ? " * " : " ";
|
|
14
|
+
console.log(`${marker}${ws.slug} (${ws.name})${ws.active ? " ← active" : ""}`);
|
|
15
|
+
}
|
|
16
|
+
console.log("");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Switch the active workspace to the given slug.
|
|
20
|
+
*/
|
|
21
|
+
export function workspaceSwitch(slug) {
|
|
22
|
+
const success = switchWorkspace(slug);
|
|
23
|
+
if (success) {
|
|
24
|
+
console.log(`Switched to workspace: ${slug}`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.error(`Workspace "${slug}" not found. Run: crm-auth workspace list`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workspace stored credentials.
|
|
3
|
+
*/
|
|
4
|
+
export interface WorkspaceConfig {
|
|
5
|
+
token: string;
|
|
6
|
+
name: string;
|
|
7
|
+
expiresAt: string;
|
|
8
|
+
user: {
|
|
9
|
+
email: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
role: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Multi-workspace config file format.
|
|
16
|
+
*/
|
|
17
|
+
export interface MultiWorkspaceFile {
|
|
18
|
+
url: string;
|
|
19
|
+
activeWorkspace: string;
|
|
20
|
+
workspaces: Record<string, WorkspaceConfig>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The runtime config shape exposed to commands.
|
|
24
|
+
*/
|
|
25
|
+
export interface Config {
|
|
26
|
+
url: string;
|
|
27
|
+
token: string;
|
|
28
|
+
expiresAt: string;
|
|
29
|
+
activeWorkspace: string | null;
|
|
30
|
+
user: {
|
|
31
|
+
email: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
role: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export declare function loadConfig(): Config | null;
|
|
37
|
+
export declare function loadMultiConfig(): MultiWorkspaceFile | null;
|
|
38
|
+
export declare function saveWorkspace(url: string, slug: string, workspace: WorkspaceConfig, setActive?: boolean): void;
|
|
39
|
+
export declare function switchWorkspace(slug: string): boolean;
|
|
40
|
+
export declare function listWorkspaces(): {
|
|
41
|
+
slug: string;
|
|
42
|
+
name: string;
|
|
43
|
+
active: boolean;
|
|
44
|
+
}[];
|
|
45
|
+
export declare function clearConfig(): void;
|
|
46
|
+
export declare function requireConfig(): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
const CONFIG_DIR = path.join(os.homedir(), ".crm-auth");
|
|
5
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
6
|
+
function ensureDir() {
|
|
7
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
8
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function readRawConfig() {
|
|
12
|
+
try {
|
|
13
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
14
|
+
return null;
|
|
15
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isMultiWorkspace(raw) {
|
|
23
|
+
return "workspaces" in raw && "activeWorkspace" in raw;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Migrate legacy format to multi-workspace on read.
|
|
27
|
+
*/
|
|
28
|
+
function migrateLegacy(legacy) {
|
|
29
|
+
const defaultSlug = "default";
|
|
30
|
+
const multi = {
|
|
31
|
+
url: legacy.url,
|
|
32
|
+
activeWorkspace: defaultSlug,
|
|
33
|
+
workspaces: {
|
|
34
|
+
[defaultSlug]: {
|
|
35
|
+
token: legacy.token,
|
|
36
|
+
name: "Default Workspace",
|
|
37
|
+
expiresAt: legacy.expiresAt,
|
|
38
|
+
user: legacy.user,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
// Persist the migration
|
|
43
|
+
writeRawConfig(multi);
|
|
44
|
+
return multi;
|
|
45
|
+
}
|
|
46
|
+
function writeRawConfig(config) {
|
|
47
|
+
ensureDir();
|
|
48
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function loadConfig() {
|
|
53
|
+
const raw = readRawConfig();
|
|
54
|
+
if (!raw)
|
|
55
|
+
return null;
|
|
56
|
+
const multi = isMultiWorkspace(raw) ? raw : migrateLegacy(raw);
|
|
57
|
+
const slug = multi.activeWorkspace;
|
|
58
|
+
const ws = multi.workspaces[slug];
|
|
59
|
+
if (!ws)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
url: multi.url,
|
|
63
|
+
token: ws.token,
|
|
64
|
+
expiresAt: ws.expiresAt,
|
|
65
|
+
activeWorkspace: slug,
|
|
66
|
+
user: ws.user,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function loadMultiConfig() {
|
|
70
|
+
const raw = readRawConfig();
|
|
71
|
+
if (!raw)
|
|
72
|
+
return null;
|
|
73
|
+
return isMultiWorkspace(raw) ? raw : migrateLegacy(raw);
|
|
74
|
+
}
|
|
75
|
+
export function saveWorkspace(url, slug, workspace, setActive = true) {
|
|
76
|
+
let multi = loadMultiConfig();
|
|
77
|
+
if (!multi) {
|
|
78
|
+
multi = { url, activeWorkspace: slug, workspaces: {} };
|
|
79
|
+
}
|
|
80
|
+
multi.url = url;
|
|
81
|
+
multi.workspaces[slug] = workspace;
|
|
82
|
+
if (setActive) {
|
|
83
|
+
multi.activeWorkspace = slug;
|
|
84
|
+
}
|
|
85
|
+
writeRawConfig(multi);
|
|
86
|
+
}
|
|
87
|
+
export function switchWorkspace(slug) {
|
|
88
|
+
const multi = loadMultiConfig();
|
|
89
|
+
if (!multi)
|
|
90
|
+
return false;
|
|
91
|
+
if (!multi.workspaces[slug])
|
|
92
|
+
return false;
|
|
93
|
+
multi.activeWorkspace = slug;
|
|
94
|
+
writeRawConfig(multi);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
export function listWorkspaces() {
|
|
98
|
+
const multi = loadMultiConfig();
|
|
99
|
+
if (!multi)
|
|
100
|
+
return [];
|
|
101
|
+
return Object.entries(multi.workspaces).map(([slug, ws]) => ({
|
|
102
|
+
slug,
|
|
103
|
+
name: ws.name,
|
|
104
|
+
active: slug === multi.activeWorkspace,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
export function clearConfig() {
|
|
108
|
+
try {
|
|
109
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
110
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// ignore
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function requireConfig() {
|
|
118
|
+
const config = loadConfig();
|
|
119
|
+
if (!config) {
|
|
120
|
+
console.error("Not logged in. Run: crm-auth login --url <CRM_URL>");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
if (new Date(config.expiresAt) < new Date()) {
|
|
124
|
+
console.error("Token expired. Run: crm-auth login --url " + config.url);
|
|
125
|
+
clearConfig();
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
return config;
|
|
129
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { login } from "./commands/login.js";
|
|
4
|
+
import { list } from "./commands/list.js";
|
|
5
|
+
import { assume } from "./commands/assume.js";
|
|
6
|
+
import { openConsole } from "./commands/console.js";
|
|
7
|
+
import { status } from "./commands/status.js";
|
|
8
|
+
import { logout } from "./commands/logout.js";
|
|
9
|
+
import { workspaceList, workspaceSwitch } from "./commands/workspace.js";
|
|
10
|
+
import { ApiError } from "./api.js";
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name("crm-auth")
|
|
14
|
+
.description("CLI tool for CRM cross-account AWS access")
|
|
15
|
+
.version("1.0.0");
|
|
16
|
+
program
|
|
17
|
+
.command("login")
|
|
18
|
+
.description("Authenticate with the CRM via browser")
|
|
19
|
+
.requiredOption("--url <url>", "CRM base URL (e.g. https://crm.example.com)")
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
await login(opts.url);
|
|
22
|
+
});
|
|
23
|
+
program
|
|
24
|
+
.command("list")
|
|
25
|
+
.description("List available customer AWS accounts")
|
|
26
|
+
.option("-c, --company <name>", "Filter by company name")
|
|
27
|
+
.action(async (opts) => {
|
|
28
|
+
await list(opts);
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command("assume <company>")
|
|
32
|
+
.description("Assume role and write credentials to ~/.aws/credentials")
|
|
33
|
+
.option("-r, --role <name>", "Role short name (e.g. ReadOnly, Administrator)")
|
|
34
|
+
.option("-d, --duration <seconds>", "Session duration in seconds")
|
|
35
|
+
.option("--env", "Output as environment variable exports")
|
|
36
|
+
.option("--json", "Output credentials as JSON (for scripting)")
|
|
37
|
+
.option("--no-profile", "Print credentials without writing to file")
|
|
38
|
+
.action(async (company, opts) => {
|
|
39
|
+
await assume(company, opts);
|
|
40
|
+
});
|
|
41
|
+
program
|
|
42
|
+
.command("console <company>")
|
|
43
|
+
.description("Open AWS Console in browser for a customer account")
|
|
44
|
+
.option("-r, --role <name>", "Role short name (e.g. ReadOnly, Administrator)")
|
|
45
|
+
.option("-d, --duration <seconds>", "Session duration in seconds")
|
|
46
|
+
.action(async (company, opts) => {
|
|
47
|
+
await openConsole(company, opts);
|
|
48
|
+
});
|
|
49
|
+
program
|
|
50
|
+
.command("status")
|
|
51
|
+
.description("Show current authentication status")
|
|
52
|
+
.action(async () => {
|
|
53
|
+
await status();
|
|
54
|
+
});
|
|
55
|
+
program
|
|
56
|
+
.command("logout")
|
|
57
|
+
.description("Clear cached authentication token")
|
|
58
|
+
.action(() => {
|
|
59
|
+
logout();
|
|
60
|
+
});
|
|
61
|
+
// Workspace commands
|
|
62
|
+
const workspace = program
|
|
63
|
+
.command("workspace")
|
|
64
|
+
.description("Manage workspaces");
|
|
65
|
+
workspace
|
|
66
|
+
.command("list")
|
|
67
|
+
.description("List stored workspaces")
|
|
68
|
+
.action(() => {
|
|
69
|
+
workspaceList();
|
|
70
|
+
});
|
|
71
|
+
workspace
|
|
72
|
+
.command("switch <slug>")
|
|
73
|
+
.description("Switch the active workspace")
|
|
74
|
+
.action((slug) => {
|
|
75
|
+
workspaceSwitch(slug);
|
|
76
|
+
});
|
|
77
|
+
// Global error handler
|
|
78
|
+
program.hook("postAction", () => { });
|
|
79
|
+
async function main() {
|
|
80
|
+
try {
|
|
81
|
+
await program.parseAsync(process.argv);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof ApiError) {
|
|
85
|
+
if (err.status === 401) {
|
|
86
|
+
console.error("Authentication failed. Run: crm-auth login --url <CRM_URL>");
|
|
87
|
+
}
|
|
88
|
+
else if (err.status === 403) {
|
|
89
|
+
console.error(`Permission denied: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.error(`API error (${err.status}): ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (err instanceof Error) {
|
|
96
|
+
console.error(`Error: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inferdat/crm-auth",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool for Inferdat CRM cross-account AWS access",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.0.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"crm-auth": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"start": "node dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^13.1.0",
|
|
26
|
+
"open": "^10.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.0.0",
|
|
30
|
+
"typescript": "^5.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|