unbound-cli 1.19.0 → 1.20.1
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 +33 -7
- 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 +19 -6
- package/test/login-scope-callback.test.js +211 -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
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Shared browser-based authentication flow.
|
|
3
3
|
* Used by both `login` and `setup` commands.
|
|
4
4
|
*/
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const http = require('http');
|
|
7
|
+
const os = require('os');
|
|
6
8
|
const { URL } = require('url');
|
|
7
9
|
const authPages = require('./auth-pages');
|
|
8
10
|
const config = require('./config');
|
|
@@ -32,8 +34,10 @@ function warnIfNotStored(stored) {
|
|
|
32
34
|
* @param {string} frontendUrl - The frontend URL to open
|
|
33
35
|
* @returns {Promise<{email: string, orgName: string}>}
|
|
34
36
|
*/
|
|
35
|
-
async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
37
|
+
async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
|
|
36
38
|
const server = http.createServer();
|
|
39
|
+
// One-time nonce: only the redirect the dashboard was handed can complete this login.
|
|
40
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
37
41
|
let callbackResolve;
|
|
38
42
|
let callbackReject;
|
|
39
43
|
|
|
@@ -72,7 +76,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
72
76
|
function respondAndShutdown(res, statusCode, html) {
|
|
73
77
|
res.on('finish', shutdownServer);
|
|
74
78
|
res.on('close', shutdownServer);
|
|
75
|
-
res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
|
|
79
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'close' });
|
|
76
80
|
res.end(html);
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -83,6 +87,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
83
87
|
let callbackHandled = false;
|
|
84
88
|
let stored;
|
|
85
89
|
|
|
90
|
+
// Compare byte lengths, not string lengths: a multi-byte candidate would make timingSafeEqual throw.
|
|
91
|
+
function stateMatches(candidate) {
|
|
92
|
+
if (typeof candidate !== 'string') return false;
|
|
93
|
+
const given = Buffer.from(candidate);
|
|
94
|
+
const expected = Buffer.from(state);
|
|
95
|
+
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
server.on('request', (req, res) => {
|
|
87
99
|
const url = new URL(req.url, `http://localhost`);
|
|
88
100
|
|
|
@@ -92,6 +104,13 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
92
104
|
return;
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
// A request without this login's nonce is not the dashboard's redirect: answer it and keep waiting.
|
|
108
|
+
if (!stateMatches(url.searchParams.get('state'))) {
|
|
109
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'close' });
|
|
110
|
+
res.end(authPages.failurePage('Invalid login callback'));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
95
114
|
if (callbackHandled) {
|
|
96
115
|
res.writeHead(409, { 'Connection': 'close' });
|
|
97
116
|
res.end('Callback already processed');
|
|
@@ -102,10 +121,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
102
121
|
const apiKey = url.searchParams.get('api_key');
|
|
103
122
|
const email = url.searchParams.get('email');
|
|
104
123
|
const orgName = url.searchParams.get('org');
|
|
124
|
+
// Nonce-gated, but the reason is still stripped of control characters before display as defence in depth.
|
|
125
|
+
const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
|
|
126
|
+
// `scope` echoes what the dashboard minted; an older dashboard sends none.
|
|
127
|
+
const grantedScope = url.searchParams.get('scope');
|
|
105
128
|
|
|
106
129
|
if (!apiKey) {
|
|
107
|
-
respondAndShutdown(res, 400, authPages.failurePage());
|
|
108
|
-
callbackReject(new Error('No API key received in callback'));
|
|
130
|
+
respondAndShutdown(res, 400, authPages.failurePage(reason));
|
|
131
|
+
callbackReject(new Error(reason || 'No API key received in callback'));
|
|
109
132
|
return;
|
|
110
133
|
}
|
|
111
134
|
|
|
@@ -116,7 +139,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
116
139
|
config.writeConfig(cfg);
|
|
117
140
|
|
|
118
141
|
respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
|
|
119
|
-
callbackResolve({ email, orgName });
|
|
142
|
+
callbackResolve(grantedScope ? { email, orgName, scope: grantedScope } : { email, orgName });
|
|
120
143
|
});
|
|
121
144
|
|
|
122
145
|
await new Promise((resolve, reject) => {
|
|
@@ -125,8 +148,11 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
125
148
|
});
|
|
126
149
|
|
|
127
150
|
const port = server.address().port;
|
|
128
|
-
|
|
129
|
-
const
|
|
151
|
+
// 127.0.0.1 literal: the server binds only IPv4, so a `localhost` redirect could land on a squatter at [::1].
|
|
152
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?state=${state}`;
|
|
153
|
+
// `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
|
|
154
|
+
const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
|
|
155
|
+
const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
|
|
130
156
|
|
|
131
157
|
const open = openFn || (await import('open')).default;
|
|
132
158
|
|
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
|
|
@@ -63,11 +64,17 @@ function expectConnectionRefused(port) {
|
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
67
|
+
// Mirrors the dashboard: keep callback_url's own query (the state nonce) and
|
|
68
|
+
// set the success params the /callback handler reads (api_key/email/org).
|
|
69
|
+
function withCallbackParams(callbackUrl, params) {
|
|
70
|
+
const u = new URL(callbackUrl);
|
|
71
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
72
|
+
return u.toString();
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
function buildSuccessCallback(authUrl) {
|
|
69
76
|
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
70
|
-
return
|
|
77
|
+
return withCallbackParams(callbackUrl, { api_key: 'sk-test', email: 'a@b.co', org: 'Acme' });
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
// Monkeypatch config so no real ~/.unbound write happens. Restored in finally.
|
|
@@ -105,8 +112,11 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
|
|
|
105
112
|
let successHeaders;
|
|
106
113
|
let successSocket;
|
|
107
114
|
|
|
115
|
+
let authParams;
|
|
116
|
+
|
|
108
117
|
const fakeOpen = async (authUrl) => {
|
|
109
|
-
|
|
118
|
+
authParams = new URL(authUrl).searchParams;
|
|
119
|
+
port = Number(new URL(authParams.get('callback_url')).port);
|
|
110
120
|
const res = await getKeepAlive(agent, buildSuccessCallback(authUrl));
|
|
111
121
|
successHeaders = res.headers;
|
|
112
122
|
successSocket = res.socket;
|
|
@@ -120,6 +130,9 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
|
|
|
120
130
|
);
|
|
121
131
|
|
|
122
132
|
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
133
|
+
// The dashboard names this machine's login token after `machine`, so a re-login replaces it.
|
|
134
|
+
assert.equal(authParams.get('app_type'), 'cli');
|
|
135
|
+
assert.equal(authParams.get('machine'), os.hostname());
|
|
123
136
|
assert.equal(successHeaders.connection, 'close', 'success response must set Connection: close');
|
|
124
137
|
|
|
125
138
|
// The leak-proof: the held keep-alive socket is actually closed by the
|
|
@@ -142,13 +155,13 @@ test('loginWithBrowser: duplicate /callback cannot overwrite the stored credenti
|
|
|
142
155
|
|
|
143
156
|
const fakeOpen = async (authUrl) => {
|
|
144
157
|
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
145
|
-
await getKeepAlive(agent,
|
|
158
|
+
await getKeepAlive(agent, withCallbackParams(callbackUrl, { api_key: 'sk-first', email: 'a@b.co', org: 'Acme' }));
|
|
146
159
|
// Second callback with a different key: teardown is deferred to the
|
|
147
160
|
// response flush, so the server may still be up — it must answer 409
|
|
148
161
|
// without persisting; once down, ECONNREFUSED is equally acceptable.
|
|
149
162
|
// Either way the first credentials must be the only ones stored.
|
|
150
163
|
try {
|
|
151
|
-
const dup = await getKeepAlive(agent,
|
|
164
|
+
const dup = await getKeepAlive(agent, withCallbackParams(callbackUrl, { api_key: 'sk-attacker', email: 'x@y.z', org: 'Evil' }));
|
|
152
165
|
assert.equal(dup.statusCode, 409, 'late duplicate must be rejected, not processed');
|
|
153
166
|
} catch (err) {
|
|
154
167
|
if (err.code !== 'ECONNREFUSED') throw err;
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
// Mirrors the dashboard: `new URL(callback_url)` keeps the state nonce, then the result params are set on it.
|
|
23
|
+
function withCallbackParams(callbackUrl, params) {
|
|
24
|
+
const u = new URL(callbackUrl);
|
|
25
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
26
|
+
return u.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function withStubbedConfig(fn) {
|
|
30
|
+
const saved = { setApiKey: config.setApiKey, writeConfig: config.writeConfig, readConfig: config.readConfig };
|
|
31
|
+
config.setApiKey = () => {};
|
|
32
|
+
config.writeConfig = () => {};
|
|
33
|
+
config.readConfig = () => ({});
|
|
34
|
+
return Promise.resolve().then(fn).finally(() => Object.assign(config, saved));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('loginWithBrowser: scope flags ride on the auth URL and the echoed scope comes back', async () => {
|
|
38
|
+
await withStubbedConfig(async () => {
|
|
39
|
+
let authParams;
|
|
40
|
+
const fakeOpen = async (authUrl) => {
|
|
41
|
+
authParams = new URL(authUrl).searchParams;
|
|
42
|
+
const callbackUrl = authParams.get('callback_url');
|
|
43
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test', email: 'a@b.co', org: 'Acme', scope: 'role:Analytics Viewer' })));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', {
|
|
47
|
+
open: fakeOpen, scope: { role: 'Analytics Viewer', expires_in_days: '30' },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert.equal(authParams.get('role'), 'Analytics Viewer');
|
|
51
|
+
assert.equal(authParams.get('expires_in_days'), '30');
|
|
52
|
+
assert.equal(authParams.get('app_type'), 'cli');
|
|
53
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme', scope: 'role:Analytics Viewer' });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('loginWithBrowser: control characters in the callback error never reach the terminal', async () => {
|
|
58
|
+
await withStubbedConfig(async () => {
|
|
59
|
+
const fakeOpen = async (authUrl) => {
|
|
60
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
61
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { error: 'bad\u001b[31m role\u0007' })));
|
|
62
|
+
};
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'x' } }),
|
|
65
|
+
(err) => err.message === 'bad[31m role',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows on the failure page', async () => {
|
|
71
|
+
await withStubbedConfig(async () => {
|
|
72
|
+
let page;
|
|
73
|
+
const fakeOpen = async (authUrl) => {
|
|
74
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
75
|
+
setImmediate(async () => { page = await get(withCallbackParams(callbackUrl, { error: 'Unknown role "Policy-Auditor"' })); });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'Policy-Auditor' } }),
|
|
80
|
+
/Unknown role "Policy-Auditor"/,
|
|
81
|
+
);
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
83
|
+
assert.equal(page.statusCode, 400);
|
|
84
|
+
assert.match(page.body, /Unknown role "Policy-Auditor"/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// WEB-6462: the callback is gated by a per-login `state` nonce and pinned to 127.0.0.1, so a local
|
|
89
|
+
// page or an IPv6 bind-squatter can neither complete the login nor read the dashboard's redirect.
|
|
90
|
+
|
|
91
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
92
|
+
|
|
93
|
+
test('loginWithBrowser: callback_url is pinned to 127.0.0.1 and carries a 32-hex state nonce', async () => {
|
|
94
|
+
await withStubbedConfig(async () => {
|
|
95
|
+
let callbackUrl;
|
|
96
|
+
const fakeOpen = async (authUrl) => {
|
|
97
|
+
callbackUrl = new URL(new URL(authUrl).searchParams.get('callback_url'));
|
|
98
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test' })));
|
|
99
|
+
};
|
|
100
|
+
await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
101
|
+
assert.equal(callbackUrl.hostname, '127.0.0.1');
|
|
102
|
+
assert.equal(callbackUrl.pathname, '/callback');
|
|
103
|
+
assert.match(callbackUrl.searchParams.get('state'), /^[0-9a-f]{32}$/);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('loginWithBrowser: a wrong state gets 400 and the real callback still completes with 200', async () => {
|
|
108
|
+
await withStubbedConfig(async () => {
|
|
109
|
+
const storedKeys = [];
|
|
110
|
+
config.setApiKey = (key) => { storedKeys.push(key); };
|
|
111
|
+
let forged;
|
|
112
|
+
let real;
|
|
113
|
+
const fakeOpen = async (authUrl) => {
|
|
114
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
115
|
+
setImmediate(async () => {
|
|
116
|
+
forged = await get(withCallbackParams(callbackUrl, { state: 'f'.repeat(32), api_key: 'sk-attacker' }));
|
|
117
|
+
// The login settles before this response body flushes, so hold the promise and await it below.
|
|
118
|
+
real = get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
122
|
+
assert.equal(forged.statusCode, 400);
|
|
123
|
+
assert.equal((await real).statusCode, 200, 'the forged request must not consume the one-shot callback');
|
|
124
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
125
|
+
assert.deepEqual(storedKeys, ['sk-real']);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('loginWithBrowser: a callback without state gets 400 and the login still completes', async () => {
|
|
130
|
+
await withStubbedConfig(async () => {
|
|
131
|
+
let bare;
|
|
132
|
+
const fakeOpen = async (authUrl) => {
|
|
133
|
+
const callbackUrl = new URL(new URL(authUrl).searchParams.get('callback_url'));
|
|
134
|
+
const noState = new URL(callbackUrl); noState.searchParams.delete('state'); noState.searchParams.set('api_key', 'sk-attacker');
|
|
135
|
+
setImmediate(async () => {
|
|
136
|
+
bare = await get(noState.toString());
|
|
137
|
+
await get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
141
|
+
assert.equal(bare.statusCode, 400);
|
|
142
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('loginWithBrowser: a wrong-state error= neither shows its text nor settles the login', async () => {
|
|
147
|
+
await withStubbedConfig(async () => {
|
|
148
|
+
let forged;
|
|
149
|
+
let callbackUrl;
|
|
150
|
+
const fakeOpen = async (authUrl) => {
|
|
151
|
+
callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
152
|
+
setImmediate(async () => {
|
|
153
|
+
forged = await get(withCallbackParams(callbackUrl, { state: '0'.repeat(32), error: 'ATTACKER-TEXT' }));
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
let settled = false;
|
|
157
|
+
const login = auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }).finally(() => { settled = true; });
|
|
158
|
+
await sleep(100);
|
|
159
|
+
assert.equal(forged.statusCode, 400);
|
|
160
|
+
assert.doesNotMatch(forged.body, /ATTACKER-TEXT/);
|
|
161
|
+
assert.equal(settled, false, 'a forged error must not reject the login');
|
|
162
|
+
// Finish the login so the server does not outlive the test.
|
|
163
|
+
await get(withCallbackParams(callbackUrl, { api_key: 'sk-real' }));
|
|
164
|
+
await login;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('loginWithBrowser: a 32-char multi-byte state gets 400 instead of crashing the server', async () => {
|
|
169
|
+
await withStubbedConfig(async () => {
|
|
170
|
+
let forged;
|
|
171
|
+
const fakeOpen = async (authUrl) => {
|
|
172
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
173
|
+
setImmediate(async () => {
|
|
174
|
+
// Same JS length as the real nonce, different UTF-8 byte length.
|
|
175
|
+
forged = await get(withCallbackParams(callbackUrl, { state: '\u00e9'.repeat(32), api_key: 'sk-attacker' }));
|
|
176
|
+
await get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
180
|
+
assert.equal(forged.statusCode, 400);
|
|
181
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('loginWithBrowser: each login mints a different state', async () => {
|
|
186
|
+
await withStubbedConfig(async () => {
|
|
187
|
+
const states = [];
|
|
188
|
+
const fakeOpen = async (authUrl) => {
|
|
189
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
190
|
+
states.push(new URL(callbackUrl).searchParams.get('state'));
|
|
191
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test' })));
|
|
192
|
+
};
|
|
193
|
+
await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
194
|
+
await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
195
|
+
assert.equal(states.length, 2);
|
|
196
|
+
assert.notEqual(states[0], states[1]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('loginWithBrowser: error= with the correct state still rejects with the dashboard reason', async () => {
|
|
201
|
+
await withStubbedConfig(async () => {
|
|
202
|
+
const fakeOpen = async (authUrl) => {
|
|
203
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
204
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { error: 'Role not permitted' })));
|
|
205
|
+
};
|
|
206
|
+
await assert.rejects(
|
|
207
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
|
|
208
|
+
/Role not permitted/,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -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
|
+
});
|