vvauth 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/index.js +18 -16
- package/package.json +2 -2
- package/test/token-cache.js +229 -71
package/README.md
CHANGED
|
@@ -63,10 +63,13 @@ vauth env --ir://json
|
|
|
63
63
|
|
|
64
64
|
# Token cache
|
|
65
65
|
|
|
66
|
-
Tokens are cached in `~/.vauth/
|
|
66
|
+
Tokens are cached in `~/.vauth/tokens/<session>.creds`, isolated by Vault and
|
|
67
67
|
authentication. Tokens are reused, renewed within their lease’s final third, or
|
|
68
68
|
replaced when expired. Existing `VAULT_TOKEN` takes precedence over cached tokens.
|
|
69
69
|
|
|
70
|
+
Each session file is armored using `ssh-agent-crypt` and your SSH agent’s first
|
|
71
|
+
key. Without an SSH agent, disk caching is disabled.
|
|
72
|
+
|
|
70
73
|
Set `token_cache: false` in `.vauthrc` to disable disk cache reads and writes.
|
|
71
74
|
|
|
72
75
|
## Force reauthentication
|
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const url = require('url');
|
|
8
8
|
const {createHash} = require('crypto');
|
|
9
|
+
const {encrypt, decrypt} = require('ssh-agent-crypt');
|
|
9
10
|
const {spawn, execFileSync} = require('child_process');
|
|
10
11
|
const passthru = require('nyks/child_process/passthru');
|
|
11
12
|
const wait = require('nyks/child_process/wait');
|
|
@@ -89,6 +90,9 @@ class vvauth {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
|
|
93
|
+
if(!process.env.SSH_AUTH_SOCK)
|
|
94
|
+
this.rc.token_cache = false;
|
|
95
|
+
|
|
92
96
|
this.VAULT_ADDR = this.rc.vault_addr || process.env.VAULT_ADDR; //might be null
|
|
93
97
|
this.VAULT_TOKEN = process.env.VAULT_TOKEN;
|
|
94
98
|
|
|
@@ -112,40 +116,38 @@ class vvauth {
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
_token_cache_file() {
|
|
115
|
-
return path.join(os.homedir(), '.vauth', '
|
|
119
|
+
return path.join(os.homedir(), '.vauth', 'tokens', this._token_cache_key() + '.creds');
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
_read_token_cache() {
|
|
122
|
+
async _read_token_cache() {
|
|
119
123
|
if(this.rc.token_cache === false)
|
|
120
|
-
return
|
|
124
|
+
return;
|
|
121
125
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
return cache;
|
|
126
|
+
const armor = fs.readFileSync(this._token_cache_file(), 'utf8');
|
|
127
|
+
return JSON.parse(await decrypt(armor));
|
|
125
128
|
} catch(err) {}
|
|
126
|
-
return {version : 1, tokens : {}};
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
_save_cached_token(token, metadata) {
|
|
131
|
+
async _save_cached_token(token, metadata) {
|
|
130
132
|
if(this.rc.token_cache === false)
|
|
131
133
|
return;
|
|
132
134
|
const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
|
|
133
135
|
const file = this._token_cache_file();
|
|
134
136
|
const temporary = file + '.next';
|
|
135
137
|
try {
|
|
136
|
-
const cache = this._read_token_cache();
|
|
137
|
-
const key = this._token_cache_key();
|
|
138
138
|
const {lease_duration} = metadata;
|
|
139
|
-
cache
|
|
139
|
+
const cache = {
|
|
140
140
|
token, lease_duration, renewable : metadata.renewable === true,
|
|
141
141
|
expires_at : ttl === 0 ? null : Date.now() + ttl * 1000,
|
|
142
142
|
};
|
|
143
143
|
fs.mkdirSync(path.dirname(file), {recursive : true, mode : 0o700});
|
|
144
|
-
|
|
144
|
+
const identity = process.env.VAUTH_USER_IDENTITY || process.env.VAUTH_USER_MAIL;
|
|
145
|
+
const armor = await encrypt(JSON.stringify(cache), identity);
|
|
146
|
+
fs.writeFileSync(temporary, armor, {mode : 0o600});
|
|
145
147
|
fs.renameSync(temporary, file);
|
|
146
148
|
} catch(err) {
|
|
147
149
|
// Cache failures must not prevent authentication or expose credentials.
|
|
148
|
-
|
|
150
|
+
console.error('Could not encrypt/write token cache; token not persisted');
|
|
149
151
|
} finally {
|
|
150
152
|
try { fs.unlinkSync(temporary); } catch(err) {}
|
|
151
153
|
}
|
|
@@ -162,7 +164,7 @@ class vvauth {
|
|
|
162
164
|
return this.VAULT_TOKEN;
|
|
163
165
|
this._connected = false;
|
|
164
166
|
|
|
165
|
-
const cached = this._read_token_cache()
|
|
167
|
+
const cached = await this._read_token_cache();
|
|
166
168
|
let token = this.VAULT_TOKEN || (cached && cached.token);
|
|
167
169
|
const had_token = !!token;
|
|
168
170
|
let metadata, operation = 'reused';
|
|
@@ -213,7 +215,7 @@ class vvauth {
|
|
|
213
215
|
|
|
214
216
|
if(token) {
|
|
215
217
|
if(operation !== 'reused' || !cached || cached.token !== token)
|
|
216
|
-
this._save_cached_token(token, metadata);
|
|
218
|
+
await this._save_cached_token(token, metadata);
|
|
217
219
|
this._token_status(operation, metadata);
|
|
218
220
|
}
|
|
219
221
|
this.VAULT_TOKEN = token;
|
|
@@ -445,7 +447,7 @@ class vvauth {
|
|
|
445
447
|
let response = JSON.parse(await drain(res)).data;
|
|
446
448
|
if(!Number.isFinite(response.ttl) || response.ttl < 0)
|
|
447
449
|
throw new Error('Invalid Vault token TTL');
|
|
448
|
-
const cached = this._read_token_cache()
|
|
450
|
+
const cached = await this._read_token_cache();
|
|
449
451
|
if(cached && cached.token === token)
|
|
450
452
|
response.lease_duration = cached.lease_duration;
|
|
451
453
|
else
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vvauth",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Vault Auth helper",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"mout": "^1.2.4",
|
|
19
19
|
"nyks": "^6.15.0",
|
|
20
20
|
"semver": "^7.5.4",
|
|
21
|
-
"ssh-agent-crypt": "^1.0
|
|
21
|
+
"ssh-agent-crypt": "^1.2.0",
|
|
22
22
|
"ssh2": "^1.16.0",
|
|
23
23
|
"yaml": "^2.6.1"
|
|
24
24
|
},
|
package/test/token-cache.js
CHANGED
|
@@ -1,21 +1,58 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert/strict');
|
|
4
|
-
const {test} = require('node:test');
|
|
4
|
+
const {test, before, after} = require('node:test');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const http = require('http');
|
|
9
|
-
const {execFile, spawn} = require('child_process');
|
|
9
|
+
const {execFile, spawn, spawnSync} = require('child_process');
|
|
10
10
|
const {promisify, format} = require('util');
|
|
11
11
|
const exec = promisify(execFile);
|
|
12
12
|
const Vauth = require('../index');
|
|
13
13
|
|
|
14
|
+
const {encrypt, decrypt} = require('ssh-agent-crypt');
|
|
15
|
+
const readCache = async file => JSON.parse(await decrypt(fs.readFileSync(file, 'utf8')));
|
|
16
|
+
const writeCache = async (file, cache) => fs.writeFileSync(file, await encrypt(JSON.stringify(cache)));
|
|
17
|
+
let agentDirectory, agentPid;
|
|
18
|
+
const previousSocket = process.env.SSH_AUTH_SOCK;
|
|
19
|
+
const previousIdentity = process.env.VAUTH_USER_IDENTITY;
|
|
20
|
+
const previousMail = process.env.VAUTH_USER_MAIL;
|
|
21
|
+
|
|
22
|
+
before(() => {
|
|
23
|
+
delete process.env.VAUTH_USER_IDENTITY;
|
|
24
|
+
delete process.env.VAUTH_USER_MAIL;
|
|
25
|
+
agentDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-agent-'));
|
|
26
|
+
const key = path.join(agentDirectory, 'key');
|
|
27
|
+
assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', key]).status, 0);
|
|
28
|
+
const agent = spawnSync('ssh-agent', ['-s'], {encoding : 'utf8'});
|
|
29
|
+
assert.equal(agent.status, 0);
|
|
30
|
+
process.env.SSH_AUTH_SOCK = agent.stdout.match(/SSH_AUTH_SOCK=([^;]+)/)[1];
|
|
31
|
+
agentPid = agent.stdout.match(/SSH_AGENT_PID=(\d+)/)[1];
|
|
32
|
+
assert.equal(spawnSync('ssh-add', [key]).status, 0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
after(() => {
|
|
36
|
+
for(const [name, value] of [['VAUTH_USER_IDENTITY', previousIdentity], ['VAUTH_USER_MAIL', previousMail]]) {
|
|
37
|
+
if(value === undefined)
|
|
38
|
+
delete process.env[name];
|
|
39
|
+
else
|
|
40
|
+
process.env[name] = value;
|
|
41
|
+
}
|
|
42
|
+
if(agentPid)
|
|
43
|
+
process.kill(Number(agentPid));
|
|
44
|
+
if(previousSocket === undefined)
|
|
45
|
+
delete process.env.SSH_AUTH_SOCK;
|
|
46
|
+
else
|
|
47
|
+
process.env.SSH_AUTH_SOCK = previousSocket;
|
|
48
|
+
fs.rmSync(agentDirectory, {recursive : true, force : true});
|
|
49
|
+
});
|
|
50
|
+
|
|
14
51
|
const HOUR = 60 * 60;
|
|
15
52
|
|
|
16
53
|
async function fixture(t, options = {}) {
|
|
17
54
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-test-'));
|
|
18
|
-
|
|
55
|
+
let file;
|
|
19
56
|
const requests = [];
|
|
20
57
|
const server = http.createServer(async (req, res) => {
|
|
21
58
|
let body = '';
|
|
@@ -48,20 +85,22 @@ async function fixture(t, options = {}) {
|
|
|
48
85
|
const instance = Object.create(Vauth.prototype);
|
|
49
86
|
instance.rc = {jwt_auth : {path : 'jwt', jwt : 'test-jwt', role : 'test-role'}};
|
|
50
87
|
instance.VAULT_ADDR = `http://127.0.0.1:${server.address().port}`;
|
|
51
|
-
instance._token_cache_file = () =>
|
|
88
|
+
instance._token_cache_file = () => path.join(directory, '.vauth', 'tokens', instance._token_cache_key() + '.creds');
|
|
52
89
|
return instance;
|
|
53
90
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
91
|
+
file = client()._token_cache_file();
|
|
92
|
+
const remaining = async (instance, ttl) => {
|
|
93
|
+
const file = instance._token_cache_file();
|
|
94
|
+
const cache = (await readCache(file));
|
|
95
|
+
cache.expires_at = ttl === 0 ? null : Date.now() + ttl * 1000;
|
|
96
|
+
await writeCache(file, cache);
|
|
58
97
|
};
|
|
59
|
-
const seed = (instance, lease = 12 * HOUR) => {
|
|
60
|
-
instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
|
|
98
|
+
const seed = async (instance, lease = 12 * HOUR) => {
|
|
99
|
+
await instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
|
|
61
100
|
if(options.ttl !== undefined)
|
|
62
|
-
remaining(instance, options.ttl);
|
|
101
|
+
await remaining(instance, options.ttl);
|
|
63
102
|
};
|
|
64
|
-
const read = instance =>
|
|
103
|
+
const read = async instance => readCache(instance._token_cache_file());
|
|
65
104
|
return {client, seed, read, requests, file, remaining};
|
|
66
105
|
}
|
|
67
106
|
|
|
@@ -69,31 +108,31 @@ test('login persists a token; a new instance reuses it without logging in', asyn
|
|
|
69
108
|
const {client, read, requests, file} = await fixture(t);
|
|
70
109
|
const first = client();
|
|
71
110
|
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);
|
|
111
|
+
assert.equal((await read(first)).token, 'fresh-token');
|
|
112
|
+
assert.ok((await read(first)).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
74
113
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
75
114
|
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
|
|
76
115
|
const second = client();
|
|
77
116
|
assert.equal(await second.connect(), 'fresh-token');
|
|
78
117
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
79
|
-
assert.deepEqual(fs.readdirSync(path.dirname(file)), [
|
|
118
|
+
assert.deepEqual(fs.readdirSync(path.dirname(file)), [path.basename(file)]);
|
|
80
119
|
});
|
|
81
120
|
|
|
82
121
|
test('a token in the last third of its lease is renewed and saved', async t => {
|
|
83
122
|
const {client, seed, read, requests} = await fixture(t, {ttl : HOUR});
|
|
84
123
|
const instance = client();
|
|
85
|
-
seed(instance, 12 * HOUR);
|
|
124
|
+
await seed(instance, 12 * HOUR);
|
|
86
125
|
assert.equal(await instance.connect(), 'cached-token');
|
|
87
126
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
|
|
88
127
|
assert.equal(requests[0].method, 'POST');
|
|
89
128
|
assert.equal(requests[0].token, 'cached-token');
|
|
90
|
-
assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
129
|
+
assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
91
130
|
});
|
|
92
131
|
|
|
93
132
|
test('more than one third remaining does not trigger renewal', async t => {
|
|
94
133
|
const {client, seed, requests} = await fixture(t, {ttl : 4 * HOUR + 1});
|
|
95
134
|
const instance = client();
|
|
96
|
-
seed(instance);
|
|
135
|
+
await seed(instance);
|
|
97
136
|
assert.equal(await instance.connect(), 'cached-token');
|
|
98
137
|
assert.equal(requests.length, 0);
|
|
99
138
|
});
|
|
@@ -101,19 +140,19 @@ test('more than one third remaining does not trigger renewal', async t => {
|
|
|
101
140
|
test('expired cached tokens are replaced with a new persisted login', async t => {
|
|
102
141
|
const {client, seed, read, requests, file} = await fixture(t);
|
|
103
142
|
const instance = client();
|
|
104
|
-
seed(instance);
|
|
105
|
-
const cache =
|
|
106
|
-
cache.
|
|
107
|
-
|
|
143
|
+
await seed(instance);
|
|
144
|
+
const cache = (await readCache(file));
|
|
145
|
+
cache.expires_at = Date.now() - 1000;
|
|
146
|
+
await writeCache(file, cache);
|
|
108
147
|
assert.equal(await instance.connect(), 'fresh-token');
|
|
109
|
-
assert.equal(read(instance).token, 'fresh-token');
|
|
148
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
110
149
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
111
150
|
});
|
|
112
151
|
|
|
113
152
|
test('cached tokens are reused without checking revocation', async t => {
|
|
114
153
|
const {client, seed, requests} = await fixture(t, {lookup_status : 403});
|
|
115
154
|
const instance = client();
|
|
116
|
-
seed(instance);
|
|
155
|
+
await seed(instance);
|
|
117
156
|
assert.equal(await instance.connect(), 'cached-token');
|
|
118
157
|
assert.deepEqual(requests, []);
|
|
119
158
|
await assert.rejects(instance._lookup_token(instance.VAULT_TOKEN));
|
|
@@ -122,7 +161,7 @@ test('cached tokens are reused without checking revocation', async t => {
|
|
|
122
161
|
test('nonrenewable tokens nearing expiry trigger reauthentication', async t => {
|
|
123
162
|
const {client, seed, requests} = await fixture(t, {ttl : HOUR, renewable : false});
|
|
124
163
|
const instance = client();
|
|
125
|
-
seed(instance);
|
|
164
|
+
await seed(instance);
|
|
126
165
|
assert.equal(await instance.connect(), 'fresh-token');
|
|
127
166
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
128
167
|
});
|
|
@@ -131,9 +170,9 @@ for(const status of [400, 403]) {
|
|
|
131
170
|
test(`rejected renewal (${status}) triggers a new persisted login`, async t => {
|
|
132
171
|
const {client, seed, read, requests} = await fixture(t, {ttl : HOUR, renew_status : status});
|
|
133
172
|
const instance = client();
|
|
134
|
-
seed(instance);
|
|
173
|
+
await seed(instance);
|
|
135
174
|
assert.equal(await instance.connect(), 'fresh-token');
|
|
136
|
-
assert.equal(read(instance).token, 'fresh-token');
|
|
175
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
137
176
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self', '/v1/auth/jwt/login']);
|
|
138
177
|
});
|
|
139
178
|
}
|
|
@@ -141,7 +180,7 @@ for(const status of [400, 403]) {
|
|
|
141
180
|
test('server failures do not generate extra tokens, and connect can be retried', async t => {
|
|
142
181
|
const {client, seed, requests} = await fixture(t, {lookup_status : 500});
|
|
143
182
|
const instance = client();
|
|
144
|
-
seed(instance);
|
|
183
|
+
await seed(instance);
|
|
145
184
|
instance.VAULT_TOKEN = 'uncached-token';
|
|
146
185
|
await assert.rejects(instance.connect());
|
|
147
186
|
await assert.rejects(instance.connect());
|
|
@@ -151,7 +190,7 @@ test('server failures do not generate extra tokens, and connect can be retried',
|
|
|
151
190
|
test('renewal server failures do not generate extra tokens', async t => {
|
|
152
191
|
const {client, seed, requests} = await fixture(t, {ttl : HOUR, renew_status : 500});
|
|
153
192
|
const instance = client();
|
|
154
|
-
seed(instance);
|
|
193
|
+
await seed(instance);
|
|
155
194
|
await assert.rejects(instance.connect());
|
|
156
195
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
|
|
157
196
|
});
|
|
@@ -159,10 +198,10 @@ test('renewal server failures do not generate extra tokens', async t => {
|
|
|
159
198
|
test('an existing environment token is respected and cached', async t => {
|
|
160
199
|
const {client, seed, read, requests} = await fixture(t);
|
|
161
200
|
const instance = client();
|
|
162
|
-
seed(instance);
|
|
201
|
+
await seed(instance);
|
|
163
202
|
instance.VAULT_TOKEN = 'environment-token';
|
|
164
203
|
assert.equal(await instance.connect(), 'environment-token');
|
|
165
|
-
assert.equal(read(instance).token, 'environment-token');
|
|
204
|
+
assert.equal((await read(instance)).token, 'environment-token');
|
|
166
205
|
assert.equal(requests[0].token, 'environment-token');
|
|
167
206
|
assert.equal(requests.length, 1);
|
|
168
207
|
});
|
|
@@ -170,34 +209,35 @@ test('an existing environment token is respected and cached', async t => {
|
|
|
170
209
|
test('nonexpiring tokens are reused without renewal', async t => {
|
|
171
210
|
const {client, seed, read, requests} = await fixture(t, {ttl : 0});
|
|
172
211
|
const instance = client();
|
|
173
|
-
seed(instance, 0);
|
|
212
|
+
await seed(instance, 0);
|
|
174
213
|
assert.equal(await instance.connect(), 'cached-token');
|
|
175
|
-
assert.equal(read(instance).expires_at, null);
|
|
214
|
+
assert.equal((await read(instance)).expires_at, null);
|
|
176
215
|
assert.equal(requests.length, 0);
|
|
177
216
|
});
|
|
178
217
|
|
|
179
218
|
test('corrupt caches are replaced with a valid cache', async t => {
|
|
180
219
|
const {client, read, file} = await fixture(t);
|
|
181
|
-
fs.mkdirSync(path.dirname(file));
|
|
220
|
+
fs.mkdirSync(path.dirname(file), {recursive : true});
|
|
182
221
|
fs.writeFileSync(file, '{broken');
|
|
183
222
|
const instance = client();
|
|
184
223
|
assert.equal(await instance.connect(), 'fresh-token');
|
|
185
|
-
assert.equal(read(instance).token, 'fresh-token');
|
|
224
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
186
225
|
});
|
|
187
226
|
|
|
188
227
|
test('cache entries are isolated by Vault address and authentication configuration', async t => {
|
|
189
228
|
const {client, seed, read, file} = await fixture(t);
|
|
190
229
|
const first = client();
|
|
191
|
-
seed(first);
|
|
230
|
+
await seed(first);
|
|
192
231
|
const second = client();
|
|
193
232
|
second.rc.jwt_auth.role = 'other-role';
|
|
194
233
|
assert.equal(await second.connect(), 'fresh-token');
|
|
195
|
-
assert.
|
|
196
|
-
assert.equal(read(
|
|
234
|
+
assert.notEqual(first._token_cache_file(), second._token_cache_file());
|
|
235
|
+
assert.equal((await read(first)).token, 'cached-token');
|
|
236
|
+
assert.equal((await read(second)).token, 'fresh-token');
|
|
197
237
|
const third = client();
|
|
198
238
|
third.VAULT_ADDR = 'http://other-vault.test';
|
|
199
239
|
assert.notEqual(third._token_cache_key(), first._token_cache_key());
|
|
200
|
-
assert.equal(
|
|
240
|
+
assert.equal(fs.readdirSync(path.dirname(file)).length, 2);
|
|
201
241
|
assert.ok(!fs.readFileSync(file, 'utf8').includes('test-jwt'));
|
|
202
242
|
});
|
|
203
243
|
|
|
@@ -211,6 +251,7 @@ test('repeated connect calls reuse the established connection', async t => {
|
|
|
211
251
|
|
|
212
252
|
test('an unwritable cache does not prevent login', async t => {
|
|
213
253
|
const {client, file} = await fixture(t);
|
|
254
|
+
fs.mkdirSync(path.dirname(path.dirname(file)), {recursive : true});
|
|
214
255
|
fs.writeFileSync(path.dirname(file), 'not a directory');
|
|
215
256
|
assert.equal(await client().connect(), 'fresh-token');
|
|
216
257
|
});
|
|
@@ -219,7 +260,7 @@ test('an invalid token without an auth method fails rather than exporting undefi
|
|
|
219
260
|
const {client, seed} = await fixture(t, {lookup_status : 403});
|
|
220
261
|
const instance = client();
|
|
221
262
|
instance.rc = {};
|
|
222
|
-
seed(instance);
|
|
263
|
+
await seed(instance);
|
|
223
264
|
instance.VAULT_TOKEN = 'uncached-token';
|
|
224
265
|
await assert.rejects(instance.connect(), /no authentication method/);
|
|
225
266
|
});
|
|
@@ -227,22 +268,22 @@ test('an invalid token without an auth method fails rather than exporting undefi
|
|
|
227
268
|
test('--renew ignores both environment and cached tokens and saves a new token', async t => {
|
|
228
269
|
const {client, seed, read, requests} = await fixture(t);
|
|
229
270
|
const instance = client();
|
|
230
|
-
seed(instance);
|
|
271
|
+
await seed(instance);
|
|
231
272
|
instance.VAULT_TOKEN = 'environment-token';
|
|
232
273
|
assert.equal(await instance.connect(true), 'fresh-token');
|
|
233
274
|
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);
|
|
275
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
276
|
+
assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
|
|
236
277
|
});
|
|
237
278
|
|
|
238
279
|
test('--renew replaces the token of a previously connected instance', async t => {
|
|
239
280
|
const {client, seed, read, requests} = await fixture(t);
|
|
240
281
|
const instance = client();
|
|
241
|
-
seed(instance);
|
|
282
|
+
await seed(instance);
|
|
242
283
|
await instance.connect();
|
|
243
284
|
assert.equal(await instance.connect(true), 'fresh-token');
|
|
244
285
|
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
245
|
-
assert.equal(read(instance).token, 'fresh-token');
|
|
286
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
246
287
|
});
|
|
247
288
|
|
|
248
289
|
test('each successive --renew call forces a new login', async t => {
|
|
@@ -274,13 +315,13 @@ for(const flag of ['renew']) {
|
|
|
274
315
|
test(`real CLI ${command} --${flag} generates and caches a new token`, async t => {
|
|
275
316
|
const {client, seed, read, requests, file} = await fixture(t);
|
|
276
317
|
const instance = client();
|
|
277
|
-
seed(instance);
|
|
278
|
-
const home = path.dirname(path.dirname(file));
|
|
318
|
+
await seed(instance);
|
|
319
|
+
const home = path.dirname(path.dirname(path.dirname(file)));
|
|
279
320
|
const rc = path.join(home, '.vauthrc');
|
|
280
321
|
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
|
|
281
322
|
const options = {
|
|
282
323
|
cwd : home,
|
|
283
|
-
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK :
|
|
324
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
|
|
284
325
|
};
|
|
285
326
|
const cli = path.resolve(__dirname, '..', 'index.js');
|
|
286
327
|
const output = await exec(process.execPath, [cli, command, '--source', `--${flag}`], options);
|
|
@@ -292,7 +333,7 @@ for(const flag of ['renew']) {
|
|
|
292
333
|
assert.ok(!output.stderr.includes('environment-token'));
|
|
293
334
|
assert.deepEqual(requests.filter(req => !req.path.startsWith('/v1/identity/')).map(req => req.path), command === 'env'
|
|
294
335
|
? ['/v1/auth/jwt/login', '/v1/auth/token/lookup-self'] : ['/v1/auth/jwt/login']);
|
|
295
|
-
assert.equal(read(instance).token, 'fresh-token');
|
|
336
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
296
337
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
297
338
|
requests.length = 0;
|
|
298
339
|
const reused = await exec(process.execPath, [cli, command, '--source'], {
|
|
@@ -309,8 +350,8 @@ for(const flag of ['renew']) {
|
|
|
309
350
|
test('installed venv forwards --renew through the real CLI', async t => {
|
|
310
351
|
const {client, seed, read, requests, file} = await fixture(t);
|
|
311
352
|
const instance = client();
|
|
312
|
-
seed(instance);
|
|
313
|
-
const home = path.dirname(path.dirname(file));
|
|
353
|
+
await seed(instance);
|
|
354
|
+
const home = path.dirname(path.dirname(path.dirname(file)));
|
|
314
355
|
const original_home = os.homedir;
|
|
315
356
|
instance._function_exists = async () => 1;
|
|
316
357
|
try {
|
|
@@ -328,7 +369,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
|
|
|
328
369
|
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
|
|
329
370
|
const options = {
|
|
330
371
|
cwd : home,
|
|
331
|
-
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK :
|
|
372
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK, PATH : `${bin}:${process.env.PATH}`},
|
|
332
373
|
};
|
|
333
374
|
const initial = await exec('bash', ['-c', `${declaration}\nvenv; printf '%s' "$VAULT_TOKEN"`], options);
|
|
334
375
|
assert.equal(initial.stdout, 'cached-token');
|
|
@@ -341,7 +382,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
|
|
|
341
382
|
assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
|
|
342
383
|
assert.ok(!output.stderr.includes('Vauth token: renewed'));
|
|
343
384
|
assert.ok(!output.stderr.includes('fresh-token'));
|
|
344
|
-
assert.equal(read(instance).token, 'fresh-token');
|
|
385
|
+
assert.equal((await read(instance)).token, 'fresh-token');
|
|
345
386
|
}
|
|
346
387
|
assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 1);
|
|
347
388
|
assert.ok(!requests.some(req => req.path.endsWith('/renew-self')));
|
|
@@ -350,7 +391,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
|
|
|
350
391
|
for(const answer of ['yes', 'no', '']) {
|
|
351
392
|
test(`install asks before overriding an existing function: ${JSON.stringify(answer)}`, async t => {
|
|
352
393
|
const {file} = await fixture(t);
|
|
353
|
-
const home = path.dirname(path.dirname(file));
|
|
394
|
+
const home = path.dirname(path.dirname(path.dirname(file)));
|
|
354
395
|
const bashrc = path.join(home, '.bashrc');
|
|
355
396
|
const original = '# before\nfunction venv() { source <(/usr/bin/env vauth env --source); }\n# after\n';
|
|
356
397
|
fs.writeFileSync(bashrc, original);
|
|
@@ -430,7 +471,7 @@ for(const options of [
|
|
|
430
471
|
test(`one final status per connection: ${JSON.stringify(options)}`, async t => {
|
|
431
472
|
const {client, seed} = await fixture(t, options);
|
|
432
473
|
const instance = client();
|
|
433
|
-
seed(instance);
|
|
474
|
+
await seed(instance);
|
|
434
475
|
const calls = [];
|
|
435
476
|
instance._token_status = (operation, metadata) => {
|
|
436
477
|
calls.push(...captureStatus(() => Vauth.prototype._token_status.call(instance, operation, metadata)));
|
|
@@ -445,7 +486,7 @@ for(const options of [
|
|
|
445
486
|
test('failed connections never announce successful token status', async t => {
|
|
446
487
|
const {client, seed} = await fixture(t, {lookup_status : 500});
|
|
447
488
|
const instance = client();
|
|
448
|
-
seed(instance);
|
|
489
|
+
await seed(instance);
|
|
449
490
|
instance.VAULT_TOKEN = 'uncached-token';
|
|
450
491
|
const calls = [];
|
|
451
492
|
instance._token_status = operation => calls.push(operation);
|
|
@@ -464,10 +505,10 @@ for(const [lease, remaining, renew] of [
|
|
|
464
505
|
test(`proportional threshold: lease=${lease}, remaining=${remaining}`, async t => {
|
|
465
506
|
const {client, seed, read, requests} = await fixture(t, {ttl : remaining});
|
|
466
507
|
const instance = client();
|
|
467
|
-
seed(instance, lease);
|
|
508
|
+
await seed(instance, lease);
|
|
468
509
|
await instance.connect();
|
|
469
510
|
assert.equal(requests.some(req => req.path.endsWith('/renew-self')), renew);
|
|
470
|
-
assert.equal(read(instance).lease_duration, renew ? 24 * HOUR : lease);
|
|
511
|
+
assert.equal((await read(instance)).lease_duration, renew ? 24 * HOUR : lease);
|
|
471
512
|
});
|
|
472
513
|
}
|
|
473
514
|
|
|
@@ -475,13 +516,13 @@ test('lookups never reset the original lease duration', async t => {
|
|
|
475
516
|
const options = {ttl : 20 * HOUR};
|
|
476
517
|
const {client, seed, read, requests, remaining} = await fixture(t, options);
|
|
477
518
|
const instance = client();
|
|
478
|
-
seed(instance, 24 * HOUR);
|
|
519
|
+
await seed(instance, 24 * HOUR);
|
|
479
520
|
await instance.connect();
|
|
480
|
-
assert.equal(read(instance).lease_duration, 24 * HOUR);
|
|
481
|
-
remaining(instance, 12 * HOUR);
|
|
521
|
+
assert.equal((await read(instance)).lease_duration, 24 * HOUR);
|
|
522
|
+
await remaining(instance, 12 * HOUR);
|
|
482
523
|
await client().connect();
|
|
483
|
-
assert.equal(read(instance).lease_duration, 24 * HOUR);
|
|
484
|
-
remaining(instance, 8 * HOUR);
|
|
524
|
+
assert.equal((await read(instance)).lease_duration, 24 * HOUR);
|
|
525
|
+
await remaining(instance, 8 * HOUR);
|
|
485
526
|
await client().connect();
|
|
486
527
|
assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 1);
|
|
487
528
|
});
|
|
@@ -490,10 +531,10 @@ test('renewal updates the lease baseline to the newly granted duration', async t
|
|
|
490
531
|
const options = {ttl : 20 * 60};
|
|
491
532
|
const {client, seed, read, requests, remaining} = await fixture(t, options);
|
|
492
533
|
const instance = client();
|
|
493
|
-
seed(instance, HOUR);
|
|
534
|
+
await seed(instance, HOUR);
|
|
494
535
|
await instance.connect();
|
|
495
|
-
assert.equal(read(instance).lease_duration, 24 * HOUR);
|
|
496
|
-
remaining(instance, 8 * HOUR);
|
|
536
|
+
assert.equal((await read(instance)).lease_duration, 24 * HOUR);
|
|
537
|
+
await remaining(instance, 8 * HOUR);
|
|
497
538
|
await client().connect();
|
|
498
539
|
assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 2);
|
|
499
540
|
});
|
|
@@ -501,7 +542,7 @@ test('renewal updates the lease baseline to the newly granted duration', async t
|
|
|
501
542
|
test('normal reuse never writes the token cache', async t => {
|
|
502
543
|
const {client, seed, file, requests} = await fixture(t);
|
|
503
544
|
const instance = client();
|
|
504
|
-
seed(instance);
|
|
545
|
+
await seed(instance);
|
|
505
546
|
instance.VAULT_TOKEN = 'cached-token';
|
|
506
547
|
const before = fs.readFileSync(file, 'utf8');
|
|
507
548
|
const stat = fs.statSync(file);
|
|
@@ -520,7 +561,7 @@ test('lookup returns the lease duration and preserves a cached renewal baseline'
|
|
|
520
561
|
const uncached = await instance._lookup_token('environment-token');
|
|
521
562
|
assert.equal(uncached.lease_duration, 24 * HOUR);
|
|
522
563
|
assert.equal(uncached.ttl, HOUR);
|
|
523
|
-
seed(instance, 12 * HOUR);
|
|
564
|
+
await seed(instance, 12 * HOUR);
|
|
524
565
|
const cached = await instance._lookup_token('cached-token');
|
|
525
566
|
assert.equal(cached.lease_duration, 12 * HOUR);
|
|
526
567
|
assert.equal(requests.length, 2);
|
|
@@ -539,7 +580,7 @@ test('token_cache false skips all cache filesystem access', async t => {
|
|
|
539
580
|
test('token_cache false ignores existing tokens on disk without deleting them', async t => {
|
|
540
581
|
const {client, seed, requests, file} = await fixture(t);
|
|
541
582
|
const instance = client();
|
|
542
|
-
seed(instance);
|
|
583
|
+
await seed(instance);
|
|
543
584
|
const before = fs.readFileSync(file, 'utf8');
|
|
544
585
|
instance.rc.token_cache = false;
|
|
545
586
|
assert.equal(await instance.connect(), 'fresh-token');
|
|
@@ -563,14 +604,14 @@ test('token_cache false still respects an environment token without persisting i
|
|
|
563
604
|
test('real CLI reads token_cache false from .vauthrc', async t => {
|
|
564
605
|
const {client, file, requests} = await fixture(t);
|
|
565
606
|
const instance = client();
|
|
566
|
-
const home = path.dirname(path.dirname(file));
|
|
607
|
+
const home = path.dirname(path.dirname(path.dirname(file)));
|
|
567
608
|
const rc = path.join(home, '.vauthrc');
|
|
568
609
|
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : false}));
|
|
569
610
|
const cli = path.resolve(__dirname, '..', 'index.js');
|
|
570
611
|
for(let i = 0; i < 2; i++) {
|
|
571
612
|
const output = await exec(process.execPath, [cli, 'login', '--source'], {
|
|
572
613
|
cwd : home,
|
|
573
|
-
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK :
|
|
614
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
|
|
574
615
|
});
|
|
575
616
|
assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
|
|
576
617
|
assert.ok(output.stderr.includes('Vauth token: generated'));
|
|
@@ -578,3 +619,120 @@ test('real CLI reads token_cache false from .vauthrc', async t => {
|
|
|
578
619
|
assert.equal(fs.existsSync(file), false);
|
|
579
620
|
assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
|
|
580
621
|
});
|
|
622
|
+
|
|
623
|
+
test('disk cache contains only armor, interoperable with ssh-agent-crypt CLI', async t => {
|
|
624
|
+
const {client, file} = await fixture(t);
|
|
625
|
+
const instance = client();
|
|
626
|
+
await instance.connect();
|
|
627
|
+
const armor = fs.readFileSync(file, 'utf8');
|
|
628
|
+
assert.match(armor, /^ssh-agent-crypt:v2:/);
|
|
629
|
+
assert.ok(!armor.includes('fresh-token'));
|
|
630
|
+
assert.ok(!armor.includes('lease_duration'));
|
|
631
|
+
assert.ok(!armor.includes('test-jwt'));
|
|
632
|
+
const cli = require.resolve('ssh-agent-crypt/bin/ssh-agent-crypt');
|
|
633
|
+
const decoded = spawnSync('bash', [cli, '-decrypt'], {input : armor, encoding : 'utf8'});
|
|
634
|
+
assert.equal(decoded.status, 0);
|
|
635
|
+
assert.equal(JSON.parse(decoded.stdout).token, 'fresh-token');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('a plaintext cache is not accepted as a token cache', async t => {
|
|
639
|
+
const {client, file, requests} = await fixture(t);
|
|
640
|
+
const instance = client();
|
|
641
|
+
fs.mkdirSync(path.dirname(file), {recursive : true});
|
|
642
|
+
fs.writeFileSync(file, JSON.stringify({token : 'plaintext-token', lease_duration : 86400, expires_at : Date.now() + 86400000}));
|
|
643
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
644
|
+
assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
|
|
645
|
+
assert.match(fs.readFileSync(file, 'utf8'), /^ssh-agent-crypt:v2:/);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test('without an SSH agent cache is skipped silently', async t => {
|
|
649
|
+
const {client, file} = await fixture(t);
|
|
650
|
+
const instance = client();
|
|
651
|
+
instance._token_cache_file = () => { throw new Error('No agent: no disk access'); };
|
|
652
|
+
const socket = process.env.SSH_AUTH_SOCK;
|
|
653
|
+
const original = console.error;
|
|
654
|
+
const messages = [];
|
|
655
|
+
try {
|
|
656
|
+
delete process.env.SSH_AUTH_SOCK;
|
|
657
|
+
instance.rc.token_cache = false;
|
|
658
|
+
console.error = (...args) => messages.push(format(...args));
|
|
659
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
660
|
+
} finally {
|
|
661
|
+
process.env.SSH_AUTH_SOCK = socket;
|
|
662
|
+
console.error = original;
|
|
663
|
+
}
|
|
664
|
+
assert.equal(fs.existsSync(file), false);
|
|
665
|
+
assert.equal(fs.existsSync(file + '.next'), false);
|
|
666
|
+
assert.ok(!messages.some(message => message.includes('token not persisted')));
|
|
667
|
+
assert.ok(!messages.join('\n').includes('fresh-token'));
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('without an SSH agent existing armor is left untouched', async t => {
|
|
671
|
+
const {client, seed, file} = await fixture(t);
|
|
672
|
+
const instance = client();
|
|
673
|
+
await seed(instance);
|
|
674
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
675
|
+
const socket = process.env.SSH_AUTH_SOCK;
|
|
676
|
+
try {
|
|
677
|
+
delete process.env.SSH_AUTH_SOCK;
|
|
678
|
+
instance.rc.token_cache = false;
|
|
679
|
+
await instance._save_cached_token('replacement-token', {lease_duration : 86400, renewable : true});
|
|
680
|
+
} finally {
|
|
681
|
+
process.env.SSH_AUTH_SOCK = socket;
|
|
682
|
+
}
|
|
683
|
+
assert.equal(fs.readFileSync(file, 'utf8'), before);
|
|
684
|
+
assert.equal(fs.existsSync(file + '.next'), false);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
test('constructor disables token caching when no SSH agent is configured', async t => {
|
|
689
|
+
const {client, file} = await fixture(t);
|
|
690
|
+
const instance = client();
|
|
691
|
+
const home = path.dirname(path.dirname(path.dirname(file)));
|
|
692
|
+
const rc = path.join(home, '.vauthrc');
|
|
693
|
+
fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : true}));
|
|
694
|
+
const module_path = path.resolve(__dirname, '..', 'index.js');
|
|
695
|
+
const script = `const Vauth = require(${JSON.stringify(module_path)});
|
|
696
|
+
const instance = new Vauth();
|
|
697
|
+
process.stdout.write(String(instance.rc.token_cache));
|
|
698
|
+
instance.login().catch(() => process.exit(1));`;
|
|
699
|
+
const output = await exec(process.execPath, ['-e', script], {
|
|
700
|
+
cwd : home,
|
|
701
|
+
env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
|
|
702
|
+
});
|
|
703
|
+
assert.equal(output.stdout.trim(), 'false');
|
|
704
|
+
assert.ok(!output.stderr.includes('token not persisted'));
|
|
705
|
+
assert.equal(fs.existsSync(file), false);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
for(const selected of ['identity', 'mail', 'default']) {
|
|
710
|
+
test(`cache encryption selects ${selected}; decryption uses embedded fingerprint`, async t => {
|
|
711
|
+
const {client, file} = await fixture(t);
|
|
712
|
+
const instance = client();
|
|
713
|
+
const directory = path.dirname(path.dirname(path.dirname(file)));
|
|
714
|
+
const key = path.join(directory, 'selected-key');
|
|
715
|
+
const comment = 'selected-cache@test';
|
|
716
|
+
assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', comment, '-f', key]).status, 0);
|
|
717
|
+
assert.equal(spawnSync('ssh-add', [key]).status, 0);
|
|
718
|
+
try {
|
|
719
|
+
if(selected === 'identity') {
|
|
720
|
+
process.env.VAUTH_USER_IDENTITY = comment;
|
|
721
|
+
process.env.VAUTH_USER_MAIL = 'missing-key@test';
|
|
722
|
+
} else if(selected === 'mail') {
|
|
723
|
+
process.env.VAUTH_USER_MAIL = comment;
|
|
724
|
+
}
|
|
725
|
+
assert.equal(await instance.connect(), 'fresh-token');
|
|
726
|
+
const fingerprint = spawnSync('ssh-keygen', ['-lf', key + '.pub'], {encoding : 'utf8'}).stdout.split(/\s+/)[1];
|
|
727
|
+
const armor = fs.readFileSync(file, 'utf8');
|
|
728
|
+
assert.equal(armor.startsWith(`ssh-agent-crypt:v2:${fingerprint}.`), selected !== 'default');
|
|
729
|
+
delete process.env.VAUTH_USER_IDENTITY;
|
|
730
|
+
delete process.env.VAUTH_USER_MAIL;
|
|
731
|
+
assert.equal(await client().connect(), 'fresh-token');
|
|
732
|
+
} finally {
|
|
733
|
+
spawnSync('ssh-add', ['-d', key]);
|
|
734
|
+
delete process.env.VAUTH_USER_IDENTITY;
|
|
735
|
+
delete process.env.VAUTH_USER_MAIL;
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
}
|