@blakearoberts/visage 0.0.1-rc.19 → 0.0.1-rc.21

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/dist/index.js CHANGED
@@ -1,111 +1,14 @@
1
- import { mkdirSync, chmodSync, openSync, rmSync, existsSync, createWriteStream, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
2
- import { join, dirname } from 'node:path';
1
+ import { join } from 'node:path';
2
+ import { readFileSync, mkdirSync, chmodSync, openSync, rmSync, existsSync, createWriteStream, appendFileSync, writeFileSync } from 'node:fs';
3
+ import { parse, stringify } from 'yaml';
3
4
  import { spawnSync, spawn } from 'node:child_process';
4
5
  import { homedir } from 'node:os';
5
6
  import { Readable } from 'node:stream';
6
7
  import { pipeline } from 'node:stream/promises';
7
- import { stringify } from 'yaml';
8
8
  import { hashSync } from 'bcryptjs';
9
9
  import { Eta } from 'eta';
10
10
  import { randomBytes } from 'node:crypto';
11
11
 
12
- const CACHE_HOME = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
13
- async function ensureCerts({ certs, hostname }) {
14
- const CAROOT = join(CACHE_HOME, 'visage/ca');
15
- mkdirSync(CAROOT, { recursive: true, mode: 0o700 });
16
- chmodSync(CAROOT, 0o700);
17
- const mkcert = await ensureMkCert();
18
- const logs = join(dirname(certs), 'logs');
19
- mkdirSync(logs, { recursive: true });
20
- const log = join(logs, 'mkcert.log');
21
- const output = openSync(log, 'w');
22
- const env = { CAROOT, TRUST_STORES: 'system', ...process.env };
23
- const tty = process.stdin.isTTY;
24
- const stdio = [
25
- tty ? 'inherit' : 'ignore',
26
- output,
27
- output,
28
- ];
29
- if (process.env.CI !== 'true') {
30
- // mkcert -install is idempotent; CA files alone do not prove trust-store state.
31
- const result = spawnSync(mkcert, ['-install'], { env, stdio });
32
- if (result.error)
33
- throw result.error;
34
- if (result.status !== 0) {
35
- throw new Error('Failed to install CA');
36
- }
37
- }
38
- const cert = join(certs, 'tls.crt');
39
- const key = join(certs, 'tls.key');
40
- mkdirSync(certs, { recursive: true });
41
- rmSync(cert, { force: true });
42
- rmSync(key, { force: true });
43
- const names = [...new Set([hostname, 'localhost', '127.0.0.1', '::1'])];
44
- const args = ['-cert-file', cert, '-key-file', key, ...names];
45
- const result = spawnSync(mkcert, args, { env, stdio });
46
- if (result.error)
47
- throw result.error;
48
- if (result.status !== 0) {
49
- throw new Error('Failed to generate TLS certificates');
50
- }
51
- }
52
- async function ensureMkCert() {
53
- const bin = join(CACHE_HOME, 'visage/bin');
54
- const file = join(bin, `mkcert-${process.platform}-${process.arch}`);
55
- if (existsSync(file))
56
- return file;
57
- mkdirSync(bin, { recursive: true });
58
- const base = 'https://dl.filippo.io/mkcert/latest';
59
- const arch = process.arch === 'x64' ? 'amd64' : process.arch;
60
- const params = `?for=${process.platform}/${arch}`;
61
- const url = new URL(params, base);
62
- const response = await fetch(url);
63
- if (!response.ok || !response.body) {
64
- throw new Error('Failed to download mkcert');
65
- }
66
- await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
67
- chmodSync(file, 0o755);
68
- return file;
69
- }
70
-
71
- let stopRef;
72
- function startCompose(file) {
73
- stopRef?.();
74
- stopRef = undefined;
75
- const logs = join(dirname(file), 'logs');
76
- mkdirSync(logs, { recursive: true });
77
- const output = openSync(join(logs, 'compose.log'), 'w');
78
- const compose = [
79
- 'compose',
80
- '--ansi=never',
81
- `--file=${file}`,
82
- `--project-name=${process.env.COMPOSE_PROJECT_NAME ?? 'visage'}`,
83
- ];
84
- const env = { ...process.env, COMPOSE_MENU: 'false' };
85
- const opts = {
86
- cwd: dirname(file),
87
- stdio: ['ignore', output, output],
88
- env,
89
- };
90
- const up = [
91
- ...compose,
92
- 'up',
93
- '--abort-on-container-failure',
94
- '--remove-orphans',
95
- ];
96
- const child = spawn('docker', up, opts);
97
- const stop = () => {
98
- if (stopRef !== stop)
99
- return;
100
- stopRef = undefined;
101
- child.kill();
102
- const down = [...compose, 'down', '--remove-orphans'];
103
- spawnSync('docker', down, opts);
104
- };
105
- stopRef = stop;
106
- return stop;
107
- }
108
-
109
12
  const BaseFiles = {
110
13
  certs: ['./certs', '/etc/nginx/certs'],
111
14
  compose: './compose.yaml',
@@ -115,29 +18,24 @@ const BaseFiles = {
115
18
  clientSecret: ['./oauth2-client-secret', '/etc/oauth2-proxy/client-secret'],
116
19
  cookieSecret: ['./oauth2-cookie-secret', '/etc/oauth2-proxy/cookie-secret'],
117
20
  };
21
+ const DockerImages = parse(readFileSync(new URL('../docker-compose.images.yml', import.meta.url), 'utf8')).services;
118
22
  const BaseServiceDex = {
119
- image: 'ghcr.io/dexidp/dex:v2.45.1',
23
+ image: DockerImages.dex.image,
120
24
  command: ['dex', 'serve', '/etc/dex/dex.yml'],
121
25
  restart: 'always',
122
26
  };
123
27
  const BaseServiceNginx = {
124
- image: 'nginx:1.30.0-alpine',
28
+ image: DockerImages.nginx.image,
125
29
  depends_on: ['oauth2_proxy'],
126
30
  extra_hosts: ['host.docker.internal:host-gateway'],
127
31
  restart: 'always',
128
32
  };
129
33
  const BaseServiceOAuth2Proxy = {
130
- image: 'quay.io/oauth2-proxy/oauth2-proxy:v7.15.2',
34
+ image: DockerImages.oauth2_proxy.image,
131
35
  command: ['--config', '/etc/oauth2-proxy/config.yml'],
132
36
  extra_hosts: ['host.docker.internal:host-gateway'],
133
37
  restart: 'always',
134
38
  };
135
- const BaseUpstreamDex = {
136
- host: 'dex',
137
- scheme: 'http',
138
- port: 5556,
139
- locations: { '/dex/': { auth: { enabled: false } } },
140
- };
141
39
  const BaseUpstreamOauth2Proxy = {
142
40
  host: 'oauth2_proxy',
143
41
  scheme: 'http',
@@ -159,10 +57,7 @@ const DefaultCookiePolicy = {
159
57
  cookie_secret_file: BaseFiles.cookieSecret[1],
160
58
  };
161
59
  const DefaultDexUsers = [
162
- {
163
- email: 'user@example.com',
164
- password: 'pass',
165
- },
60
+ { email: 'user@example.com', password: 'pass' },
166
61
  ];
167
62
  const DefaultOAuth2Client = {
168
63
  id: 'visage',
@@ -170,7 +65,6 @@ const DefaultOAuth2Client = {
170
65
  scopes: ['openid', 'email', 'profile', 'offline_access'],
171
66
  emailDomains: ['example.com']};
172
67
  const DefaultProxyPolicy = {
173
- auth: { enabled: true, forward: 'id', redirect: false },
174
68
  headers: {
175
69
  Cookie: '""', // Don't forward session cookie.
176
70
  Host: '$host',
@@ -183,7 +77,7 @@ const DefaultProxyPolicy = {
183
77
  },
184
78
  };
185
79
  function resolveOptions(options) {
186
- const { host = 'localhost', port = 9001, cookie = {}, oauth2 = {} } = options;
80
+ const { host = 'localhost', port = 9001, cookie = {}, idp = {}, oauth2 = {}, } = options;
187
81
  const cookieName = cookie.name ?? 'sess';
188
82
  const publicClient = oauth2.clientSecret === null;
189
83
  const services = resolveServicesOptions(options.services);
@@ -207,7 +101,19 @@ function resolveOptions(options) {
207
101
  : { cookie_domains: cookie.domains }),
208
102
  ...(cookie.path === undefined ? {} : { cookie_path: cookie.path }),
209
103
  },
210
- idp: resolveIdpOption(options.idp),
104
+ idp: 'issuer' in idp
105
+ ? idp
106
+ : {
107
+ dex: {
108
+ ...(idp.expiry ? { expiry: idp.expiry } : {}),
109
+ users: (idp.users ?? DefaultDexUsers).map((user) => ({
110
+ email: user.email,
111
+ password: user.password,
112
+ username: user.username ?? user.email.split('@', 1)[0],
113
+ userID: user.userID ?? user.email,
114
+ })),
115
+ },
116
+ },
211
117
  oauth2: {
212
118
  id: oauth2.clientId ?? DefaultOAuth2Client.id,
213
119
  ...(publicClient
@@ -274,78 +180,28 @@ function resolveUpstreamsOptions(services, upstreams = {}) {
274
180
  ])),
275
181
  };
276
182
  }
277
- function resolveIdpOption(idp) {
278
- if (idp && 'issuer' in idp) {
279
- return {
280
- issuer: idp.issuer,
281
- authorization: idp.authorization ?? '/auth',
282
- token: idp.token ?? '/token',
283
- jwks: idp.jwks ?? '/keys',
284
- };
285
- }
286
- return {
287
- dex: {
288
- ...(idp?.expiry ? { expiry: idp.expiry } : {}),
289
- users: (idp?.users ?? DefaultDexUsers).map((user) => ({
290
- email: user.email,
291
- password: user.password,
292
- username: user.username ?? user.email.split('@', 1)[0],
293
- userID: user.userID ?? user.email,
294
- })),
295
- },
296
- };
297
- }
298
- function resolveDirectives(directives = {}) {
299
- return Object.fromEntries(Object.entries(directives).map(([name, value]) => [
300
- name,
301
- Array.isArray(value) ? value : [value],
302
- ]));
303
- }
304
- function resolveIdpConfig({ host, port, idp, }) {
305
- if ('dex' in idp) {
306
- const issuer = `https://${host}:${port}/dex`;
307
- const upstream = `http://dex:5556/dex`;
308
- return {
309
- upstream: 'dex',
310
- issuer,
311
- authorization: `${issuer}/auth`,
312
- token: `${upstream}/token`,
313
- jwks: `${upstream}/keys`,
314
- dex: {
315
- expiry: idp.dex.expiry,
316
- users: (idp.dex?.users ?? DefaultDexUsers).map((user) => ({
317
- email: user.email,
318
- password: user.password,
319
- username: user.username ?? user.email.split('@', 1)[0],
320
- userID: user.userID ?? user.email,
321
- })),
322
- },
323
- };
324
- }
325
- return {
326
- upstream: 'idp',
327
- issuer: idp.issuer,
328
- authorization: idp.issuer + (idp.authorization ?? '/auth'),
329
- token: idp.issuer + (idp.token ?? '/token'),
330
- jwks: idp.issuer + (idp.jwks ?? '/keys'),
331
- };
332
- }
333
- function resolveExternalIdpUpstream(idp) {
334
- const issuer = new URL(idp.issuer);
335
- return {
336
- host: issuer.hostname,
337
- locations: {},
338
- scheme: issuer.protocol === 'https:' ? 'https' : 'http',
339
- port: Number(issuer.port) || (issuer.protocol === 'https:' ? 443 : 80),
340
- };
341
- }
342
183
  function resolveConfig(options, cache) {
343
184
  const idp = resolveIdpConfig(options);
344
185
  const upstreams = {
345
- oauth2_proxy: BaseUpstreamOauth2Proxy,
346
- ...(idp.dex === undefined
347
- ? { idp: resolveExternalIdpUpstream(idp) }
348
- : { dex: BaseUpstreamDex }),
186
+ oauth2_proxy: {
187
+ ...BaseUpstreamOauth2Proxy,
188
+ locations: {
189
+ ...BaseUpstreamOauth2Proxy.locations,
190
+ '/oauth2/sign_out': {
191
+ auth: { enabled: false },
192
+ headers: {
193
+ Cookie: '$http_cookie', // Forward session cookie.
194
+ 'X-Auth-Request-Redirect': idp.oidc.end_session_endpoint
195
+ ? JSON.stringify(idp.oidc.end_session_endpoint +
196
+ (idp.oidc.end_session_endpoint.includes('?') ? '&' : '?') +
197
+ 'id_token_hint={id_token}&post_logout_redirect_uri=' +
198
+ encodeURIComponent(`https://${options.host}:${options.port}/`))
199
+ : '/',
200
+ },
201
+ },
202
+ },
203
+ },
204
+ ...idp.upstream,
349
205
  ...options.upstreams,
350
206
  };
351
207
  return {
@@ -355,15 +211,19 @@ function resolveConfig(options, cache) {
355
211
  idp,
356
212
  oauth2: options.oauth2,
357
213
  cache,
358
- files: { ...BaseFiles },
214
+ files: BaseFiles,
215
+ network: {
216
+ name: process.env.COMPOSE_PROJECT_NAME ?? 'visage',
217
+ trustedProxyIps: [],
218
+ },
359
219
  services: {
360
- ...(idp.dex === undefined
361
- ? { nginx: BaseServiceNginx, oauth2_proxy: BaseServiceOAuth2Proxy }
362
- : {
220
+ ...('dex' in idp
221
+ ? {
363
222
  dex: BaseServiceDex,
364
223
  nginx: { ...BaseServiceNginx, depends_on: ['dex', 'oauth2_proxy'] },
365
224
  oauth2_proxy: { ...BaseServiceOAuth2Proxy, depends_on: ['dex'] },
366
- }),
225
+ }
226
+ : { nginx: BaseServiceNginx, oauth2_proxy: BaseServiceOAuth2Proxy }),
367
227
  ...Object.fromEntries(Object.entries(options.services).map(([name, { upstream: _upstream, ...service }]) => [
368
228
  name,
369
229
  { restart: 'on-failure', ...service },
@@ -377,33 +237,106 @@ function resolveConfig(options, cache) {
377
237
  {
378
238
  ...upstream,
379
239
  external,
380
- locations: Object.fromEntries(Object.entries(upstream.locations ?? {}).map(([path, policy]) => [
381
- path,
382
- {
383
- auth: { ...DefaultProxyPolicy.auth, ...policy.auth },
384
- headers: {
385
- ...(external
386
- ? { ...DefaultProxyPolicy.headers, Host: upstream.host }
387
- : DefaultProxyPolicy.headers),
388
- ...policy.headers,
389
- },
390
- directives: {
391
- ...DefaultProxyPolicy.directives,
392
- ...resolveDirectives(policy.directives),
240
+ locations: Object.fromEntries(Object.entries(upstream.locations ?? {}).map(([path, policy]) => {
241
+ const auth = resolveAuthPolicy(policy.auth, external && name !== 'vite');
242
+ return [
243
+ path,
244
+ {
245
+ auth,
246
+ csrf: policy.csrf ?? (auth.enabled ? 'api' : false),
247
+ headers: {
248
+ ...(external
249
+ ? { ...DefaultProxyPolicy.headers, Host: upstream.host }
250
+ : DefaultProxyPolicy.headers),
251
+ ...policy.headers,
252
+ },
253
+ directives: {
254
+ ...DefaultProxyPolicy.directives,
255
+ ...Object.fromEntries(Object.entries(policy.directives ?? {}).map(([name, value]) => [
256
+ name,
257
+ Array.isArray(value) ? value : [value],
258
+ ])),
259
+ },
393
260
  },
394
- },
395
- ])),
261
+ ];
262
+ })),
396
263
  },
397
264
  ];
398
265
  })),
399
266
  };
400
267
  }
268
+ function resolveIdpConfig({ host, port, idp, }) {
269
+ if ('dex' in idp) {
270
+ return {
271
+ dex: {
272
+ expiry: idp.dex.expiry,
273
+ users: (idp.dex?.users ?? DefaultDexUsers).map((user) => ({
274
+ email: user.email,
275
+ password: user.password,
276
+ username: user.username ?? user.email.split('@', 1)[0],
277
+ userID: user.userID ?? user.email,
278
+ })),
279
+ },
280
+ oidc: {
281
+ issuer: `https://${host}:${port}/dex`,
282
+ authorization: `https://${host}:${port}/dex/auth`,
283
+ token: 'http://dex:5556/dex/token',
284
+ jwks: 'http://dex:5556/dex/keys',
285
+ },
286
+ upstream: {
287
+ dex: {
288
+ host: 'dex',
289
+ scheme: 'http',
290
+ port: 5556,
291
+ locations: { '/dex/': { auth: { enabled: false } } },
292
+ },
293
+ },
294
+ };
295
+ }
296
+ const issuer = new URL(idp.issuer);
297
+ const oidc = {
298
+ issuer: idp.issuer,
299
+ ...(idp.end_session_endpoint === undefined
300
+ ? {}
301
+ : { end_session_endpoint: idp.end_session_endpoint }),
302
+ };
303
+ return {
304
+ oidc: !idp.authorization && !idp.token && !idp.jwks
305
+ ? oidc
306
+ : {
307
+ ...oidc,
308
+ authorization: idp.issuer + (idp.authorization ?? '/auth'),
309
+ token: idp.issuer + (idp.token ?? '/token'),
310
+ jwks: idp.issuer + (idp.jwks ?? '/keys'),
311
+ },
312
+ upstream: {
313
+ idp: {
314
+ scheme: issuer.protocol === 'https:' ? 'https' : 'http',
315
+ host: issuer.hostname,
316
+ port: Number(issuer.port) || (issuer.protocol === 'https:' ? 443 : 80),
317
+ locations: {},
318
+ },
319
+ },
320
+ };
321
+ }
322
+ function resolveAuthPolicy(auth = {}, external) {
323
+ return {
324
+ enabled: auth.enabled ?? true,
325
+ forward: auth.forward === true
326
+ ? external
327
+ ? 'access'
328
+ : 'id'
329
+ : (auth.forward ?? false),
330
+ redirect: auth.redirect ?? false,
331
+ };
332
+ }
401
333
  const BaseViteUpstream = {
402
334
  host: 'host.docker.internal',
403
335
  scheme: 'http',
404
336
  locations: {
405
337
  '/': {
406
- auth: { forward: undefined, redirect: true },
338
+ auth: { redirect: true },
339
+ csrf: 'app',
407
340
  headers: {
408
341
  Host: '$host',
409
342
  Upgrade: '$http_upgrade',
@@ -425,15 +358,14 @@ function resolveViteUpstream(vite = { locations: {} }) {
425
358
  ...Object.fromEntries(Object.entries(vite.locations ?? {}).map(([path, policy]) => {
426
359
  if (path !== '/')
427
360
  return [path, policy];
428
- const defaults = BaseViteUpstream.locations['/'];
361
+ const base = BaseViteUpstream.locations['/'];
429
362
  return [
430
363
  path,
431
364
  {
432
- ...defaults,
433
- ...policy,
434
- auth: { ...defaults.auth, ...policy.auth },
435
- headers: { ...defaults.headers, ...policy.headers },
436
- directives: { ...defaults.directives, ...policy.directives },
365
+ auth: { ...base.auth, ...policy.auth },
366
+ csrf: policy.csrf ?? base.csrf,
367
+ headers: { ...base.headers, ...policy.headers },
368
+ directives: { ...base.directives, ...policy.directives },
437
369
  },
438
370
  ];
439
371
  })),
@@ -441,12 +373,99 @@ function resolveViteUpstream(vite = { locations: {} }) {
441
373
  };
442
374
  }
443
375
 
376
+ const CACHE_HOME = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
377
+ async function ensureCerts(config) {
378
+ const CAROOT = join(CACHE_HOME, 'visage/ca');
379
+ mkdirSync(CAROOT, { recursive: true, mode: 0o700 });
380
+ chmodSync(CAROOT, 0o700);
381
+ const mkcert = await ensureMkCert();
382
+ mkdirSync(join(config.cache, 'logs'), { recursive: true });
383
+ const out = openSync(join(config.cache, 'logs', 'mkcert.log'), 'w');
384
+ const env = { CAROOT, TRUST_STORES: 'system', ...process.env };
385
+ const tty = process.stdin.isTTY;
386
+ const stdio = [tty ? 'inherit' : 'ignore', out, out];
387
+ if (process.env.CI !== 'true') {
388
+ // mkcert -install is idempotent; CA files alone do not prove trust-store state.
389
+ const result = spawnSync(mkcert, ['-install'], { env, stdio });
390
+ if (result.error)
391
+ throw result.error;
392
+ if (result.status !== 0) {
393
+ throw new Error('Failed to install CA');
394
+ }
395
+ }
396
+ const certs = join(config.cache, config.files.certs[0]);
397
+ const cert = join(certs, 'tls.crt');
398
+ const key = join(certs, 'tls.key');
399
+ mkdirSync(certs, { recursive: true });
400
+ rmSync(cert, { force: true });
401
+ rmSync(key, { force: true });
402
+ const names = [...new Set([config.host, 'localhost', '127.0.0.1', '::1'])];
403
+ const args = ['-cert-file', cert, '-key-file', key, ...names];
404
+ const result = spawnSync(mkcert, args, { env, stdio });
405
+ if (result.error)
406
+ throw result.error;
407
+ if (result.status !== 0) {
408
+ throw new Error('Failed to generate TLS certificates');
409
+ }
410
+ }
411
+ async function ensureMkCert() {
412
+ const bin = join(CACHE_HOME, 'visage/bin');
413
+ const file = join(bin, `mkcert-${process.platform}-${process.arch}`);
414
+ if (existsSync(file))
415
+ return file;
416
+ mkdirSync(bin, { recursive: true });
417
+ const base = 'https://dl.filippo.io/mkcert/latest';
418
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch;
419
+ const params = `?for=${process.platform}/${arch}`;
420
+ const url = new URL(params, base);
421
+ const response = await fetch(url);
422
+ if (!response.ok || !response.body) {
423
+ throw new Error('Failed to download mkcert');
424
+ }
425
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
426
+ chmodSync(file, 0o755);
427
+ return file;
428
+ }
429
+
430
+ let stopRef;
431
+ function startCompose(config) {
432
+ stopRef?.();
433
+ stopRef = undefined;
434
+ const file = join(config.cache, config.files.compose);
435
+ const logs = join(config.cache, 'logs');
436
+ const output = openSync(join(logs, 'compose.log'), 'w');
437
+ const compose = [
438
+ 'compose',
439
+ '--ansi=never',
440
+ `--file=${file}`,
441
+ `--project-name=${process.env.COMPOSE_PROJECT_NAME ?? 'visage'}`,
442
+ ];
443
+ const env = { ...process.env, COMPOSE_MENU: 'false' };
444
+ const opts = {
445
+ cwd: config.cache,
446
+ stdio: ['ignore', output, output],
447
+ env,
448
+ };
449
+ const up = [...compose, 'up', '--remove-orphans'];
450
+ const child = spawn('docker', up, opts);
451
+ const stop = () => {
452
+ if (stopRef !== stop)
453
+ return;
454
+ stopRef = undefined;
455
+ child.kill();
456
+ const down = [...compose, 'down', '--remove-orphans'];
457
+ spawnSync('docker', down, opts);
458
+ };
459
+ stopRef = stop;
460
+ return stop;
461
+ }
462
+
444
463
  const HOSTS_FILE = '/etc/hosts';
445
- function ensureHostEntry(hostname) {
446
- if (!hostname ||
447
- hostname.trim() !== hostname ||
448
- hostname.includes('/') ||
449
- hostname.includes(':')) {
464
+ function ensureHostEntry({ host }) {
465
+ if (!host ||
466
+ host.trim() !== host ||
467
+ host.includes('/') ||
468
+ host.includes(':')) {
450
469
  throw new Error('Invalid hostname');
451
470
  }
452
471
  const contents = readFileSync(HOSTS_FILE, 'utf8');
@@ -456,7 +475,7 @@ function ensureHostEntry(hostname) {
456
475
  continue;
457
476
  }
458
477
  const [address, ...names] = uncommented.split(/\s+/);
459
- if (!names.includes(hostname)) {
478
+ if (!names.includes(host)) {
460
479
  continue;
461
480
  }
462
481
  if (address === '127.0.0.1' || address === '::1') {
@@ -466,7 +485,7 @@ function ensureHostEntry(hostname) {
466
485
  throw new Error('Hosts file contains a conflicting entry');
467
486
  }
468
487
  const prefix = contents.endsWith('\n') ? '' : '\n';
469
- const entry = `${prefix}127.0.0.1\t${hostname} # visage\n`;
488
+ const entry = `${prefix}127.0.0.1\t${host} # visage\n`;
470
489
  try {
471
490
  appendFileSync(HOSTS_FILE, entry);
472
491
  return;
@@ -486,6 +505,65 @@ function ensureHostEntry(hostname) {
486
505
  }
487
506
  }
488
507
 
508
+ function ensureNginxNetwork(config) {
509
+ const exists = spawnSync('docker', [
510
+ 'network',
511
+ 'ls',
512
+ '--filter',
513
+ `name=${config.network.name}`,
514
+ '--format',
515
+ '{{ .Name }}',
516
+ ], { encoding: 'utf-8' });
517
+ if (exists.error)
518
+ throw exists.error;
519
+ if (exists.status !== 0) {
520
+ console.error(exists.stderr);
521
+ throw new Error('Failed to list Docker network');
522
+ }
523
+ if (exists.stdout) {
524
+ return {
525
+ ...config,
526
+ network: {
527
+ ...config.network,
528
+ trustedProxyIps: inspectNetwork(config.network.name),
529
+ },
530
+ };
531
+ }
532
+ const create = spawnSync('docker', ['network', 'create', '--driver', 'bridge', config.network.name], { encoding: 'utf-8' });
533
+ if (create.error)
534
+ throw create.error;
535
+ if (create.status !== 0) {
536
+ console.error(create.stderr);
537
+ throw new Error('Failed to create Docker network');
538
+ }
539
+ return {
540
+ ...config,
541
+ network: {
542
+ ...config.network,
543
+ trustedProxyIps: inspectNetwork(config.network.name),
544
+ },
545
+ };
546
+ }
547
+ function inspectNetwork(name) {
548
+ const result = spawnSync('docker', [
549
+ 'network',
550
+ 'inspect',
551
+ '--format',
552
+ '{{range .IPAM.Config}}{{println .Subnet}}{{end}}',
553
+ name,
554
+ ], { encoding: 'utf-8' });
555
+ if (result.error)
556
+ throw result.error;
557
+ if (result.status !== 0) {
558
+ console.error(result.stderr);
559
+ throw new Error('Failed to inspect Docker network');
560
+ }
561
+ return result.stdout
562
+ .split(/\r?\n/)
563
+ .map((line) => line.trim())
564
+ .filter(Boolean);
565
+ }
566
+
489
567
  function writeComposeConfig(config) {
490
568
  const file = join(config.cache, config.files.compose);
491
569
  const render = renderComposeConfig(config);
@@ -494,8 +572,9 @@ function writeComposeConfig(config) {
494
572
  function renderComposeConfig(config) {
495
573
  const { dex, nginx, oauth2_proxy, ...services } = config.services;
496
574
  return stringify({
575
+ networks: { default: { external: true, name: config.network.name } },
497
576
  services: {
498
- ...(config.idp.dex !== undefined
577
+ ...('dex' in config.idp
499
578
  ? {
500
579
  dex: {
501
580
  ...config.services.dex,
@@ -526,38 +605,34 @@ function renderComposeConfig(config) {
526
605
  }
527
606
 
528
607
  function writeDexConfig(config) {
529
- const file = join(config.cache, config.files.dex[0]);
530
608
  const render = renderDexConfig(config);
609
+ const file = join(config.cache, config.files.dex[0]);
531
610
  writeFileSync(file, render, 'utf-8');
532
611
  }
533
612
  function renderDexConfig(config) {
534
- const { idp } = config;
535
- if (idp.dex === undefined) {
536
- throw new Error('Dex config is required to render Dex');
537
- }
538
- const origin = `https://${config.host}:${config.port}`;
539
- const redirect = `${origin}/oauth2/callback`;
540
- const upstream = config.upstreams[idp.upstream];
613
+ if (!('dex' in config.idp))
614
+ throw new Error('Dex config missing');
615
+ const { host, port, oauth2, idp: { dex: { expiry, users }, oidc, upstream, }, } = config;
541
616
  return stringify({
542
- issuer: idp.issuer,
617
+ issuer: oidc.issuer,
543
618
  storage: { type: 'memory' },
544
- web: { http: `0.0.0.0:${upstream.port}` },
619
+ web: { http: `0.0.0.0:${upstream.dex.port}` },
545
620
  oauth2: { skipApprovalScreen: true },
546
621
  staticClients: [
547
622
  {
548
- id: config.oauth2.id,
623
+ id: oauth2.id,
549
624
  name: 'Visage',
550
- ...(config.oauth2.secret === undefined
625
+ ...(oauth2.secret === undefined
551
626
  ? { public: true }
552
- : { secret: config.oauth2.secret }),
553
- redirectURIs: [redirect],
627
+ : { secret: oauth2.secret }),
628
+ redirectURIs: [`https://${host}:${port}/oauth2/callback`],
554
629
  },
555
630
  ],
556
631
  enablePasswordDB: true,
557
- ...(idp.dex.expiry === undefined ? {} : { expiry: idp.dex.expiry }),
558
- staticPasswords: idp.dex.users.map(({ password, ...user }) => ({
632
+ ...(expiry === undefined ? {} : { expiry }),
633
+ staticPasswords: users.map(({ password, ...user }) => ({
559
634
  ...user,
560
- hash: hashSync(password, 10),
635
+ hash: hashSync(password),
561
636
  })),
562
637
  });
563
638
  }
@@ -582,6 +657,18 @@ http {
582
657
  '' close;
583
658
  }
584
659
 
660
+ # Fetch Metadata CSRF guards for cookie-backed locations.
661
+ map $http_sec_fetch_site $csrf_api {
662
+ default 0;
663
+ same-site 1;
664
+ cross-site 1;
665
+ }
666
+ map "$http_sec_fetch_site:$request_method:$http_sec_fetch_mode:$http_sec_fetch_dest" $csrf_app {
667
+ default 0;
668
+ ~^(cross-site|same-site):GET:navigate:document$ 0;
669
+ ~^(cross-site|same-site): 1;
670
+ }
671
+
585
672
  <%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
586
673
 
587
674
  upstream <%~ name %> {
@@ -608,8 +695,21 @@ http {
608
695
  error_page 497 =301 https://$http_host$request_uri;
609
696
 
610
697
  <%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
611
- <%_ for (const [path, location] of Object.entries(upstream.locations ?? {})) { %>
698
+ <%_ for (const [path, location] of Object.entries(upstream.locations)) { %>
612
699
  location <%~ path %> {
700
+ <%_ if (location.csrf) { %>
701
+ add_header Vary "Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest" always;
702
+ <%_ if (location.csrf === 'app') { %>
703
+ if ($csrf_app) {
704
+ return 403;
705
+ }
706
+ <%_ } else { %>
707
+ if ($csrf_api) {
708
+ return 403;
709
+ }
710
+ <%_ } %>
711
+
712
+ <%_ } %>
613
713
  <%_ if (location.auth?.enabled) { %>
614
714
  auth_request /oauth2/auth;
615
715
  auth_request_set $authorization $upstream_http_authorization;
@@ -646,7 +746,7 @@ http {
646
746
  <%_ } %>
647
747
  proxy_pass <%~ upstream.scheme %>://<%~ name %>;
648
748
  }
649
- <%_ } %>
749
+ <%_ } %>
650
750
 
651
751
  <%_ } %>
652
752
  }
@@ -703,11 +803,15 @@ function renderOauth2ProxyConfig(config) {
703
803
  const data = {
704
804
  http_address: `0.0.0.0:${config.upstreams.oauth2_proxy.port}`,
705
805
  provider: 'oidc',
706
- oidc_issuer_url: config.idp.issuer,
707
- skip_oidc_discovery: true,
708
- login_url: config.idp.authorization,
709
- redeem_url: config.idp.token,
710
- oidc_jwks_url: config.idp.jwks,
806
+ oidc_issuer_url: config.idp.oidc.issuer,
807
+ ...('authorization' in config.idp.oidc
808
+ ? {
809
+ skip_oidc_discovery: true,
810
+ login_url: config.idp.oidc.authorization,
811
+ redeem_url: config.idp.oidc.token,
812
+ oidc_jwks_url: config.idp.oidc.jwks,
813
+ }
814
+ : {}),
711
815
  redirect_url: `https://${config.host}:${config.port}/oauth2/callback`,
712
816
  client_id: config.oauth2.id,
713
817
  ...(config.oauth2.secret === undefined
@@ -723,9 +827,16 @@ function renderOauth2ProxyConfig(config) {
723
827
  cookie_csrf_per_request: true,
724
828
  cookie_csrf_per_request_limit: 16,
725
829
  email_domains: config.oauth2.emailDomains,
726
- whitelist_domains: [config.host, `${config.host}:${config.port}`],
830
+ whitelist_domains: [
831
+ config.host,
832
+ `${config.host}:${config.port}`,
833
+ ...(!('dex' in config.idp) && config.idp.oidc.end_session_endpoint
834
+ ? [new URL(config.idp.oidc.end_session_endpoint).host]
835
+ : []),
836
+ ],
727
837
  scope: config.oauth2.scopes.join(' '),
728
838
  reverse_proxy: true,
839
+ trusted_proxy_ips: config.network.trustedProxyIps,
729
840
  set_xauthrequest: true,
730
841
  set_authorization_header: true,
731
842
  pass_access_token: true,
@@ -746,36 +857,19 @@ function renderOauth2ProxyConfig(config) {
746
857
  .join('\n')}\n`;
747
858
  }
748
859
 
749
- function render(config) {
750
- writeComposeConfig(config);
751
- if (config.idp.dex !== undefined) {
752
- writeDexConfig(config);
753
- }
754
- writeNginxConfig(config);
755
- writeOauth2ProxyConfig(config);
756
- }
757
-
758
860
  function createVisageServer(options) {
861
+ const cache = join(process.cwd(), '.visage');
759
862
  const config = resolveConfig(resolveOptions({
760
863
  ...options,
761
864
  upstreams: {
762
865
  ...options.upstreams,
763
866
  vite: resolveViteUpstream(options.upstreams?.vite),
764
867
  },
765
- }), join(process.cwd(), '.visage'));
868
+ }), cache);
766
869
  let stop;
767
870
  return {
768
871
  async listen() {
769
- if (stop)
770
- return;
771
- rmSync(join(config.cache, 'logs'), { recursive: true, force: true });
772
- await ensureCerts({
773
- certs: join(config.cache, config.files.certs[0]),
774
- hostname: config.host,
775
- });
776
- ensureHostEntry(config.host);
777
- render(config);
778
- stop = await startCompose(join(config.cache, config.files.compose));
872
+ stop ??= await startVisageServer(config);
779
873
  },
780
874
  close() {
781
875
  stop?.();
@@ -783,6 +877,22 @@ function createVisageServer(options) {
783
877
  },
784
878
  };
785
879
  }
880
+ async function startVisageServer(config) {
881
+ const logs = join(config.cache, 'logs');
882
+ rmSync(logs, { recursive: true, force: true });
883
+ mkdirSync(logs, { recursive: true });
884
+ await ensureCerts(config);
885
+ ensureHostEntry(config);
886
+ const renderConfig = ensureNginxNetwork(config);
887
+ writeComposeConfig(renderConfig);
888
+ if ('dex' in renderConfig.idp) {
889
+ writeDexConfig(renderConfig);
890
+ }
891
+ writeNginxConfig(renderConfig);
892
+ writeOauth2ProxyConfig(renderConfig);
893
+ return startCompose(renderConfig);
894
+ }
895
+
786
896
  function visage(options = {}) {
787
897
  const resolvedOptions = resolveOptions(options);
788
898
  let stop;
@@ -813,21 +923,6 @@ function visage(options = {}) {
813
923
  printUrls();
814
924
  viteDevServer.config.logger.info(visageUrl ?? 'Visage failed to start');
815
925
  };
816
- async function startVisage(vite) {
817
- const config = resolveConfig(resolveOptions({
818
- ...options,
819
- upstreams: { ...options.upstreams, vite },
820
- }), join(viteDevServer.config.cacheDir, 'visage'));
821
- visageUrl = formatVisageUrlLog(config.host, config.port);
822
- rmSync(join(config.cache, 'logs'), { recursive: true, force: true });
823
- await ensureCerts({
824
- certs: join(config.cache, config.files.certs[0]),
825
- hostname: config.host,
826
- });
827
- ensureHostEntry(config.host);
828
- render(config);
829
- return startCompose(join(config.cache, config.files.compose));
830
- }
831
926
  // monkey patch vite's listen to get vite's auto-resolved port
832
927
  const listen = viteDevServer.listen.bind(viteDevServer);
833
928
  viteDevServer.listen = async (port, isRestart) => {
@@ -836,11 +931,19 @@ function visage(options = {}) {
836
931
  if (!address || typeof address === 'string') {
837
932
  throw new Error('Failed to resolve port for Visage');
838
933
  }
839
- const vite = resolveViteUpstream({
840
- port: address.port,
841
- ...options.upstreams?.vite,
842
- });
843
- stop = await startVisage(vite);
934
+ const cache = join(viteDevServer.config.cacheDir, 'visage');
935
+ const config = resolveConfig(resolveOptions({
936
+ ...options,
937
+ upstreams: {
938
+ ...options.upstreams,
939
+ vite: resolveViteUpstream({
940
+ port: address.port,
941
+ ...options.upstreams?.vite,
942
+ }),
943
+ },
944
+ }), cache);
945
+ visageUrl = formatVisageUrlLog(config.host, config.port);
946
+ stop = await startVisageServer(config);
844
947
  viteDevServer.httpServer?.once('close', closeBundle);
845
948
  return result;
846
949
  };
@@ -856,4 +959,4 @@ function formatVisageUrlLog(host, port) {
856
959
  return ` ${AnsiGreen}➜${AnsiReset} ${AnsiBold}Visage${AnsiReset}: ${AnsiCyan}https://${host}:${AnsiBold}${port}${AnsiReset}${AnsiCyan}/${AnsiReset}`;
857
960
  }
858
961
 
859
- export { createVisageServer, visage as default };
962
+ export { createVisageServer, visage as default, visage };