vvauth 2.1.0 → 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 +160 -38
- 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');
|
|
@@ -23,7 +26,7 @@ const promiser = require('nyks/function/promiser');
|
|
|
23
26
|
const {args} = require('nyks/process/parseArgs')();
|
|
24
27
|
|
|
25
28
|
|
|
26
|
-
const {
|
|
29
|
+
const {createAgent} = require('ssh2/lib/agent');
|
|
27
30
|
const debug = require('debug');
|
|
28
31
|
|
|
29
32
|
const logger = {
|
|
@@ -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() {
|
|
@@ -48,7 +51,7 @@ class vvauth {
|
|
|
48
51
|
for(let [module_name, module_version] of Object.entries(dependencies)) {
|
|
49
52
|
|
|
50
53
|
let {version} = require(require.resolve(`${module_name}/package.json`, {
|
|
51
|
-
paths : ['.', ...require.main.paths]
|
|
54
|
+
paths : ['.', ...(require.main && require.main.paths || module.paths)]
|
|
52
55
|
}));
|
|
53
56
|
|
|
54
57
|
if(!semver.satisfies(version, module_version))
|
|
@@ -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,21 +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
|
-
if(!
|
|
110
|
-
|
|
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
|
+
}
|
|
111
213
|
|
|
112
|
-
if(
|
|
113
|
-
|
|
114
|
-
|
|
214
|
+
if(token) {
|
|
215
|
+
if(operation !== 'reused' || !cached || cached.token !== token)
|
|
216
|
+
this._save_cached_token(token, metadata);
|
|
217
|
+
this._token_status(operation, metadata);
|
|
115
218
|
}
|
|
116
|
-
this.VAULT_TOKEN =
|
|
219
|
+
this.VAULT_TOKEN = token;
|
|
220
|
+
this._connected = true;
|
|
221
|
+
return token;
|
|
117
222
|
}
|
|
118
223
|
|
|
119
|
-
async
|
|
120
|
-
|
|
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);
|
|
121
236
|
if(source) {
|
|
122
237
|
let env = {VAULT_TOKEN : this.VAULT_TOKEN};
|
|
123
238
|
this._publish_env(env);
|
|
@@ -153,15 +268,15 @@ class vvauth {
|
|
|
153
268
|
return {...database, ...profile};
|
|
154
269
|
}
|
|
155
270
|
|
|
156
|
-
async _vault_get_profile() {
|
|
157
|
-
await this.connect();
|
|
271
|
+
async _vault_get_profile(renew = false) {
|
|
272
|
+
await this.connect(renew);
|
|
158
273
|
|
|
159
274
|
if(!this.VAULT_TOKEN)
|
|
160
275
|
return {};
|
|
161
276
|
|
|
162
277
|
let {entity_id} = await this._lookup_token(this.VAULT_TOKEN);
|
|
163
278
|
let identity = await this._lookup_identity(this.VAULT_TOKEN, entity_id);
|
|
164
|
-
let profile = {...(identity.metadata
|
|
279
|
+
let profile = {...(identity.metadata)};
|
|
165
280
|
let database = {};
|
|
166
281
|
|
|
167
282
|
if(profile.VAUTH_USER_LOGIN)
|
|
@@ -170,8 +285,8 @@ class vvauth {
|
|
|
170
285
|
return {entity_id, identity, profile, database};
|
|
171
286
|
}
|
|
172
287
|
|
|
173
|
-
async _get_env() {
|
|
174
|
-
let {profile, database} = await this._vault_get_profile();
|
|
288
|
+
async _get_env(renew = false) {
|
|
289
|
+
let {profile, database} = await this._vault_get_profile(renew);
|
|
175
290
|
profile = {...database, ...profile};
|
|
176
291
|
|
|
177
292
|
let env = {}, secrets = {},
|
|
@@ -233,8 +348,8 @@ class vvauth {
|
|
|
233
348
|
process.exit();
|
|
234
349
|
}
|
|
235
350
|
|
|
236
|
-
async env(source = false) {
|
|
237
|
-
const env = await this._get_env();
|
|
351
|
+
async env(source = false, renew = false) {
|
|
352
|
+
const env = await this._get_env(renew);
|
|
238
353
|
|
|
239
354
|
if(source) {
|
|
240
355
|
this._publish_env(env);
|
|
@@ -272,16 +387,16 @@ class vvauth {
|
|
|
272
387
|
}
|
|
273
388
|
|
|
274
389
|
|
|
275
|
-
async _login_vault_ssh({path = 'ssh', role}) {
|
|
390
|
+
async _login_vault_ssh({path = 'ssh', role}, agent_path = process.env.SSH_AUTH_SOCK) {
|
|
276
391
|
logger.info("Trying to auth as '%s'", role);
|
|
277
392
|
|
|
278
|
-
let agent =
|
|
393
|
+
let agent = createAgent(agent_path);
|
|
279
394
|
let keys = await promiser(chain => agent.getIdentities(chain));
|
|
280
395
|
|
|
281
396
|
|
|
282
|
-
let
|
|
397
|
+
let auth;
|
|
283
398
|
await eachLimit(keys, 1, async (pubKey) => {
|
|
284
|
-
if(
|
|
399
|
+
if(auth)
|
|
285
400
|
return;
|
|
286
401
|
|
|
287
402
|
let remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/${path}/nonce`;
|
|
@@ -293,17 +408,17 @@ class vvauth {
|
|
|
293
408
|
const public_key = pubKey.type + ' ' + pubKey.getPublicSSH().toString('base64');
|
|
294
409
|
const payload = {public_key, role, nonce : Buffer.from(nonce).toString('base64'), signature};
|
|
295
410
|
try {
|
|
296
|
-
|
|
411
|
+
auth = await this._login_vault(path, payload);
|
|
297
412
|
} catch(err) {
|
|
298
413
|
logger.debug("ssh : invalid challenge for public key", pubKey.comment);
|
|
299
414
|
}
|
|
300
415
|
});
|
|
301
416
|
|
|
302
417
|
|
|
303
|
-
if(!
|
|
418
|
+
if(!auth || !auth.client_token)
|
|
304
419
|
throw `Could not login to vault`;
|
|
305
420
|
|
|
306
|
-
return
|
|
421
|
+
return auth;
|
|
307
422
|
}
|
|
308
423
|
async _function_exists(alias) {
|
|
309
424
|
let child = spawn('bash', ["-lc", `declare -F ${alias}`]);
|
|
@@ -313,12 +428,11 @@ class vvauth {
|
|
|
313
428
|
async install() {
|
|
314
429
|
const bashrc_path = path.resolve(os.homedir(), ".bashrc");
|
|
315
430
|
let bashrc = fs.existsSync(bashrc_path) ? fs.readFileSync(bashrc_path, 'utf-8').trim() : '';
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return;
|
|
431
|
+
if(await this._function_exists(FUNCTION_NAME) === 0) {
|
|
432
|
+
if(!await boolPrompt("Function already defined, override current definition? ", false))
|
|
433
|
+
return;
|
|
320
434
|
}
|
|
321
|
-
console.error("
|
|
435
|
+
console.error("Installing function %s in %s", FUNCTION_NAME, bashrc_path);
|
|
322
436
|
|
|
323
437
|
fs.writeFileSync(bashrc_path, [bashrc, FUNCTION_DECL, ""].join("\n"));
|
|
324
438
|
console.error(`Installation ok, please \nsource ${bashrc_path}`);
|
|
@@ -329,6 +443,13 @@ class vvauth {
|
|
|
329
443
|
let query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200};
|
|
330
444
|
let res = await request(query);
|
|
331
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;
|
|
332
453
|
return response;
|
|
333
454
|
}
|
|
334
455
|
|
|
@@ -357,9 +478,10 @@ class vvauth {
|
|
|
357
478
|
if(res.statusCode !== 200)
|
|
358
479
|
throw `Could not login to vault : ${response}`;
|
|
359
480
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
485
|
}
|
|
364
486
|
|
|
365
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
|
+
});
|