vvauth 2.1.1 → 2.2.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/.github/workflows/publish-npm.yml +1 -0
- package/README.md +74 -62
- package/index.js +156 -35
- package/package.json +2 -1
- package/test/token-cache.js +580 -0
package/README.md
CHANGED
|
@@ -1,62 +1,74 @@
|
|
|
1
|
-
Vault Creds manager
|
|
2
|
-
|
|
3
|
-
This projects helps you log yourself in a HCL vault and retrieve VAULT_TOKEN through different auth methods
|
|
4
|
-
* jwt login
|
|
5
|
-
* ssh (with agent) login
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
# Vauth configuration
|
|
9
|
-
## Vauth configuration file location
|
|
10
|
-
vauth configuration file lies on a `.vauthrc` file (this name can be controlled by the VAUTHRC env var).
|
|
11
|
-
vauth will try to find
|
|
12
|
-
* if specified, the VAUTHRC file
|
|
13
|
-
* fallback to a .vauthrc file in the current directory
|
|
14
|
-
* fallback to a .vauthrc file in the current user home directory
|
|
15
|
-
|
|
16
|
-
## Vauth configuration format
|
|
17
|
-
vauth configuration file is a simple yaml file with a specific macro expansion syntax for dynamic parts.
|
|
18
|
-
The configuration file should abide the following schema
|
|
19
|
-
|
|
20
|
-
### configuration macro expansion set
|
|
21
|
-
* $${profile.XXX} expand to vault entity metadata and user `.vauth_database` vars
|
|
22
|
-
* $${env.XXX} expand to local environement vars
|
|
23
|
-
* $${secrets.XXX} expand to remote scrapped secrets (see the env.paths)
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
# vauth URL
|
|
27
|
-
vault_addr: https://vauth.myserver.org
|
|
28
|
-
|
|
29
|
-
# for vauth-auth-plugin-ssh, configure the binding role here
|
|
30
|
-
ssh_auth:
|
|
31
|
-
role: $${env.VAUTH_USER_LOGIN}
|
|
32
|
-
env:
|
|
33
|
-
map:
|
|
34
|
-
TF_HTTP_USERNAME: $${profile.VAUTH_USER_LOGIN}
|
|
35
|
-
TF_HTTP_PASSWORD: $${profile.GITLAB_API_TOKEN}
|
|
36
|
-
AWS_ACCESS_KEY_ID: $${secrets.AWS_ACCESS_KEY_ID}
|
|
37
|
-
AWS_SECRET_ACCESS_KEY: $${secrets.AWS_SECRET_ACCESS_KEY}
|
|
38
|
-
|
|
39
|
-
# remote secrets mecanism
|
|
40
|
-
# set the secrets mount point - default to secrets
|
|
41
|
-
[path: secrets]
|
|
42
|
-
# list extra secrets to be reached and populated into the $${secrets.XXX} macro
|
|
43
|
-
paths:
|
|
44
|
-
- /some/pa4-backend.creds
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# Credits
|
|
50
|
-
* [Francois Leurent](https://github.com/131)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# Machine-readable environment
|
|
54
|
-
|
|
55
|
-
`vauth env --ir://json` resolves the same environment as `vauth env --source`
|
|
56
|
-
and asks the `cnyks` runner to write one JSON object to stdout. Diagnostic
|
|
57
|
-
messages remain on stderr, so a caller can parse stdout directly.
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
vauth env --ir://json
|
|
61
|
-
```
|
|
62
|
-
|
|
1
|
+
Vault Creds manager
|
|
2
|
+
|
|
3
|
+
This projects helps you log yourself in a HCL vault and retrieve VAULT_TOKEN through different auth methods
|
|
4
|
+
* jwt login
|
|
5
|
+
* ssh (with agent) login
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Vauth configuration
|
|
9
|
+
## Vauth configuration file location
|
|
10
|
+
vauth configuration file lies on a `.vauthrc` file (this name can be controlled by the VAUTHRC env var).
|
|
11
|
+
vauth will try to find
|
|
12
|
+
* if specified, the VAUTHRC file
|
|
13
|
+
* fallback to a .vauthrc file in the current directory
|
|
14
|
+
* fallback to a .vauthrc file in the current user home directory
|
|
15
|
+
|
|
16
|
+
## Vauth configuration format
|
|
17
|
+
vauth configuration file is a simple yaml file with a specific macro expansion syntax for dynamic parts.
|
|
18
|
+
The configuration file should abide the following schema
|
|
19
|
+
|
|
20
|
+
### configuration macro expansion set
|
|
21
|
+
* $${profile.XXX} expand to vault entity metadata and user `.vauth_database` vars
|
|
22
|
+
* $${env.XXX} expand to local environement vars
|
|
23
|
+
* $${secrets.XXX} expand to remote scrapped secrets (see the env.paths)
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
# vauth URL
|
|
27
|
+
vault_addr: https://vauth.myserver.org
|
|
28
|
+
|
|
29
|
+
# for vauth-auth-plugin-ssh, configure the binding role here
|
|
30
|
+
ssh_auth:
|
|
31
|
+
role: $${env.VAUTH_USER_LOGIN}
|
|
32
|
+
env:
|
|
33
|
+
map:
|
|
34
|
+
TF_HTTP_USERNAME: $${profile.VAUTH_USER_LOGIN}
|
|
35
|
+
TF_HTTP_PASSWORD: $${profile.GITLAB_API_TOKEN}
|
|
36
|
+
AWS_ACCESS_KEY_ID: $${secrets.AWS_ACCESS_KEY_ID}
|
|
37
|
+
AWS_SECRET_ACCESS_KEY: $${secrets.AWS_SECRET_ACCESS_KEY}
|
|
38
|
+
|
|
39
|
+
# remote secrets mecanism
|
|
40
|
+
# set the secrets mount point - default to secrets
|
|
41
|
+
[path: secrets]
|
|
42
|
+
# list extra secrets to be reached and populated into the $${secrets.XXX} macro
|
|
43
|
+
paths:
|
|
44
|
+
- /some/pa4-backend.creds
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Credits
|
|
50
|
+
* [Francois Leurent](https://github.com/131)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Machine-readable environment
|
|
54
|
+
|
|
55
|
+
`vauth env --ir://json` resolves the same environment as `vauth env --source`
|
|
56
|
+
and asks the `cnyks` runner to write one JSON object to stdout. Diagnostic
|
|
57
|
+
messages remain on stderr, so a caller can parse stdout directly.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
vauth env --ir://json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Token cache
|
|
65
|
+
|
|
66
|
+
Tokens are cached in `~/.vauth/token.json`, isolated by Vault and
|
|
67
|
+
authentication. Tokens are reused, renewed within their lease’s final third, or
|
|
68
|
+
replaced when expired. Existing `VAULT_TOKEN` takes precedence over cached tokens.
|
|
69
|
+
|
|
70
|
+
Set `token_cache: false` in `.vauthrc` to disable disk cache reads and writes.
|
|
71
|
+
|
|
72
|
+
## Force reauthentication
|
|
73
|
+
|
|
74
|
+
`venv --renew` forces authentication, generates and caches a new token.
|
package/index.js
CHANGED
|
@@ -5,9 +5,11 @@ const os = require('os');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const url = require('url');
|
|
8
|
+
const {createHash} = require('crypto');
|
|
8
9
|
const {spawn, execFileSync} = require('child_process');
|
|
9
10
|
const passthru = require('nyks/child_process/passthru');
|
|
10
11
|
const wait = require('nyks/child_process/wait');
|
|
12
|
+
const boolPrompt = require('cnyks/prompt/bool');
|
|
11
13
|
|
|
12
14
|
const {parse} = require('yaml');
|
|
13
15
|
const semver = require('semver');
|
|
@@ -15,6 +17,7 @@ const trim = require('mout/string/trim');
|
|
|
15
17
|
const get = require('mout/object/get');
|
|
16
18
|
const eachLimit = require('nyks/async/eachLimit');
|
|
17
19
|
const walk = require('nyks/object/walk');
|
|
20
|
+
const humanDiff = require('nyks/date/humanDiff');
|
|
18
21
|
|
|
19
22
|
const request = require('nyks/http/request');
|
|
20
23
|
const drain = require('nyks/stream/drain');
|
|
@@ -35,7 +38,7 @@ const logger = {
|
|
|
35
38
|
|
|
36
39
|
const VAUTH_RC = [process.env.VAUTHRC, path.join(process.cwd(), ".vauthrc"), path.join(os.homedir(), ".vauthrc")];
|
|
37
40
|
const FUNCTION_NAME = "venv";
|
|
38
|
-
const FUNCTION_DECL = `function ${FUNCTION_NAME}() { source <(/usr/bin/env vauth env --source); }`;
|
|
41
|
+
const FUNCTION_DECL = `function ${FUNCTION_NAME}() { source <(/usr/bin/env vauth env --source "$@"); }`;
|
|
39
42
|
|
|
40
43
|
class vvauth {
|
|
41
44
|
constructor() {
|
|
@@ -89,9 +92,7 @@ class vvauth {
|
|
|
89
92
|
this.VAULT_ADDR = this.rc.vault_addr || process.env.VAULT_ADDR; //might be null
|
|
90
93
|
this.VAULT_TOKEN = process.env.VAULT_TOKEN;
|
|
91
94
|
|
|
92
|
-
if(this.VAULT_ADDR)
|
|
93
|
-
console.error("vauth bound to '%s'", this.VAULT_ADDR);
|
|
94
|
-
else
|
|
95
|
+
if(!this.VAULT_ADDR)
|
|
95
96
|
console.error("Not bound to any vault");
|
|
96
97
|
|
|
97
98
|
}
|
|
@@ -103,22 +104,135 @@ class vvauth {
|
|
|
103
104
|
process.exit();
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
_token_cache_key() {
|
|
108
|
+
const {ssh_auth, jwt_auth} = this.rc;
|
|
109
|
+
return createHash('sha256').update(JSON.stringify({
|
|
110
|
+
vault_addr : trim(this.VAULT_ADDR || '', '/'), ssh_auth, jwt_auth,
|
|
111
|
+
})).digest('hex');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_token_cache_file() {
|
|
115
|
+
return path.join(os.homedir(), '.vauth', 'token.json');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_read_token_cache() {
|
|
119
|
+
if(this.rc.token_cache === false)
|
|
120
|
+
return {version : 1, tokens : {}};
|
|
121
|
+
try {
|
|
122
|
+
const cache = JSON.parse(fs.readFileSync(this._token_cache_file(), 'utf8'));
|
|
123
|
+
if(cache.version === 1 && cache.tokens && typeof cache.tokens === 'object' && !Array.isArray(cache.tokens))
|
|
124
|
+
return cache;
|
|
125
|
+
} catch(err) {}
|
|
126
|
+
return {version : 1, tokens : {}};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_save_cached_token(token, metadata) {
|
|
130
|
+
if(this.rc.token_cache === false)
|
|
131
|
+
return;
|
|
132
|
+
const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
|
|
133
|
+
const file = this._token_cache_file();
|
|
134
|
+
const temporary = file + '.next';
|
|
135
|
+
try {
|
|
136
|
+
const cache = this._read_token_cache();
|
|
137
|
+
const key = this._token_cache_key();
|
|
138
|
+
const {lease_duration} = metadata;
|
|
139
|
+
cache.tokens[key] = {
|
|
140
|
+
token, lease_duration, renewable : metadata.renewable === true,
|
|
141
|
+
expires_at : ttl === 0 ? null : Date.now() + ttl * 1000,
|
|
142
|
+
};
|
|
143
|
+
fs.mkdirSync(path.dirname(file), {recursive : true, mode : 0o700});
|
|
144
|
+
fs.writeFileSync(temporary, JSON.stringify(cache, null, 2) + '\n', {mode : 0o600});
|
|
145
|
+
fs.renameSync(temporary, file);
|
|
146
|
+
} catch(err) {
|
|
147
|
+
// Cache failures must not prevent authentication or expose credentials.
|
|
148
|
+
logger.error('Could not write token cache (%s)', err.code || 'invalid cache');
|
|
149
|
+
} finally {
|
|
150
|
+
try { fs.unlinkSync(temporary); } catch(err) {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_token_status(operation, metadata) {
|
|
155
|
+
const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
|
|
156
|
+
const validity = ttl === 0 ? 'valid indefinitely' : humanDiff(ttl, 2);
|
|
157
|
+
console.error('Vauth token: %s · %s · %s', operation, this.VAULT_ADDR, validity);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async connect(renew = false) {
|
|
161
|
+
if(this._connected && !renew)
|
|
162
|
+
return this.VAULT_TOKEN;
|
|
163
|
+
this._connected = false;
|
|
164
|
+
|
|
165
|
+
const cached = this._read_token_cache().tokens[this._token_cache_key()];
|
|
166
|
+
let token = this.VAULT_TOKEN || (cached && cached.token);
|
|
167
|
+
const had_token = !!token;
|
|
168
|
+
let metadata, operation = 'reused';
|
|
169
|
+
if(renew || (cached && token === cached.token && cached.expires_at !== null && cached.expires_at <= Date.now()))
|
|
170
|
+
token = undefined;
|
|
171
|
+
|
|
172
|
+
if(token) {
|
|
173
|
+
try {
|
|
174
|
+
if(cached && cached.token === token) {
|
|
175
|
+
const ttl = cached.expires_at === null ? 0 : (cached.expires_at - Date.now()) / 1000;
|
|
176
|
+
metadata = {...cached, ttl};
|
|
177
|
+
} else {
|
|
178
|
+
metadata = await this._lookup_token(token);
|
|
179
|
+
}
|
|
180
|
+
const {ttl, renewable, lease_duration} = metadata;
|
|
181
|
+
if(ttl > 0 && ttl <= lease_duration / 3) {
|
|
182
|
+
if(renewable) {
|
|
183
|
+
metadata = await this._renew_token(token);
|
|
184
|
+
token = metadata.client_token;
|
|
185
|
+
operation = 'renewed';
|
|
186
|
+
} else {
|
|
187
|
+
token = undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch(err) {
|
|
191
|
+
// Reauthenticate on invalid tokens, not network/server failures.
|
|
192
|
+
const status = err.res && err.res.statusCode;
|
|
193
|
+
if(![400, 403].includes(status))
|
|
194
|
+
throw err;
|
|
195
|
+
token = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
108
198
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
199
|
+
if(!token) {
|
|
200
|
+
const {ssh_auth, jwt_auth} = this.rc;
|
|
201
|
+
const agent_path = process.env.SSH_AUTH_SOCK || (process.platform === 'win32' ? 'pageant' : null);
|
|
202
|
+
if(ssh_auth && agent_path) {
|
|
203
|
+
metadata = await this._login_vault_ssh({...ssh_auth}, agent_path);
|
|
204
|
+
} else if(jwt_auth && jwt_auth.jwt) {
|
|
205
|
+
const {path, jwt, role} = jwt_auth;
|
|
206
|
+
metadata = await this._login_vault(path, {jwt, role});
|
|
207
|
+
}
|
|
208
|
+
token = metadata && metadata.client_token;
|
|
209
|
+
if(!token && (renew || had_token))
|
|
210
|
+
throw new Error('Could not login to vault: no authentication method available');
|
|
211
|
+
operation = 'generated';
|
|
212
|
+
}
|
|
112
213
|
|
|
113
|
-
if(
|
|
114
|
-
|
|
115
|
-
|
|
214
|
+
if(token) {
|
|
215
|
+
if(operation !== 'reused' || !cached || cached.token !== token)
|
|
216
|
+
this._save_cached_token(token, metadata);
|
|
217
|
+
this._token_status(operation, metadata);
|
|
116
218
|
}
|
|
117
|
-
this.VAULT_TOKEN =
|
|
219
|
+
this.VAULT_TOKEN = token;
|
|
220
|
+
this._connected = true;
|
|
221
|
+
return token;
|
|
118
222
|
}
|
|
119
223
|
|
|
120
|
-
async
|
|
121
|
-
|
|
224
|
+
async _renew_token(token) {
|
|
225
|
+
const remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/token/renew-self`;
|
|
226
|
+
const query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200, json : true};
|
|
227
|
+
const res = await request(query, {});
|
|
228
|
+
const auth = JSON.parse(String(await drain(res))).auth;
|
|
229
|
+
if(!auth.client_token || !Number.isFinite(auth.lease_duration) || auth.lease_duration < 0)
|
|
230
|
+
throw new Error('Invalid Vault token renewal response');
|
|
231
|
+
return auth;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async login(source = false, renew = false) {
|
|
235
|
+
await this.connect(renew);
|
|
122
236
|
if(source) {
|
|
123
237
|
let env = {VAULT_TOKEN : this.VAULT_TOKEN};
|
|
124
238
|
this._publish_env(env);
|
|
@@ -154,15 +268,15 @@ class vvauth {
|
|
|
154
268
|
return {...database, ...profile};
|
|
155
269
|
}
|
|
156
270
|
|
|
157
|
-
async _vault_get_profile() {
|
|
158
|
-
await this.connect();
|
|
271
|
+
async _vault_get_profile(renew = false) {
|
|
272
|
+
await this.connect(renew);
|
|
159
273
|
|
|
160
274
|
if(!this.VAULT_TOKEN)
|
|
161
275
|
return {};
|
|
162
276
|
|
|
163
277
|
let {entity_id} = await this._lookup_token(this.VAULT_TOKEN);
|
|
164
278
|
let identity = await this._lookup_identity(this.VAULT_TOKEN, entity_id);
|
|
165
|
-
let profile = {...(identity.metadata
|
|
279
|
+
let profile = {...(identity.metadata)};
|
|
166
280
|
let database = {};
|
|
167
281
|
|
|
168
282
|
if(profile.VAUTH_USER_LOGIN)
|
|
@@ -171,8 +285,8 @@ class vvauth {
|
|
|
171
285
|
return {entity_id, identity, profile, database};
|
|
172
286
|
}
|
|
173
287
|
|
|
174
|
-
async _get_env() {
|
|
175
|
-
let {profile, database} = await this._vault_get_profile();
|
|
288
|
+
async _get_env(renew = false) {
|
|
289
|
+
let {profile, database} = await this._vault_get_profile(renew);
|
|
176
290
|
profile = {...database, ...profile};
|
|
177
291
|
|
|
178
292
|
let env = {}, secrets = {},
|
|
@@ -234,8 +348,8 @@ class vvauth {
|
|
|
234
348
|
process.exit();
|
|
235
349
|
}
|
|
236
350
|
|
|
237
|
-
async env(source = false) {
|
|
238
|
-
const env = await this._get_env();
|
|
351
|
+
async env(source = false, renew = false) {
|
|
352
|
+
const env = await this._get_env(renew);
|
|
239
353
|
|
|
240
354
|
if(source) {
|
|
241
355
|
this._publish_env(env);
|
|
@@ -280,9 +394,9 @@ class vvauth {
|
|
|
280
394
|
let keys = await promiser(chain => agent.getIdentities(chain));
|
|
281
395
|
|
|
282
396
|
|
|
283
|
-
let
|
|
397
|
+
let auth;
|
|
284
398
|
await eachLimit(keys, 1, async (pubKey) => {
|
|
285
|
-
if(
|
|
399
|
+
if(auth)
|
|
286
400
|
return;
|
|
287
401
|
|
|
288
402
|
let remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/${path}/nonce`;
|
|
@@ -294,17 +408,17 @@ class vvauth {
|
|
|
294
408
|
const public_key = pubKey.type + ' ' + pubKey.getPublicSSH().toString('base64');
|
|
295
409
|
const payload = {public_key, role, nonce : Buffer.from(nonce).toString('base64'), signature};
|
|
296
410
|
try {
|
|
297
|
-
|
|
411
|
+
auth = await this._login_vault(path, payload);
|
|
298
412
|
} catch(err) {
|
|
299
413
|
logger.debug("ssh : invalid challenge for public key", pubKey.comment);
|
|
300
414
|
}
|
|
301
415
|
});
|
|
302
416
|
|
|
303
417
|
|
|
304
|
-
if(!
|
|
418
|
+
if(!auth || !auth.client_token)
|
|
305
419
|
throw `Could not login to vault`;
|
|
306
420
|
|
|
307
|
-
return
|
|
421
|
+
return auth;
|
|
308
422
|
}
|
|
309
423
|
async _function_exists(alias) {
|
|
310
424
|
let child = spawn('bash', ["-lc", `declare -F ${alias}`]);
|
|
@@ -314,12 +428,11 @@ class vvauth {
|
|
|
314
428
|
async install() {
|
|
315
429
|
const bashrc_path = path.resolve(os.homedir(), ".bashrc");
|
|
316
430
|
let bashrc = fs.existsSync(bashrc_path) ? fs.readFileSync(bashrc_path, 'utf-8').trim() : '';
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return;
|
|
431
|
+
if(await this._function_exists(FUNCTION_NAME) === 0) {
|
|
432
|
+
if(!await boolPrompt("Function already defined, override current definition? ", false))
|
|
433
|
+
return;
|
|
321
434
|
}
|
|
322
|
-
console.error("
|
|
435
|
+
console.error("Installing function %s in %s", FUNCTION_NAME, bashrc_path);
|
|
323
436
|
|
|
324
437
|
fs.writeFileSync(bashrc_path, [bashrc, FUNCTION_DECL, ""].join("\n"));
|
|
325
438
|
console.error(`Installation ok, please \nsource ${bashrc_path}`);
|
|
@@ -330,6 +443,13 @@ class vvauth {
|
|
|
330
443
|
let query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200};
|
|
331
444
|
let res = await request(query);
|
|
332
445
|
let response = JSON.parse(await drain(res)).data;
|
|
446
|
+
if(!Number.isFinite(response.ttl) || response.ttl < 0)
|
|
447
|
+
throw new Error('Invalid Vault token TTL');
|
|
448
|
+
const cached = this._read_token_cache().tokens[this._token_cache_key()];
|
|
449
|
+
if(cached && cached.token === token)
|
|
450
|
+
response.lease_duration = cached.lease_duration;
|
|
451
|
+
else
|
|
452
|
+
response.lease_duration = response.creation_ttl;
|
|
333
453
|
return response;
|
|
334
454
|
}
|
|
335
455
|
|
|
@@ -358,9 +478,10 @@ class vvauth {
|
|
|
358
478
|
if(res.statusCode !== 200)
|
|
359
479
|
throw `Could not login to vault : ${response}`;
|
|
360
480
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
481
|
+
const auth = JSON.parse(response).auth;
|
|
482
|
+
if(!auth.client_token || !Number.isFinite(auth.lease_duration) || auth.lease_duration < 0)
|
|
483
|
+
throw new Error('Invalid Vault login response');
|
|
484
|
+
return auth;
|
|
364
485
|
}
|
|
365
486
|
|
|
366
487
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vvauth",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Vault Auth helper",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"vauth": "./index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
+
"test": "node --test test/token-cache.js",
|
|
10
11
|
"eslint": "eslint ."
|
|
11
12
|
},
|
|
12
13
|
"author": "",
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const {test} = require('node:test');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const {execFile, spawn} = require('child_process');
|
|
10
|
+
const {promisify, format} = require('util');
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
const Vauth = require('../index');
|
|
13
|
+
|
|
14
|
+
const HOUR = 60 * 60;
|
|
15
|
+
|
|
16
|
+
async function fixture(t, options = {}) {
|
|
17
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-test-'));
|
|
18
|
+
const file = path.join(directory, '.vauth', 'token.json');
|
|
19
|
+
const requests = [];
|
|
20
|
+
const server = http.createServer(async (req, res) => {
|
|
21
|
+
let body = '';
|
|
22
|
+
for await(const chunk of req)
|
|
23
|
+
body += chunk;
|
|
24
|
+
requests.push({path : req.url, method : req.method, token : req.headers['x-vault-token'], body});
|
|
25
|
+
res.setHeader('content-type', 'application/json');
|
|
26
|
+
if(req.url === '/v1/auth/jwt/login') {
|
|
27
|
+
res.end(JSON.stringify({auth : {client_token : 'fresh-token', lease_duration : 24 * HOUR, renewable : true}}));
|
|
28
|
+
} else if(req.url === '/v1/auth/token/lookup-self') {
|
|
29
|
+
res.statusCode = options.lookup_status || 200;
|
|
30
|
+
res.end(JSON.stringify({data : {ttl : options.ttl === undefined ? 12 * HOUR : options.ttl, renewable : options.renewable !== false, creation_ttl : options.creation_ttl === undefined ? 24 * HOUR : options.creation_ttl, entity_id : 'test-entity'}}));
|
|
31
|
+
} else if(req.url === '/v1/auth/token/renew-self') {
|
|
32
|
+
res.statusCode = options.renew_status || 200;
|
|
33
|
+
res.end(JSON.stringify({auth : {client_token : req.headers['x-vault-token'], lease_duration : 24 * HOUR, renewable : true}}));
|
|
34
|
+
} else if(req.url === '/v1/identity/entity/id/test-entity') {
|
|
35
|
+
res.end(JSON.stringify({data : {metadata : {}}}));
|
|
36
|
+
} else {
|
|
37
|
+
res.statusCode = 404;
|
|
38
|
+
res.end('{}');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
42
|
+
t.after(async () => {
|
|
43
|
+
await new Promise(resolve => server.close(resolve));
|
|
44
|
+
fs.rmSync(directory, {recursive : true, force : true});
|
|
45
|
+
});
|
|
46
|
+
const client = () => {
|
|
47
|
+
// Avoid reading the real user's environment/configuration/cache.
|
|
48
|
+
const instance = Object.create(Vauth.prototype);
|
|
49
|
+
instance.rc = {jwt_auth : {path : 'jwt', jwt : 'test-jwt', role : 'test-role'}};
|
|
50
|
+
instance.VAULT_ADDR = `http://127.0.0.1:${server.address().port}`;
|
|
51
|
+
instance._token_cache_file = () => file;
|
|
52
|
+
return instance;
|
|
53
|
+
};
|
|
54
|
+
const remaining = (instance, ttl) => {
|
|
55
|
+
const cache = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
56
|
+
cache.tokens[instance._token_cache_key()].expires_at = ttl === 0 ? null : Date.now() + ttl * 1000;
|
|
57
|
+
fs.writeFileSync(file, JSON.stringify(cache));
|
|
58
|
+
};
|
|
59
|
+
const seed = (instance, lease = 12 * HOUR) => {
|
|
60
|
+
instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
|
|
61
|
+
if(options.ttl !== undefined)
|
|
62
|
+
remaining(instance, options.ttl);
|
|
63
|
+
};
|
|
64
|
+
const read = instance => JSON.parse(fs.readFileSync(file, 'utf8')).tokens[instance._token_cache_key()];
|
|
65
|
+
return {client, seed, read, requests, file, remaining};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('login persists a token; a new instance reuses it without logging in', async t => {
|
|
69
|
+
const {client, read, requests, file} = await fixture(t);
|
|
70
|
+
const first = client();
|
|
71
|
+
assert.equal(await first.connect(), 'fresh-token');
|
|
72
|
+
assert.equal(read(first).token, 'fresh-token');
|
|
73
|
+
assert.ok(read(first).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
74
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
75
|
+
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
|
|
76
|
+
const second = client();
|
|
77
|
+
assert.equal(await second.connect(), 'fresh-token');
|
|
78
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
79
|
+
assert.deepEqual(fs.readdirSync(path.dirname(file)), ['token.json']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a token in the last third of its lease is renewed and saved', async t => {
|
|
83
|
+
const {client, seed, read, requests} = await fixture(t, {ttl : HOUR});
|
|
84
|
+
const instance = client();
|
|
85
|
+
seed(instance, 12 * HOUR);
|
|
86
|
+
assert.equal(await instance.connect(), 'cached-token');
|
|
87
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
|
|
88
|
+
assert.equal(requests[0].method, 'POST');
|
|
89
|
+
assert.equal(requests[0].token, 'cached-token');
|
|
90
|
+
assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('more than one third remaining does not trigger renewal', async t => {
|
|
94
|
+
const {client, seed, requests} = await fixture(t, {ttl : 4 * HOUR + 1});
|
|
95
|
+
const instance = client();
|
|
96
|
+
seed(instance);
|
|
97
|
+
assert.equal(await instance.connect(), 'cached-token');
|
|
98
|
+
assert.equal(requests.length, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('expired cached tokens are replaced with a new persisted login', async t => {
|
|
102
|
+
const {client, seed, read, requests, file} = await fixture(t);
|
|
103
|
+
const instance = client();
|
|
104
|
+
seed(instance);
|
|
105
|
+
const cache = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
106
|
+
cache.tokens[instance._token_cache_key()].expires_at = Date.now() - 1000;
|
|
107
|
+
fs.writeFileSync(file, JSON.stringify(cache));
|
|
108
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
109
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
110
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('cached tokens are reused without checking revocation', async t => {
|
|
114
|
+
const {client, seed, requests} = await fixture(t, {lookup_status : 403});
|
|
115
|
+
const instance = client();
|
|
116
|
+
seed(instance);
|
|
117
|
+
assert.equal(await instance.connect(), 'cached-token');
|
|
118
|
+
assert.deepEqual(requests, []);
|
|
119
|
+
await assert.rejects(instance._lookup_token(instance.VAULT_TOKEN));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('nonrenewable tokens nearing expiry trigger reauthentication', async t => {
|
|
123
|
+
const {client, seed, requests} = await fixture(t, {ttl : HOUR, renewable : false});
|
|
124
|
+
const instance = client();
|
|
125
|
+
seed(instance);
|
|
126
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
127
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
for(const status of [400, 403]) {
|
|
131
|
+
test(`rejected renewal (${status}) triggers a new persisted login`, async t => {
|
|
132
|
+
const {client, seed, read, requests} = await fixture(t, {ttl : HOUR, renew_status : status});
|
|
133
|
+
const instance = client();
|
|
134
|
+
seed(instance);
|
|
135
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
136
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
137
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self', '/v1/auth/jwt/login']);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test('server failures do not generate extra tokens, and connect can be retried', async t => {
|
|
142
|
+
const {client, seed, requests} = await fixture(t, {lookup_status : 500});
|
|
143
|
+
const instance = client();
|
|
144
|
+
seed(instance);
|
|
145
|
+
instance.VAULT_TOKEN = 'uncached-token';
|
|
146
|
+
await assert.rejects(instance.connect());
|
|
147
|
+
await assert.rejects(instance.connect());
|
|
148
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/lookup-self', '/v1/auth/token/lookup-self']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('renewal server failures do not generate extra tokens', async t => {
|
|
152
|
+
const {client, seed, requests} = await fixture(t, {ttl : HOUR, renew_status : 500});
|
|
153
|
+
const instance = client();
|
|
154
|
+
seed(instance);
|
|
155
|
+
await assert.rejects(instance.connect());
|
|
156
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('an existing environment token is respected and cached', async t => {
|
|
160
|
+
const {client, seed, read, requests} = await fixture(t);
|
|
161
|
+
const instance = client();
|
|
162
|
+
seed(instance);
|
|
163
|
+
instance.VAULT_TOKEN = 'environment-token';
|
|
164
|
+
assert.equal(await instance.connect(), 'environment-token');
|
|
165
|
+
assert.equal(read(instance).token, 'environment-token');
|
|
166
|
+
assert.equal(requests[0].token, 'environment-token');
|
|
167
|
+
assert.equal(requests.length, 1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('nonexpiring tokens are reused without renewal', async t => {
|
|
171
|
+
const {client, seed, read, requests} = await fixture(t, {ttl : 0});
|
|
172
|
+
const instance = client();
|
|
173
|
+
seed(instance, 0);
|
|
174
|
+
assert.equal(await instance.connect(), 'cached-token');
|
|
175
|
+
assert.equal(read(instance).expires_at, null);
|
|
176
|
+
assert.equal(requests.length, 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('corrupt caches are replaced with a valid cache', async t => {
|
|
180
|
+
const {client, read, file} = await fixture(t);
|
|
181
|
+
fs.mkdirSync(path.dirname(file));
|
|
182
|
+
fs.writeFileSync(file, '{broken');
|
|
183
|
+
const instance = client();
|
|
184
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
185
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('cache entries are isolated by Vault address and authentication configuration', async t => {
|
|
189
|
+
const {client, seed, read, file} = await fixture(t);
|
|
190
|
+
const first = client();
|
|
191
|
+
seed(first);
|
|
192
|
+
const second = client();
|
|
193
|
+
second.rc.jwt_auth.role = 'other-role';
|
|
194
|
+
assert.equal(await second.connect(), 'fresh-token');
|
|
195
|
+
assert.equal(read(first).token, 'cached-token');
|
|
196
|
+
assert.equal(read(second).token, 'fresh-token');
|
|
197
|
+
const third = client();
|
|
198
|
+
third.VAULT_ADDR = 'http://other-vault.test';
|
|
199
|
+
assert.notEqual(third._token_cache_key(), first._token_cache_key());
|
|
200
|
+
assert.equal(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8')).tokens).length, 2);
|
|
201
|
+
assert.ok(!fs.readFileSync(file, 'utf8').includes('test-jwt'));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('repeated connect calls reuse the established connection', async t => {
|
|
205
|
+
const {client, requests} = await fixture(t);
|
|
206
|
+
const instance = client();
|
|
207
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
208
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
209
|
+
assert.equal(requests.length, 1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an unwritable cache does not prevent login', async t => {
|
|
213
|
+
const {client, file} = await fixture(t);
|
|
214
|
+
fs.writeFileSync(path.dirname(file), 'not a directory');
|
|
215
|
+
assert.equal(await client().connect(), 'fresh-token');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('an invalid token without an auth method fails rather than exporting undefined', async t => {
|
|
219
|
+
const {client, seed} = await fixture(t, {lookup_status : 403});
|
|
220
|
+
const instance = client();
|
|
221
|
+
instance.rc = {};
|
|
222
|
+
seed(instance);
|
|
223
|
+
instance.VAULT_TOKEN = 'uncached-token';
|
|
224
|
+
await assert.rejects(instance.connect(), /no authentication method/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('--renew ignores both environment and cached tokens and saves a new token', async t => {
|
|
228
|
+
const {client, seed, read, requests} = await fixture(t);
|
|
229
|
+
const instance = client();
|
|
230
|
+
seed(instance);
|
|
231
|
+
instance.VAULT_TOKEN = 'environment-token';
|
|
232
|
+
assert.equal(await instance.connect(true), 'fresh-token');
|
|
233
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
234
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
235
|
+
assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('--renew replaces the token of a previously connected instance', async t => {
|
|
239
|
+
const {client, seed, read, requests} = await fixture(t);
|
|
240
|
+
const instance = client();
|
|
241
|
+
seed(instance);
|
|
242
|
+
await instance.connect();
|
|
243
|
+
assert.equal(await instance.connect(true), 'fresh-token');
|
|
244
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
245
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('each successive --renew call forces a new login', async t => {
|
|
249
|
+
const {client, requests} = await fixture(t);
|
|
250
|
+
const instance = client();
|
|
251
|
+
assert.equal(await instance.connect(true), 'fresh-token');
|
|
252
|
+
assert.equal(await instance.connect(true), 'fresh-token');
|
|
253
|
+
assert.equal(requests.length, 2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('--renew without an authentication method fails even with no cached token', async t => {
|
|
257
|
+
const {client} = await fixture(t);
|
|
258
|
+
const instance = client();
|
|
259
|
+
instance.rc = {};
|
|
260
|
+
await assert.rejects(instance.connect(true), /no authentication method/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
for(const flag of ['renew']) {
|
|
264
|
+
test(`env forwards --${flag} into forced reauthentication`, async t => {
|
|
265
|
+
const {client} = await fixture(t);
|
|
266
|
+
const instance = client();
|
|
267
|
+
let forced;
|
|
268
|
+
instance.connect = async renew => { forced = renew; };
|
|
269
|
+
assert.deepEqual(await instance.env(false, true), {VAULT_ADDR : instance.VAULT_ADDR});
|
|
270
|
+
assert.equal(forced, true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
for(const command of ['env', 'login']) {
|
|
274
|
+
test(`real CLI ${command} --${flag} generates and caches a new token`, async t => {
|
|
275
|
+
const {client, seed, read, requests, file} = await fixture(t);
|
|
276
|
+
const instance = client();
|
|
277
|
+
seed(instance);
|
|
278
|
+
const home = path.dirname(path.dirname(file));
|
|
279
|
+
const rc = path.join(home, '.vauthrc');
|
|
280
|
+
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
|
|
281
|
+
const options = {
|
|
282
|
+
cwd : home,
|
|
283
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : ''},
|
|
284
|
+
};
|
|
285
|
+
const cli = path.resolve(__dirname, '..', 'index.js');
|
|
286
|
+
const output = await exec(process.execPath, [cli, command, '--source', `--${flag}`], options);
|
|
287
|
+
assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
|
|
288
|
+
assert.ok(!output.stdout.includes('Vauth token: '));
|
|
289
|
+
assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
|
|
290
|
+
assert.ok(!output.stderr.includes('Vauth token: renewed'));
|
|
291
|
+
assert.ok(!output.stderr.includes('fresh-token'));
|
|
292
|
+
assert.ok(!output.stderr.includes('environment-token'));
|
|
293
|
+
assert.deepEqual(requests.filter(req => !req.path.startsWith('/v1/identity/')).map(req => req.path), command === 'env'
|
|
294
|
+
? ['/v1/auth/jwt/login', '/v1/auth/token/lookup-self'] : ['/v1/auth/jwt/login']);
|
|
295
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
296
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
297
|
+
requests.length = 0;
|
|
298
|
+
const reused = await exec(process.execPath, [cli, command, '--source'], {
|
|
299
|
+
...options, env : {...options.env, VAULT_TOKEN : ''},
|
|
300
|
+
});
|
|
301
|
+
assert.ok(reused.stdout.includes("export VAULT_TOKEN='fresh-token'"));
|
|
302
|
+
assert.equal(reused.stderr.split('\n').filter(line => line.startsWith('Vauth token: ')).length, 1);
|
|
303
|
+
assert.ok(reused.stderr.includes(`Vauth token: reused · ${instance.VAULT_ADDR} · 1d`));
|
|
304
|
+
assert.ok(!requests.some(req => req.path.endsWith('/login') || req.path.endsWith('/renew-self')));
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
test('installed venv forwards --renew through the real CLI', async t => {
|
|
310
|
+
const {client, seed, read, requests, file} = await fixture(t);
|
|
311
|
+
const instance = client();
|
|
312
|
+
seed(instance);
|
|
313
|
+
const home = path.dirname(path.dirname(file));
|
|
314
|
+
const original_home = os.homedir;
|
|
315
|
+
instance._function_exists = async () => 1;
|
|
316
|
+
try {
|
|
317
|
+
os.homedir = () => home;
|
|
318
|
+
await instance.install();
|
|
319
|
+
} finally {
|
|
320
|
+
os.homedir = original_home;
|
|
321
|
+
}
|
|
322
|
+
const declaration = fs.readFileSync(path.join(home, '.bashrc'), 'utf8');
|
|
323
|
+
assert.ok(declaration.includes('vauth env --source "$@"'));
|
|
324
|
+
const bin = path.join(home, 'bin');
|
|
325
|
+
fs.mkdirSync(bin);
|
|
326
|
+
fs.symlinkSync(path.resolve(__dirname, '..', 'index.js'), path.join(bin, 'vauth'));
|
|
327
|
+
const rc = path.join(home, '.vauthrc');
|
|
328
|
+
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
|
|
329
|
+
const options = {
|
|
330
|
+
cwd : home,
|
|
331
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : '', PATH : `${bin}:${process.env.PATH}`},
|
|
332
|
+
};
|
|
333
|
+
const initial = await exec('bash', ['-c', `${declaration}\nvenv; printf '%s' "$VAULT_TOKEN"`], options);
|
|
334
|
+
assert.equal(initial.stdout, 'cached-token');
|
|
335
|
+
assert.equal(initial.stderr.split('\n').filter(line => line.startsWith('Vauth token: ')).length, 1);
|
|
336
|
+
assert.ok(initial.stderr.includes(`Vauth token: reused · ${instance.VAULT_ADDR} · 12h`));
|
|
337
|
+
assert.ok(!requests.some(req => req.path.endsWith('/login') || req.path.endsWith('/renew-self')));
|
|
338
|
+
for(const flag of ['--renew']) {
|
|
339
|
+
const output = await exec('bash', ['-c', `${declaration}\nvenv ${flag}; printf '%s' "$VAULT_TOKEN"`], options);
|
|
340
|
+
assert.equal(output.stdout, 'fresh-token');
|
|
341
|
+
assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
|
|
342
|
+
assert.ok(!output.stderr.includes('Vauth token: renewed'));
|
|
343
|
+
assert.ok(!output.stderr.includes('fresh-token'));
|
|
344
|
+
assert.equal(read(instance).token, 'fresh-token');
|
|
345
|
+
}
|
|
346
|
+
assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 1);
|
|
347
|
+
assert.ok(!requests.some(req => req.path.endsWith('/renew-self')));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
for(const answer of ['yes', 'no', '']) {
|
|
351
|
+
test(`install asks before overriding an existing function: ${JSON.stringify(answer)}`, async t => {
|
|
352
|
+
const {file} = await fixture(t);
|
|
353
|
+
const home = path.dirname(path.dirname(file));
|
|
354
|
+
const bashrc = path.join(home, '.bashrc');
|
|
355
|
+
const original = '# before\nfunction venv() { source <(/usr/bin/env vauth env --source); }\n# after\n';
|
|
356
|
+
fs.writeFileSync(bashrc, original);
|
|
357
|
+
const module_path = path.resolve(__dirname, '..', 'index.js');
|
|
358
|
+
const script = `const Vauth = require(${JSON.stringify(module_path)});
|
|
359
|
+
const instance = Object.create(Vauth.prototype);
|
|
360
|
+
instance._function_exists = async () => 0;
|
|
361
|
+
instance.install().catch(() => process.exit(1));`;
|
|
362
|
+
const child = spawn(process.execPath, ['-e', script], {cwd : home, env : {...process.env, HOME : home}});
|
|
363
|
+
let output = '', prompted = false;
|
|
364
|
+
child.stdout.on('data', chunk => {
|
|
365
|
+
output += chunk;
|
|
366
|
+
if(!prompted && output.includes('[y/N]')) {
|
|
367
|
+
prompted = true;
|
|
368
|
+
child.stdin.end(answer + '\n');
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
child.stderr.resume();
|
|
372
|
+
const exit = await new Promise((resolve, reject) => {
|
|
373
|
+
child.on('error', reject);
|
|
374
|
+
child.on('exit', resolve);
|
|
375
|
+
});
|
|
376
|
+
assert.equal(exit, 0);
|
|
377
|
+
assert.ok(output.includes('Function already defined, override current definition?'));
|
|
378
|
+
const declaration = 'function venv() { source <(/usr/bin/env vauth env --source "$@"); }\n';
|
|
379
|
+
assert.equal(fs.readFileSync(bashrc, 'utf8'), answer === 'yes' ? original + declaration : original);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function captureStatus(callback) {
|
|
384
|
+
const lines = [];
|
|
385
|
+
const original = console.error;
|
|
386
|
+
console.error = (...args) => lines.push(format(...args));
|
|
387
|
+
try {
|
|
388
|
+
callback();
|
|
389
|
+
} finally {
|
|
390
|
+
console.error = original;
|
|
391
|
+
}
|
|
392
|
+
return lines;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
test('token status uses humanDiff with two significant units', () => {
|
|
396
|
+
const instance = Object.create(Vauth.prototype);
|
|
397
|
+
instance.VAULT_ADDR = 'https://vault.example.test';
|
|
398
|
+
const cases = [
|
|
399
|
+
[2 * 86400 + 3 * HOUR + 20 * 60 + 5, '2d 3h'],
|
|
400
|
+
[12 * HOUR + 30 * 60 + 5, '12h 30m'],
|
|
401
|
+
[5 * 60 + 20, '5m 20s'],
|
|
402
|
+
[86400, '1d'],
|
|
403
|
+
[30 * 60, '30m'],
|
|
404
|
+
[45, '45s'],
|
|
405
|
+
[86400 + 20, '1d 20s'],
|
|
406
|
+
[HOUR + 5, '1h 5s'],
|
|
407
|
+
[400 * 86400 + 2 * HOUR, '1y 1month'],
|
|
408
|
+
[60.9, '1m 1s'],
|
|
409
|
+
];
|
|
410
|
+
for(const [ttl, duration] of cases) {
|
|
411
|
+
assert.deepEqual(captureStatus(() => instance._token_status('reused', {ttl})), [
|
|
412
|
+
`Vauth token: reused · ${instance.VAULT_ADDR} · ${duration}`,
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
assert.deepEqual(captureStatus(() => instance._token_status('renewed', {lease_duration : HOUR + 60})), [
|
|
416
|
+
`Vauth token: renewed · ${instance.VAULT_ADDR} · 1h 1m`,
|
|
417
|
+
]);
|
|
418
|
+
assert.deepEqual(captureStatus(() => instance._token_status('generated', {lease_duration : 0})), [
|
|
419
|
+
`Vauth token: generated · ${instance.VAULT_ADDR} · valid indefinitely`,
|
|
420
|
+
]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
for(const options of [
|
|
424
|
+
{ttl : 12 * HOUR, operation : 'reused', duration : '12h'},
|
|
425
|
+
{ttl : HOUR, operation : 'renewed', duration : '1d'},
|
|
426
|
+
{ttl : HOUR, renew_status : 400, operation : 'generated', duration : '1d'},
|
|
427
|
+
{ttl : HOUR, renew_status : 403, operation : 'generated', duration : '1d'},
|
|
428
|
+
{ttl : 0, operation : 'reused', duration : null},
|
|
429
|
+
]) {
|
|
430
|
+
test(`one final status per connection: ${JSON.stringify(options)}`, async t => {
|
|
431
|
+
const {client, seed} = await fixture(t, options);
|
|
432
|
+
const instance = client();
|
|
433
|
+
seed(instance);
|
|
434
|
+
const calls = [];
|
|
435
|
+
instance._token_status = (operation, metadata) => {
|
|
436
|
+
calls.push(...captureStatus(() => Vauth.prototype._token_status.call(instance, operation, metadata)));
|
|
437
|
+
};
|
|
438
|
+
await instance.connect();
|
|
439
|
+
await instance.connect();
|
|
440
|
+
const validity = options.duration ? `valid for ${options.duration}` : 'valid indefinitely';
|
|
441
|
+
assert.deepEqual(calls, [`Vauth token: ${options.operation} · ${instance.VAULT_ADDR} · ${validity.replace(/^valid for /, '')}`]);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
test('failed connections never announce successful token status', async t => {
|
|
446
|
+
const {client, seed} = await fixture(t, {lookup_status : 500});
|
|
447
|
+
const instance = client();
|
|
448
|
+
seed(instance);
|
|
449
|
+
instance.VAULT_TOKEN = 'uncached-token';
|
|
450
|
+
const calls = [];
|
|
451
|
+
instance._token_status = operation => calls.push(operation);
|
|
452
|
+
await assert.rejects(instance.connect());
|
|
453
|
+
assert.deepEqual(calls, []);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
for(const [lease, remaining, renew] of [
|
|
457
|
+
[24 * HOUR, 8 * HOUR, true],
|
|
458
|
+
[24 * HOUR, 8 * HOUR + 1, false],
|
|
459
|
+
[HOUR, 20 * 60, true],
|
|
460
|
+
[HOUR, 20 * 60 + 1, false],
|
|
461
|
+
[60, 20, true],
|
|
462
|
+
[60, 21, false],
|
|
463
|
+
]) {
|
|
464
|
+
test(`proportional threshold: lease=${lease}, remaining=${remaining}`, async t => {
|
|
465
|
+
const {client, seed, read, requests} = await fixture(t, {ttl : remaining});
|
|
466
|
+
const instance = client();
|
|
467
|
+
seed(instance, lease);
|
|
468
|
+
await instance.connect();
|
|
469
|
+
assert.equal(requests.some(req => req.path.endsWith('/renew-self')), renew);
|
|
470
|
+
assert.equal(read(instance).lease_duration, renew ? 24 * HOUR : lease);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
test('lookups never reset the original lease duration', async t => {
|
|
475
|
+
const options = {ttl : 20 * HOUR};
|
|
476
|
+
const {client, seed, read, requests, remaining} = await fixture(t, options);
|
|
477
|
+
const instance = client();
|
|
478
|
+
seed(instance, 24 * HOUR);
|
|
479
|
+
await instance.connect();
|
|
480
|
+
assert.equal(read(instance).lease_duration, 24 * HOUR);
|
|
481
|
+
remaining(instance, 12 * HOUR);
|
|
482
|
+
await client().connect();
|
|
483
|
+
assert.equal(read(instance).lease_duration, 24 * HOUR);
|
|
484
|
+
remaining(instance, 8 * HOUR);
|
|
485
|
+
await client().connect();
|
|
486
|
+
assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('renewal updates the lease baseline to the newly granted duration', async t => {
|
|
490
|
+
const options = {ttl : 20 * 60};
|
|
491
|
+
const {client, seed, read, requests, remaining} = await fixture(t, options);
|
|
492
|
+
const instance = client();
|
|
493
|
+
seed(instance, HOUR);
|
|
494
|
+
await instance.connect();
|
|
495
|
+
assert.equal(read(instance).lease_duration, 24 * HOUR);
|
|
496
|
+
remaining(instance, 8 * HOUR);
|
|
497
|
+
await client().connect();
|
|
498
|
+
assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 2);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('normal reuse never writes the token cache', async t => {
|
|
502
|
+
const {client, seed, file, requests} = await fixture(t);
|
|
503
|
+
const instance = client();
|
|
504
|
+
seed(instance);
|
|
505
|
+
instance.VAULT_TOKEN = 'cached-token';
|
|
506
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
507
|
+
const stat = fs.statSync(file);
|
|
508
|
+
instance._save_cached_token = () => { throw new Error('Reuse must not write'); };
|
|
509
|
+
assert.equal(await instance.connect(), 'cached-token');
|
|
510
|
+
assert.equal(fs.readFileSync(file, 'utf8'), before);
|
|
511
|
+
assert.equal(fs.statSync(file).ino, stat.ino);
|
|
512
|
+
assert.equal(fs.statSync(file).mtimeMs, stat.mtimeMs);
|
|
513
|
+
assert.deepEqual(requests, []);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
test('lookup returns the lease duration and preserves a cached renewal baseline', async t => {
|
|
518
|
+
const {client, seed, requests} = await fixture(t, {ttl : HOUR, creation_ttl : 24 * HOUR});
|
|
519
|
+
const instance = client();
|
|
520
|
+
const uncached = await instance._lookup_token('environment-token');
|
|
521
|
+
assert.equal(uncached.lease_duration, 24 * HOUR);
|
|
522
|
+
assert.equal(uncached.ttl, HOUR);
|
|
523
|
+
seed(instance, 12 * HOUR);
|
|
524
|
+
const cached = await instance._lookup_token('cached-token');
|
|
525
|
+
assert.equal(cached.lease_duration, 12 * HOUR);
|
|
526
|
+
assert.equal(requests.length, 2);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('token_cache false skips all cache filesystem access', async t => {
|
|
530
|
+
const {client, requests, file} = await fixture(t);
|
|
531
|
+
const instance = client();
|
|
532
|
+
instance.rc.token_cache = false;
|
|
533
|
+
instance._token_cache_file = () => { throw new Error('Disabled cache must not access disk'); };
|
|
534
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
535
|
+
assert.equal(fs.existsSync(file), false);
|
|
536
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('token_cache false ignores existing tokens on disk without deleting them', async t => {
|
|
540
|
+
const {client, seed, requests, file} = await fixture(t);
|
|
541
|
+
const instance = client();
|
|
542
|
+
seed(instance);
|
|
543
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
544
|
+
instance.rc.token_cache = false;
|
|
545
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
546
|
+
assert.equal(fs.readFileSync(file, 'utf8'), before);
|
|
547
|
+
const second = client();
|
|
548
|
+
second.rc.token_cache = false;
|
|
549
|
+
assert.equal(await second.connect(), 'fresh-token');
|
|
550
|
+
assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('token_cache false still respects an environment token without persisting it', async t => {
|
|
554
|
+
const {client, requests, file} = await fixture(t);
|
|
555
|
+
const instance = client();
|
|
556
|
+
instance.rc.token_cache = false;
|
|
557
|
+
instance.VAULT_TOKEN = 'environment-token';
|
|
558
|
+
assert.equal(await instance.connect(), 'environment-token');
|
|
559
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/lookup-self']);
|
|
560
|
+
assert.equal(fs.existsSync(file), false);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('real CLI reads token_cache false from .vauthrc', async t => {
|
|
564
|
+
const {client, file, requests} = await fixture(t);
|
|
565
|
+
const instance = client();
|
|
566
|
+
const home = path.dirname(path.dirname(file));
|
|
567
|
+
const rc = path.join(home, '.vauthrc');
|
|
568
|
+
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : false}));
|
|
569
|
+
const cli = path.resolve(__dirname, '..', 'index.js');
|
|
570
|
+
for(let i = 0; i < 2; i++) {
|
|
571
|
+
const output = await exec(process.execPath, [cli, 'login', '--source'], {
|
|
572
|
+
cwd : home,
|
|
573
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
|
|
574
|
+
});
|
|
575
|
+
assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
|
|
576
|
+
assert.ok(output.stderr.includes('Vauth token: generated'));
|
|
577
|
+
}
|
|
578
|
+
assert.equal(fs.existsSync(file), false);
|
|
579
|
+
assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
|
|
580
|
+
});
|