unbound-cli 1.19.0 → 1.20.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 +3 -0
- package/package.json +1 -1
- package/src/auth-pages.js +3 -2
- package/src/auth.js +12 -5
- package/src/commands/login.js +24 -2
- package/src/commands/status.js +9 -1
- package/src/index.js +1 -0
- package/src/login-scope.js +85 -0
- package/test/auth-server-leak.test.js +8 -1
- package/test/login-scope-callback.test.js +79 -0
- package/test/login-scope.test.js +51 -0
package/README.md
CHANGED
|
@@ -54,6 +54,9 @@ Only your API key is needed. Run with `sudo` to let the discovery step scan all
|
|
|
54
54
|
| `unbound login` | Sign in via browser |
|
|
55
55
|
| `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
|
|
56
56
|
| `unbound login --domain <domain>` | Sign in via a custom domain |
|
|
57
|
+
| `unbound login --role <name>` | Browser sign-in with a token scoped to a console role (`admin`, `analytics-viewer`, `manager`, or a custom role) |
|
|
58
|
+
| `unbound login --permissions <list>` | Browser sign-in with a token scoped per area, e.g. `analytics=view,logs=view` |
|
|
59
|
+
| `unbound login --expires-in-days <n>` | Token lifetime for browser sign-in: 7, 30, 60 or 90 (default 90) |
|
|
57
60
|
| `unbound logout` | Remove the CLI key from the OS keychain |
|
|
58
61
|
| `unbound status` | Show CLI status, role, and connected tools |
|
|
59
62
|
| `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
|
package/package.json
CHANGED
package/src/auth-pages.js
CHANGED
|
@@ -169,12 +169,13 @@ function successPage({ email, orgName } = {}) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/** Failure page for a callback that arrived without an api_key. */
|
|
172
|
-
function failurePage() {
|
|
172
|
+
function failurePage(reason) {
|
|
173
|
+
const detail = reason ? `<p>${escapeHtml(reason)}</p>` : '';
|
|
173
174
|
return renderPage({
|
|
174
175
|
title: 'Authentication Failed | Unbound',
|
|
175
176
|
icon: CROSS_ICON,
|
|
176
177
|
heading: 'Authentication Failed',
|
|
177
|
-
body:
|
|
178
|
+
body: `${detail}<p>You can close this tab and try again from the terminal.</p>`,
|
|
178
179
|
});
|
|
179
180
|
}
|
|
180
181
|
|
package/src/auth.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Used by both `login` and `setup` commands.
|
|
4
4
|
*/
|
|
5
5
|
const http = require('http');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const { URL } = require('url');
|
|
7
8
|
const authPages = require('./auth-pages');
|
|
8
9
|
const config = require('./config');
|
|
@@ -32,7 +33,7 @@ function warnIfNotStored(stored) {
|
|
|
32
33
|
* @param {string} frontendUrl - The frontend URL to open
|
|
33
34
|
* @returns {Promise<{email: string, orgName: string}>}
|
|
34
35
|
*/
|
|
35
|
-
async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
36
|
+
async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
|
|
36
37
|
const server = http.createServer();
|
|
37
38
|
let callbackResolve;
|
|
38
39
|
let callbackReject;
|
|
@@ -102,10 +103,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
102
103
|
const apiKey = url.searchParams.get('api_key');
|
|
103
104
|
const email = url.searchParams.get('email');
|
|
104
105
|
const orgName = url.searchParams.get('org');
|
|
106
|
+
// The callback is reachable by any local page, so its text is stripped of control characters before display.
|
|
107
|
+
const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
|
|
108
|
+
// `scope` echoes what the dashboard minted; an older dashboard sends none.
|
|
109
|
+
const grantedScope = url.searchParams.get('scope');
|
|
105
110
|
|
|
106
111
|
if (!apiKey) {
|
|
107
|
-
respondAndShutdown(res, 400, authPages.failurePage());
|
|
108
|
-
callbackReject(new Error('No API key received in callback'));
|
|
112
|
+
respondAndShutdown(res, 400, authPages.failurePage(reason));
|
|
113
|
+
callbackReject(new Error(reason || 'No API key received in callback'));
|
|
109
114
|
return;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -116,7 +121,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
116
121
|
config.writeConfig(cfg);
|
|
117
122
|
|
|
118
123
|
respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
|
|
119
|
-
callbackResolve({ email, orgName });
|
|
124
|
+
callbackResolve(grantedScope ? { email, orgName, scope: grantedScope } : { email, orgName });
|
|
120
125
|
});
|
|
121
126
|
|
|
122
127
|
await new Promise((resolve, reject) => {
|
|
@@ -126,7 +131,9 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
126
131
|
|
|
127
132
|
const port = server.address().port;
|
|
128
133
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
129
|
-
|
|
134
|
+
// `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
|
|
135
|
+
const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
|
|
136
|
+
const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
|
|
130
137
|
|
|
131
138
|
const open = openFn || (await import('open')).default;
|
|
132
139
|
|
package/src/commands/login.js
CHANGED
|
@@ -4,6 +4,7 @@ const output = require('../output');
|
|
|
4
4
|
const api = require('../api');
|
|
5
5
|
const { loginWithBrowser, loginWithApiKey, fetchDeviceKey } = require('../auth');
|
|
6
6
|
const { getDeviceSerial } = require('../device-serial');
|
|
7
|
+
const { parseLoginScope, isScoped, describeToken } = require('../login-scope');
|
|
7
8
|
|
|
8
9
|
function register(program) {
|
|
9
10
|
program
|
|
@@ -15,6 +16,9 @@ function register(program) {
|
|
|
15
16
|
.option('--backend-url <url>', 'Tenant backend REST API URL (e.g. https://backend.acme.com). Persisted to config.')
|
|
16
17
|
.addOption(new Option('--base-url <url>', 'Hidden alias of --backend-url, kept for back-compat.').hideHelp())
|
|
17
18
|
.option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
|
|
19
|
+
.option('--role <name>', 'Browser login only: scope the token to a console role (admin, analytics-viewer, manager, or a custom role name)')
|
|
20
|
+
.option('--permissions <list>', 'Browser login only: scope the token per area, e.g. analytics=view,logs=view (unlisted areas get none)')
|
|
21
|
+
.option('--expires-in-days <days>', 'Browser login only: token lifetime, one of 7, 30, 60, 90 (default 90)')
|
|
18
22
|
.addHelpText('after', `
|
|
19
23
|
Authentication methods:
|
|
20
24
|
Browser login (default):
|
|
@@ -35,9 +39,17 @@ Options:
|
|
|
35
39
|
--domain sets a custom domain for organizations with self-hosted frontends.
|
|
36
40
|
Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
|
|
37
41
|
|
|
42
|
+
Scoped browser login (--role / --permissions / --expires-in-days):
|
|
43
|
+
By default the login token has your full access for 90 days. Scope it to a
|
|
44
|
+
console role or to specific areas; the dashboard still caps it to your own
|
|
45
|
+
access. Re-running login on this machine replaces its previous token.
|
|
46
|
+
|
|
38
47
|
Examples:
|
|
39
48
|
$ unbound login # Login via default gateway
|
|
40
49
|
$ unbound login --api-key sk-abc123 # Non-interactive login
|
|
50
|
+
$ unbound login --role analytics-viewer # View-only token
|
|
51
|
+
$ unbound login --role "Policy Auditor" --expires-in-days 30
|
|
52
|
+
$ unbound login --permissions analytics=view,logs=view
|
|
41
53
|
$ unbound login --api-key sk-abc123 \\
|
|
42
54
|
--gateway-url https://api.acme.com \\
|
|
43
55
|
--frontend-url https://gateway.acme.com \\
|
|
@@ -60,8 +72,12 @@ Examples:
|
|
|
60
72
|
// wrong tenant.
|
|
61
73
|
const explicitBaseUrl = written.base_url;
|
|
62
74
|
const explicitFrontendUrl = written.frontend_url;
|
|
75
|
+
const scope = parseLoginScope(opts);
|
|
63
76
|
|
|
64
77
|
if (opts.apiKey) {
|
|
78
|
+
if (isScoped(scope)) {
|
|
79
|
+
output.warn('--role, --permissions and --expires-in-days apply to browser login only; the given key is used as is.');
|
|
80
|
+
}
|
|
65
81
|
try {
|
|
66
82
|
await loginWithApiKey(opts.apiKey, { baseUrl: explicitBaseUrl });
|
|
67
83
|
} catch {
|
|
@@ -77,14 +93,20 @@ Examples:
|
|
|
77
93
|
} else {
|
|
78
94
|
frontendUrl = explicitFrontendUrl || config.getFrontendUrl();
|
|
79
95
|
}
|
|
80
|
-
await loginWithBrowser(frontendUrl);
|
|
96
|
+
const result = await loginWithBrowser(frontendUrl, { scope });
|
|
97
|
+
if (isScoped(scope) && !result.scope) {
|
|
98
|
+
output.warn('Dashboard ignored your login flags. Stored a default 90-day full-access token. '
|
|
99
|
+
+ 'Revoke it under Settings › API Tokens if unwanted.');
|
|
100
|
+
}
|
|
81
101
|
// Validate the just-stored key against the explicit backend if one
|
|
82
102
|
// was just persisted; otherwise fall back to the env-var-aware
|
|
83
103
|
// getter. api.get reads the stored key from config internally.
|
|
84
104
|
const deviceSerial = await getDeviceSerial();
|
|
85
|
-
await api.get('/api/v1/users/privileges/', {
|
|
105
|
+
const privileges = await api.get('/api/v1/users/privileges/', {
|
|
86
106
|
baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
|
|
87
107
|
});
|
|
108
|
+
const token = describeToken(privileges);
|
|
109
|
+
if (token) output.info(`Token: ${token}`);
|
|
88
110
|
// Replace whatever config.json held (an old CLI login, another org's key) with this login's device key.
|
|
89
111
|
try {
|
|
90
112
|
config.setDeviceKey(await fetchDeviceKey(config.getApiKey(), { baseUrl: explicitBaseUrl }));
|
package/src/commands/status.js
CHANGED
|
@@ -4,9 +4,12 @@ const output = require('../output');
|
|
|
4
4
|
const { getDeviceSerial } = require('../device-serial');
|
|
5
5
|
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
6
6
|
const claudePaths = require('../claudePaths');
|
|
7
|
+
const { describeToken } = require('../login-scope');
|
|
7
8
|
|
|
8
9
|
function roleFromPrivileges(p) {
|
|
9
10
|
if (!p) return null;
|
|
11
|
+
// A scoped token reports its own cap, not the owner's role flags.
|
|
12
|
+
if (p.cli_token && p.cli_token.permissions) return 'Scoped token';
|
|
10
13
|
if (p.is_admin) return 'Admin';
|
|
11
14
|
if (p.is_analytics_viewer) return 'Analytics Viewer';
|
|
12
15
|
if (p.is_manager) return 'Manager';
|
|
@@ -25,7 +28,8 @@ Output fields:
|
|
|
25
28
|
Logged in - Whether credentials are stored (Yes/No)
|
|
26
29
|
Email - The authenticated user's email (if logged in)
|
|
27
30
|
Organization - The organization name (if logged in)
|
|
28
|
-
Role - Admin / Analytics Viewer / Manager / Member (if logged in)
|
|
31
|
+
Role - Admin / Analytics Viewer / Manager / Member, or "Scoped token" (if logged in)
|
|
32
|
+
Token - Login token name, scope and expiry (if signed in with a CLI token)
|
|
29
33
|
Backend URL - REST API host (configurable for tenant deployments)
|
|
30
34
|
Frontend URL - Browser login host
|
|
31
35
|
Gateway URL - AI gateway host (used by tool setup)
|
|
@@ -51,6 +55,7 @@ Examples:
|
|
|
51
55
|
|
|
52
56
|
let connectivity = 'Not checked (not logged in)';
|
|
53
57
|
let role = null;
|
|
58
|
+
let token = null;
|
|
54
59
|
if (loggedIn) {
|
|
55
60
|
const spin = output.spinner('Checking API connectivity...');
|
|
56
61
|
try {
|
|
@@ -60,6 +65,7 @@ Examples:
|
|
|
60
65
|
});
|
|
61
66
|
config.backfillUserInfo(privileges);
|
|
62
67
|
role = roleFromPrivileges(privileges);
|
|
68
|
+
token = describeToken(privileges);
|
|
63
69
|
spin.stop();
|
|
64
70
|
connectivity = 'Connected';
|
|
65
71
|
} catch (err) {
|
|
@@ -71,6 +77,7 @@ Examples:
|
|
|
71
77
|
pairs.push(['Email', cfg.email || '-']);
|
|
72
78
|
pairs.push(['Organization', cfg.org_name || '-']);
|
|
73
79
|
pairs.push(['Role', role || '-']);
|
|
80
|
+
if (token) pairs.push(['Token', token]);
|
|
74
81
|
}
|
|
75
82
|
pairs.push(['Backend URL', config.getBaseUrl()]);
|
|
76
83
|
pairs.push(['Frontend URL', config.getFrontendUrl()]);
|
|
@@ -100,6 +107,7 @@ Examples:
|
|
|
100
107
|
email: loggedIn ? (cfg.email || null) : null,
|
|
101
108
|
organization: loggedIn ? (cfg.org_name || null) : null,
|
|
102
109
|
role: role,
|
|
110
|
+
token: token,
|
|
103
111
|
backend_url: config.getBaseUrl(),
|
|
104
112
|
frontend_url: config.getFrontendUrl(),
|
|
105
113
|
gateway_url: config.getGatewayUrl(),
|
package/src/index.js
CHANGED
|
@@ -38,6 +38,7 @@ AUTHENTICATION
|
|
|
38
38
|
$ unbound login Sign in via browser
|
|
39
39
|
$ unbound login --api-key <key> Sign in with an API key (for CI/CD)
|
|
40
40
|
$ unbound login --domain custom.co Sign in via custom domain
|
|
41
|
+
$ unbound login --role analytics-viewer Browser sign-in with a view-only token
|
|
41
42
|
$ unbound logout Remove stored credentials
|
|
42
43
|
$ unbound status Show CLI status, role, and connected tools
|
|
43
44
|
$ unbound doctor Diagnose per-tool health and API key
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope flags for `unbound login`: what the minted login token may do and for how long.
|
|
3
|
+
* Parsed before the browser opens so a typo fails here, not after the round trip.
|
|
4
|
+
*/
|
|
5
|
+
const AREAS = ['analytics', 'logs', 'policies', 'configuration'];
|
|
6
|
+
const LEVELS = ['none', 'view', 'edit'];
|
|
7
|
+
const EXPIRY_DAYS = [7, 30, 60, 90];
|
|
8
|
+
const ROLE_NAME_MAX_LENGTH = 64;
|
|
9
|
+
|
|
10
|
+
// Built-in roles by their shell-friendly slug; custom role names pass through verbatim.
|
|
11
|
+
const BUILTIN_ROLE_SLUGS = {
|
|
12
|
+
admin: 'Admin',
|
|
13
|
+
'analytics-viewer': 'Analytics Viewer',
|
|
14
|
+
manager: 'Manager',
|
|
15
|
+
member: 'Member',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const AREA_LABELS = { analytics: 'Analytics', logs: 'Logs', policies: 'Policies', configuration: 'Configuration' };
|
|
19
|
+
|
|
20
|
+
function parsePermissions(raw) {
|
|
21
|
+
const permissions = Object.fromEntries(AREAS.map((area) => [area, 'none']));
|
|
22
|
+
for (const pair of String(raw).split(',')) {
|
|
23
|
+
const parts = pair.trim().split('=');
|
|
24
|
+
const [area, level] = parts;
|
|
25
|
+
if (parts.length !== 2 || !area || !level) throw new Error(`Invalid permissions entry "${pair.trim()}". Use area=level, e.g. analytics=view`);
|
|
26
|
+
if (!AREAS.includes(area)) throw new Error(`Unknown area "${area}". Areas: ${AREAS.join(', ')}`);
|
|
27
|
+
if (!LEVELS.includes(level)) throw new Error(`Unknown level "${level}" for ${area}. Levels: ${LEVELS.join(', ')}`);
|
|
28
|
+
if (area === 'logs' && level === 'edit') throw new Error('Logs has no edit level');
|
|
29
|
+
permissions[area] = level;
|
|
30
|
+
}
|
|
31
|
+
return permissions;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Turns the login flags into the query the dashboard reads. Returns {} when no flag was given.
|
|
36
|
+
* @param {{role?: string, permissions?: string, expiresInDays?: string|number}} opts
|
|
37
|
+
*/
|
|
38
|
+
function parseLoginScope(opts = {}) {
|
|
39
|
+
const query = {};
|
|
40
|
+
const role = typeof opts.role === 'string' ? opts.role.trim() : '';
|
|
41
|
+
const permissions = typeof opts.permissions === 'string' ? opts.permissions.trim() : '';
|
|
42
|
+
if (opts.role !== undefined && !role) throw new Error('--role must not be empty');
|
|
43
|
+
if (opts.permissions !== undefined && !permissions) throw new Error('--permissions must not be empty');
|
|
44
|
+
if (role && permissions) throw new Error('Use --role or --permissions, not both');
|
|
45
|
+
if (opts.expiresInDays !== undefined) {
|
|
46
|
+
const days = Number(opts.expiresInDays);
|
|
47
|
+
if (!EXPIRY_DAYS.includes(days)) throw new Error(`--expires-in-days must be one of ${EXPIRY_DAYS.join(', ')}`);
|
|
48
|
+
query.expires_in_days = String(days);
|
|
49
|
+
}
|
|
50
|
+
if (role) {
|
|
51
|
+
const slug = role.toLowerCase();
|
|
52
|
+
const name = Object.hasOwn(BUILTIN_ROLE_SLUGS, slug) ? BUILTIN_ROLE_SLUGS[slug] : role;
|
|
53
|
+
if (name.length > ROLE_NAME_MAX_LENGTH) throw new Error(`Unknown role "${role}"`);
|
|
54
|
+
query.role = name;
|
|
55
|
+
} else if (permissions) {
|
|
56
|
+
const map = parsePermissions(permissions);
|
|
57
|
+
query.permissions = AREAS.filter((a) => map[a] !== 'none').map((a) => `${a}=${map[a]}`).join(',');
|
|
58
|
+
if (!query.permissions) throw new Error('--permissions grants no access');
|
|
59
|
+
}
|
|
60
|
+
return query;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when any login flag was given, so a dashboard that echoes no `scope` must have ignored it. */
|
|
64
|
+
function isScoped(query) {
|
|
65
|
+
return Object.keys(query).length > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** "Analytics view, Logs view" from a permissions map, or "full access" for null. */
|
|
69
|
+
function formatPermissions(permissions) {
|
|
70
|
+
if (!permissions || typeof permissions !== 'object') return 'full access';
|
|
71
|
+
const granted = AREAS.filter((a) => permissions[a] && permissions[a] !== 'none')
|
|
72
|
+
.map((a) => `${AREA_LABELS[a]} ${permissions[a]}`);
|
|
73
|
+
return granted.length ? granted.join(', ') : 'no access';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One line describing the token the privileges call reports, or null when the caller is not a CLI token. */
|
|
77
|
+
function describeToken(privileges) {
|
|
78
|
+
const token = privileges && privileges.cli_token;
|
|
79
|
+
if (!token || typeof token !== 'object') return null;
|
|
80
|
+
const parts = [token.name, formatPermissions(token.permissions)];
|
|
81
|
+
if (token.expires_at) parts.push(`expires ${String(token.expires_at).slice(0, 10)}`);
|
|
82
|
+
return parts.filter(Boolean).join(' · ');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { parseLoginScope, isScoped, formatPermissions, describeToken, BUILTIN_ROLE_SLUGS, EXPIRY_DAYS };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { test } = require('node:test');
|
|
2
2
|
const assert = require('node:assert/strict');
|
|
3
3
|
const http = require('http');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const { URL } = require('url');
|
|
5
6
|
|
|
6
7
|
// WEB-4941, root cause #1: loginWithBrowser's local callback server leaked a
|
|
@@ -105,8 +106,11 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
|
|
|
105
106
|
let successHeaders;
|
|
106
107
|
let successSocket;
|
|
107
108
|
|
|
109
|
+
let authParams;
|
|
110
|
+
|
|
108
111
|
const fakeOpen = async (authUrl) => {
|
|
109
|
-
|
|
112
|
+
authParams = new URL(authUrl).searchParams;
|
|
113
|
+
port = Number(new URL(authParams.get('callback_url')).port);
|
|
110
114
|
const res = await getKeepAlive(agent, buildSuccessCallback(authUrl));
|
|
111
115
|
successHeaders = res.headers;
|
|
112
116
|
successSocket = res.socket;
|
|
@@ -120,6 +124,9 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
|
|
|
120
124
|
);
|
|
121
125
|
|
|
122
126
|
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
127
|
+
// The dashboard names this machine's login token after `machine`, so a re-login replaces it.
|
|
128
|
+
assert.equal(authParams.get('app_type'), 'cli');
|
|
129
|
+
assert.equal(authParams.get('machine'), os.hostname());
|
|
123
130
|
assert.equal(successHeaders.connection, 'close', 'success response must set Connection: close');
|
|
124
131
|
|
|
125
132
|
// The leak-proof: the held keep-alive socket is actually closed by the
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const { URL } = require('url');
|
|
5
|
+
|
|
6
|
+
// The browser round trip for a scoped login: the flags ride on the auth URL, the dashboard's
|
|
7
|
+
// `scope=` echo comes back on success, and its `error=` reason is what the user sees on failure.
|
|
8
|
+
|
|
9
|
+
const auth = require('../src/auth');
|
|
10
|
+
const config = require('../src/config');
|
|
11
|
+
|
|
12
|
+
function get(url) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
http.get(url, (res) => {
|
|
15
|
+
let body = '';
|
|
16
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
17
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
|
18
|
+
}).on('error', reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withStubbedConfig(fn) {
|
|
23
|
+
const saved = { setApiKey: config.setApiKey, writeConfig: config.writeConfig, readConfig: config.readConfig };
|
|
24
|
+
config.setApiKey = () => {};
|
|
25
|
+
config.writeConfig = () => {};
|
|
26
|
+
config.readConfig = () => ({});
|
|
27
|
+
return Promise.resolve().then(fn).finally(() => Object.assign(config, saved));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('loginWithBrowser: scope flags ride on the auth URL and the echoed scope comes back', async () => {
|
|
31
|
+
await withStubbedConfig(async () => {
|
|
32
|
+
let authParams;
|
|
33
|
+
const fakeOpen = async (authUrl) => {
|
|
34
|
+
authParams = new URL(authUrl).searchParams;
|
|
35
|
+
const callbackUrl = authParams.get('callback_url');
|
|
36
|
+
setImmediate(() => get(`${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme&scope=role%3AAnalytics+Viewer`));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', {
|
|
40
|
+
open: fakeOpen, scope: { role: 'Analytics Viewer', expires_in_days: '30' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(authParams.get('role'), 'Analytics Viewer');
|
|
44
|
+
assert.equal(authParams.get('expires_in_days'), '30');
|
|
45
|
+
assert.equal(authParams.get('app_type'), 'cli');
|
|
46
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme', scope: 'role:Analytics Viewer' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('loginWithBrowser: control characters in the callback error never reach the terminal', async () => {
|
|
51
|
+
await withStubbedConfig(async () => {
|
|
52
|
+
const fakeOpen = async (authUrl) => {
|
|
53
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
54
|
+
setImmediate(() => get(`${callbackUrl}?error=${encodeURIComponent('bad\u001b[31m role\u0007')}`));
|
|
55
|
+
};
|
|
56
|
+
await assert.rejects(
|
|
57
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'x' } }),
|
|
58
|
+
(err) => err.message === 'bad[31m role',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows on the failure page', async () => {
|
|
64
|
+
await withStubbedConfig(async () => {
|
|
65
|
+
let page;
|
|
66
|
+
const fakeOpen = async (authUrl) => {
|
|
67
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
68
|
+
setImmediate(async () => { page = await get(`${callbackUrl}?error=Unknown+role+%22Policy-Auditor%22`); });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'Policy-Auditor' } }),
|
|
73
|
+
/Unknown role "Policy-Auditor"/,
|
|
74
|
+
);
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
76
|
+
assert.equal(page.statusCode, 400);
|
|
77
|
+
assert.match(page.body, /Unknown role "Policy-Auditor"/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const { parseLoginScope, isScoped, formatPermissions, describeToken } = require('../src/login-scope');
|
|
5
|
+
|
|
6
|
+
test('parseLoginScope: no flags means a plain login', () => {
|
|
7
|
+
assert.deepEqual(parseLoginScope({}), {});
|
|
8
|
+
assert.equal(isScoped({}), false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('parseLoginScope: a flag given but empty is an error, not a plain login', () => {
|
|
12
|
+
assert.throws(() => parseLoginScope({ role: ' ' }), /--role must not be empty/);
|
|
13
|
+
assert.throws(() => parseLoginScope({ permissions: '' }), /--permissions must not be empty/);
|
|
14
|
+
assert.equal(isScoped(parseLoginScope({ expiresInDays: '7' })), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('parseLoginScope: built-in slugs map to the role name, custom names pass verbatim', () => {
|
|
18
|
+
assert.deepEqual(parseLoginScope({ role: 'analytics-viewer' }), { role: 'Analytics Viewer' });
|
|
19
|
+
assert.deepEqual(parseLoginScope({ role: 'ADMIN' }), { role: 'Admin' });
|
|
20
|
+
assert.deepEqual(parseLoginScope({ role: ' Policy Auditor ' }), { role: 'Policy Auditor' });
|
|
21
|
+
assert.deepEqual(parseLoginScope({ role: 'constructor' }), { role: 'constructor' });
|
|
22
|
+
assert.deepEqual(parseLoginScope({ role: '__proto__' }), { role: '__proto__' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('parseLoginScope: permissions keep only granted areas, in a fixed order', () => {
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
parseLoginScope({ permissions: 'logs=view, analytics=view', expiresInDays: '30' }),
|
|
28
|
+
{ expires_in_days: '30', permissions: 'analytics=view,logs=view' },
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('parseLoginScope: rejects bad input before the browser opens', () => {
|
|
33
|
+
assert.throws(() => parseLoginScope({ role: 'admin', permissions: 'analytics=view' }), /not both/);
|
|
34
|
+
assert.throws(() => parseLoginScope({ expiresInDays: '45' }), /7, 30, 60, 90/);
|
|
35
|
+
assert.throws(() => parseLoginScope({ permissions: 'foo=view' }), /Unknown area "foo"/);
|
|
36
|
+
assert.throws(() => parseLoginScope({ permissions: 'analytics=admin' }), /Unknown level "admin"/);
|
|
37
|
+
assert.throws(() => parseLoginScope({ permissions: 'logs=edit' }), /no edit level/);
|
|
38
|
+
assert.throws(() => parseLoginScope({ permissions: 'analytics' }), /area=level/);
|
|
39
|
+
assert.throws(() => parseLoginScope({ permissions: 'analytics=none' }), /grants no access/);
|
|
40
|
+
assert.throws(() => parseLoginScope({ role: 'x'.repeat(65) }), /Unknown role/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('formatPermissions and describeToken read the privileges payload', () => {
|
|
44
|
+
assert.equal(formatPermissions(null), 'full access');
|
|
45
|
+
assert.equal(formatPermissions({ analytics: 'view', logs: 'view', policies: 'none', configuration: 'none' }), 'Analytics view, Logs view');
|
|
46
|
+
assert.equal(describeToken({ is_admin: true }), null);
|
|
47
|
+
assert.equal(
|
|
48
|
+
describeToken({ cli_token: { name: 'cli-login:mbp', permissions: null, expires_at: '2027-02-01T00:00:00Z' } }),
|
|
49
|
+
'cli-login:mbp · full access · expires 2027-02-01',
|
|
50
|
+
);
|
|
51
|
+
});
|