@specbook/vite 0.1.0 → 0.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/README.md +1 -1
- package/dist/index.js +162 -71
- package/package.json +3 -3
- package/src/discovery.ts +60 -0
- package/src/index.ts +21 -0
- package/src/proxy.ts +30 -1
package/README.md
CHANGED
|
@@ -4,4 +4,4 @@ The Specbook plugin for Vite: the registry, the docs UI hooks and the dev server
|
|
|
4
4
|
|
|
5
5
|
Part of [Specbook](https://github.com/specbook-dev/specbook), Storybook for APIs. See the repository for documentation.
|
|
6
6
|
|
|
7
|
-
Source-available under the [Functional Source License (FSL-1.1-MIT)](LICENSE)
|
|
7
|
+
Source-available under the [Functional Source License (FSL-1.1-MIT)](LICENSE). Free to use, including at work and in commercial projects; the only restriction is offering it, or something substantially similar built from it, to others as a competing commercial product or service. Each version becomes MIT two years after its release.
|
package/dist/index.js
CHANGED
|
@@ -5,78 +5,25 @@ import { createRequire } from "module";
|
|
|
5
5
|
import { basename, dirname, join as join4, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
|
|
6
6
|
import fg4 from "fast-glob";
|
|
7
7
|
|
|
8
|
-
// src/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function frontmatter(src) {
|
|
14
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
|
|
15
|
-
if (!m) return { meta: {}, body: src };
|
|
16
|
-
const meta = {};
|
|
17
|
-
for (const line of m[1].split(/\r?\n/)) {
|
|
18
|
-
const i = line.indexOf(":");
|
|
19
|
-
if (i > 0)
|
|
20
|
-
meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
8
|
+
// ../core/src/oauth.ts
|
|
9
|
+
var OAuthError = class extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "OAuthError";
|
|
21
13
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (flat && !NOT_AN_OPERATION.test(flat[2]))
|
|
34
|
-
return { operation: `${flat[1]}/${flat[2]}` };
|
|
35
|
-
return void 0;
|
|
36
|
-
}
|
|
37
|
-
async function scanDocs(root) {
|
|
38
|
-
const files = await fg("**/*.md", {
|
|
39
|
-
cwd: root,
|
|
40
|
-
absolute: true,
|
|
41
|
-
ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
|
|
42
|
-
});
|
|
43
|
-
const pages = [];
|
|
44
|
-
const claimed = /* @__PURE__ */ new Map();
|
|
45
|
-
for (const abs of files.sort()) {
|
|
46
|
-
const { meta, body } = frontmatter(await readFile(abs, "utf8"));
|
|
47
|
-
const file = relative(root, abs).split(sep).join("/");
|
|
48
|
-
const near = colocated(file);
|
|
49
|
-
if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
|
|
50
|
-
const operation = meta.operation ?? near?.operation;
|
|
51
|
-
if (operation) {
|
|
52
|
-
const first = claimed.get(operation);
|
|
53
|
-
if (first) {
|
|
54
|
-
console.warn(
|
|
55
|
-
`[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
|
|
56
|
-
);
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
claimed.set(operation, file);
|
|
60
|
-
}
|
|
61
|
-
pages.push({
|
|
62
|
-
file,
|
|
63
|
-
operation,
|
|
64
|
-
sequence: meta.sequence,
|
|
65
|
-
sequenceFile: near?.sequenceFile,
|
|
66
|
-
requestFile: near?.requestFile,
|
|
67
|
-
replace: meta.replace === "true" || void 0,
|
|
68
|
-
title: meta.title ?? meta.page,
|
|
69
|
-
html: marked.parse(body, { async: false })
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
return pages;
|
|
14
|
+
};
|
|
15
|
+
function parseDiscovery(json3) {
|
|
16
|
+
const d = json3 && typeof json3 === "object" ? json3 : {};
|
|
17
|
+
const url = (v) => typeof v === "string" && /^https?:\/\//i.test(v) ? v : void 0;
|
|
18
|
+
const authorizationUrl = url(d.authorization_endpoint);
|
|
19
|
+
const tokenUrl = url(d.token_endpoint);
|
|
20
|
+
if (!authorizationUrl || !tokenUrl)
|
|
21
|
+
throw new OAuthError("The provider\u2019s discovery document has no authorization or token endpoint");
|
|
22
|
+
const supported = Array.isArray(d.scopes_supported) ? d.scopes_supported.filter((x) => typeof x === "string") : [];
|
|
23
|
+
const names = ["openid", ...supported.filter((x) => x !== "openid")];
|
|
24
|
+
return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ""])) };
|
|
73
25
|
}
|
|
74
26
|
|
|
75
|
-
// src/environments.ts
|
|
76
|
-
import { existsSync } from "fs";
|
|
77
|
-
import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
78
|
-
import { join } from "path";
|
|
79
|
-
|
|
80
27
|
// ../core/src/proxy/policy.ts
|
|
81
28
|
var DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
82
29
|
var DEFAULT_MAX_BODY = 25 * 1024 * 1024;
|
|
@@ -371,7 +318,119 @@ function parseDotenv(text) {
|
|
|
371
318
|
return out;
|
|
372
319
|
}
|
|
373
320
|
|
|
321
|
+
// src/discovery.ts
|
|
322
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
323
|
+
function createDiscovery(opts = {}) {
|
|
324
|
+
const f = opts.fetch ?? fetch;
|
|
325
|
+
const now = opts.now ?? Date.now;
|
|
326
|
+
const retryAfter = opts.retryAfterMs ?? 3e4;
|
|
327
|
+
const found = /* @__PURE__ */ new Map();
|
|
328
|
+
const failedAt = /* @__PURE__ */ new Map();
|
|
329
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
330
|
+
async function look(url) {
|
|
331
|
+
try {
|
|
332
|
+
const res = await f(url, {
|
|
333
|
+
headers: { accept: "application/json" },
|
|
334
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
335
|
+
});
|
|
336
|
+
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
337
|
+
const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin;
|
|
338
|
+
found.set(url, origin);
|
|
339
|
+
failedAt.delete(url);
|
|
340
|
+
return origin;
|
|
341
|
+
} catch (e) {
|
|
342
|
+
failedAt.set(url, now());
|
|
343
|
+
opts.warn?.(
|
|
344
|
+
`Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`
|
|
345
|
+
);
|
|
346
|
+
return void 0;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return function tokenOrigin(url) {
|
|
350
|
+
const known = found.get(url);
|
|
351
|
+
if (known) return Promise.resolve(known);
|
|
352
|
+
const failed = failedAt.get(url);
|
|
353
|
+
if (failed !== void 0 && now() - failed < retryAfter) return Promise.resolve(void 0);
|
|
354
|
+
let pending = inFlight.get(url);
|
|
355
|
+
if (!pending) {
|
|
356
|
+
pending = look(url).finally(() => inFlight.delete(url));
|
|
357
|
+
inFlight.set(url, pending);
|
|
358
|
+
}
|
|
359
|
+
return pending;
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/docs.ts
|
|
364
|
+
import { readFile } from "fs/promises";
|
|
365
|
+
import { relative, sep } from "path";
|
|
366
|
+
import fg from "fast-glob";
|
|
367
|
+
import { marked } from "marked";
|
|
368
|
+
function frontmatter(src) {
|
|
369
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
|
|
370
|
+
if (!m) return { meta: {}, body: src };
|
|
371
|
+
const meta = {};
|
|
372
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
373
|
+
const i = line.indexOf(":");
|
|
374
|
+
if (i > 0)
|
|
375
|
+
meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
376
|
+
}
|
|
377
|
+
return { meta, body: src.slice(m[0].length) };
|
|
378
|
+
}
|
|
379
|
+
var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
|
|
380
|
+
var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
|
|
381
|
+
var NOT_AN_OPERATION = /^(readme|index)$/i;
|
|
382
|
+
function colocated(file) {
|
|
383
|
+
if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
|
|
384
|
+
if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
|
|
385
|
+
const folder = OP_FOLDER.exec(file);
|
|
386
|
+
if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
|
|
387
|
+
const flat = OP_FLAT.exec(file);
|
|
388
|
+
if (flat && !NOT_AN_OPERATION.test(flat[2]))
|
|
389
|
+
return { operation: `${flat[1]}/${flat[2]}` };
|
|
390
|
+
return void 0;
|
|
391
|
+
}
|
|
392
|
+
async function scanDocs(root) {
|
|
393
|
+
const files = await fg("**/*.md", {
|
|
394
|
+
cwd: root,
|
|
395
|
+
absolute: true,
|
|
396
|
+
ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
|
|
397
|
+
});
|
|
398
|
+
const pages = [];
|
|
399
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
400
|
+
for (const abs of files.sort()) {
|
|
401
|
+
const { meta, body } = frontmatter(await readFile(abs, "utf8"));
|
|
402
|
+
const file = relative(root, abs).split(sep).join("/");
|
|
403
|
+
const near = colocated(file);
|
|
404
|
+
if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
|
|
405
|
+
const operation = meta.operation ?? near?.operation;
|
|
406
|
+
if (operation) {
|
|
407
|
+
const first = claimed.get(operation);
|
|
408
|
+
if (first) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
|
|
411
|
+
);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
claimed.set(operation, file);
|
|
415
|
+
}
|
|
416
|
+
pages.push({
|
|
417
|
+
file,
|
|
418
|
+
operation,
|
|
419
|
+
sequence: meta.sequence,
|
|
420
|
+
sequenceFile: near?.sequenceFile,
|
|
421
|
+
requestFile: near?.requestFile,
|
|
422
|
+
replace: meta.replace === "true" || void 0,
|
|
423
|
+
title: meta.title ?? meta.page,
|
|
424
|
+
html: marked.parse(body, { async: false })
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return pages;
|
|
428
|
+
}
|
|
429
|
+
|
|
374
430
|
// src/environments.ts
|
|
431
|
+
import { existsSync } from "fs";
|
|
432
|
+
import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
433
|
+
import { join } from "path";
|
|
375
434
|
var ENV_NAME = /^[A-Za-z0-9_-]+$/;
|
|
376
435
|
var VAR_NAME = /^[A-Za-z_][\w.-]*$/;
|
|
377
436
|
var environmentsDir = (root) => join(root, ".specbook", "environments");
|
|
@@ -541,6 +600,15 @@ function requestsDirFor(root) {
|
|
|
541
600
|
}
|
|
542
601
|
|
|
543
602
|
// src/proxy.ts
|
|
603
|
+
var AUTH_SERVICE_PREFIX = "auth:";
|
|
604
|
+
var DISCOVERY_SERVICE_PREFIX = "discovery:";
|
|
605
|
+
var decode = (s) => {
|
|
606
|
+
try {
|
|
607
|
+
return decodeURIComponent(s);
|
|
608
|
+
} catch {
|
|
609
|
+
return s;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
544
612
|
var MOUNT = "/__specbook/proxy";
|
|
545
613
|
function readBody(req) {
|
|
546
614
|
return new Promise((res, rej) => {
|
|
@@ -568,7 +636,13 @@ async function handleProxy2(req, res, deps) {
|
|
|
568
636
|
const response = await handleProxy(request, {
|
|
569
637
|
mount: MOUNT,
|
|
570
638
|
policy: {
|
|
571
|
-
upstream: (env, service) =>
|
|
639
|
+
upstream: (env, service) => {
|
|
640
|
+
if (service.startsWith(AUTH_SERVICE_PREFIX))
|
|
641
|
+
return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
|
|
642
|
+
if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
|
|
643
|
+
return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
|
|
644
|
+
return config.services[service]?.baseUrl[env];
|
|
645
|
+
},
|
|
572
646
|
// a developer's own machine: their local services are the point
|
|
573
647
|
allowPrivateHosts: true
|
|
574
648
|
},
|
|
@@ -805,6 +879,7 @@ function specbook(options) {
|
|
|
805
879
|
let notifyRegistryChanged = () => {
|
|
806
880
|
};
|
|
807
881
|
const memorySecrets = {};
|
|
882
|
+
const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) });
|
|
808
883
|
async function registryCode() {
|
|
809
884
|
const files = await scanProject(root);
|
|
810
885
|
const manifests = {};
|
|
@@ -1088,11 +1163,27 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1088
1163
|
if (!server || !cfgFile) return { services: {}, secretNames: {} };
|
|
1089
1164
|
const mod = await server.ssrLoadModule(cfgFile);
|
|
1090
1165
|
const cfg = mod.default ?? {};
|
|
1166
|
+
const tokenOrigins = {};
|
|
1167
|
+
const discoveryOrigins = {};
|
|
1168
|
+
for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
|
|
1169
|
+
try {
|
|
1170
|
+
const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {};
|
|
1171
|
+
if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin;
|
|
1172
|
+
else if (openIdConnectUrl) {
|
|
1173
|
+
discoveryOrigins[name] = new URL(openIdConnectUrl).origin;
|
|
1174
|
+
const token = await tokenOriginOf(openIdConnectUrl);
|
|
1175
|
+
if (token) tokenOrigins[name] = token;
|
|
1176
|
+
}
|
|
1177
|
+
} catch {
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1091
1180
|
return {
|
|
1092
1181
|
services: Object.fromEntries(
|
|
1093
1182
|
Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }])
|
|
1094
1183
|
),
|
|
1095
|
-
secretNames: cfg.secrets ?? {}
|
|
1184
|
+
secretNames: cfg.secrets ?? {},
|
|
1185
|
+
tokenOrigins,
|
|
1186
|
+
discoveryOrigins
|
|
1096
1187
|
};
|
|
1097
1188
|
}
|
|
1098
1189
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/vite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The Specbook plugin for Vite: the registry, the docs UI hooks and the dev server credential proxy.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"specbook",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@specbook/codegen": "0.
|
|
38
|
-
"@specbook/core": "0.
|
|
37
|
+
"@specbook/codegen": "0.2.0",
|
|
38
|
+
"@specbook/core": "0.2.0",
|
|
39
39
|
"fast-glob": "3.3.2",
|
|
40
40
|
"marked": "^18.1.1"
|
|
41
41
|
},
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseDiscovery } from '@specbook/core'
|
|
2
|
+
|
|
3
|
+
const FETCH_TIMEOUT_MS = 5_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Learns, on the server, where an OpenID Connect provider's token endpoint is. The page needs the proxy to
|
|
7
|
+
* forward its code exchange there, and the proxy only forwards to origins it was told about by the
|
|
8
|
+
* project's own config; a discovery document is the config's pointer to that origin. Successes are kept
|
|
9
|
+
* for the life of the dev server; a failure is not retried for a short while, so a provider that is down
|
|
10
|
+
* costs one slow request, not one per call.
|
|
11
|
+
*/
|
|
12
|
+
export function createDiscovery(
|
|
13
|
+
opts: {
|
|
14
|
+
fetch?: typeof fetch
|
|
15
|
+
retryAfterMs?: number
|
|
16
|
+
now?: () => number
|
|
17
|
+
warn?: (message: string) => void
|
|
18
|
+
} = {},
|
|
19
|
+
) {
|
|
20
|
+
const f = opts.fetch ?? fetch
|
|
21
|
+
const now = opts.now ?? Date.now
|
|
22
|
+
const retryAfter = opts.retryAfterMs ?? 30_000
|
|
23
|
+
const found = new Map<string, string>()
|
|
24
|
+
const failedAt = new Map<string, number>()
|
|
25
|
+
const inFlight = new Map<string, Promise<string | undefined>>()
|
|
26
|
+
|
|
27
|
+
async function look(url: string): Promise<string | undefined> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await f(url, {
|
|
30
|
+
headers: { accept: 'application/json' },
|
|
31
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
32
|
+
})
|
|
33
|
+
if (!res.ok) throw new Error(`answered ${res.status}`)
|
|
34
|
+
const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin
|
|
35
|
+
found.set(url, origin)
|
|
36
|
+
failedAt.delete(url)
|
|
37
|
+
return origin
|
|
38
|
+
} catch (e) {
|
|
39
|
+
failedAt.set(url, now())
|
|
40
|
+
opts.warn?.(
|
|
41
|
+
`Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`,
|
|
42
|
+
)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The origin of the provider's token endpoint, or `undefined` when discovery has failed. */
|
|
48
|
+
return function tokenOrigin(url: string): Promise<string | undefined> {
|
|
49
|
+
const known = found.get(url)
|
|
50
|
+
if (known) return Promise.resolve(known)
|
|
51
|
+
const failed = failedAt.get(url)
|
|
52
|
+
if (failed !== undefined && now() - failed < retryAfter) return Promise.resolve(undefined)
|
|
53
|
+
let pending = inFlight.get(url)
|
|
54
|
+
if (!pending) {
|
|
55
|
+
pending = look(url).finally(() => inFlight.delete(url))
|
|
56
|
+
inFlight.set(url, pending)
|
|
57
|
+
}
|
|
58
|
+
return pending
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
|
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
6
6
|
import fg from 'fast-glob'
|
|
7
7
|
import type { Plugin, ViteDevServer } from 'vite'
|
|
8
|
+
import { createDiscovery } from './discovery'
|
|
8
9
|
import { scanDocs } from './docs'
|
|
9
10
|
import {
|
|
10
11
|
ensureEnvironmentsDir,
|
|
@@ -156,6 +157,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
156
157
|
let notifyRegistryChanged: () => void = () => {}
|
|
157
158
|
/** Credentials entered in the UI: never written to disk, gone when the server stops. */
|
|
158
159
|
const memorySecrets: Record<string, Record<string, string>> = {}
|
|
160
|
+
// where each OpenID Connect profile's token endpoint is, read once from its discovery document
|
|
161
|
+
const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) })
|
|
159
162
|
|
|
160
163
|
async function registryCode(): Promise<string> {
|
|
161
164
|
const files = await scanProject(root)
|
|
@@ -438,12 +441,30 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
438
441
|
const cfg = (mod.default ?? {}) as {
|
|
439
442
|
services?: Record<string, { baseUrl?: Record<string, string> }>
|
|
440
443
|
secrets?: Record<string, { env?: string }>
|
|
444
|
+
auth?: Record<string, { oauth?: { tokenUrl?: string; openIdConnectUrl?: string } }>
|
|
445
|
+
}
|
|
446
|
+
const tokenOrigins: Record<string, string> = {}
|
|
447
|
+
const discoveryOrigins: Record<string, string> = {}
|
|
448
|
+
for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
|
|
449
|
+
try {
|
|
450
|
+
const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {}
|
|
451
|
+
if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin
|
|
452
|
+
else if (openIdConnectUrl) {
|
|
453
|
+
discoveryOrigins[name] = new URL(openIdConnectUrl).origin
|
|
454
|
+
const token = await tokenOriginOf(openIdConnectUrl)
|
|
455
|
+
if (token) tokenOrigins[name] = token
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
/* an unparsable URL is left out; a sign-in against it fails with a 404 */
|
|
459
|
+
}
|
|
441
460
|
}
|
|
442
461
|
return {
|
|
443
462
|
services: Object.fromEntries(
|
|
444
463
|
Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }]),
|
|
445
464
|
),
|
|
446
465
|
secretNames: cfg.secrets ?? {},
|
|
466
|
+
tokenOrigins,
|
|
467
|
+
discoveryOrigins,
|
|
447
468
|
}
|
|
448
469
|
}
|
|
449
470
|
|
package/src/proxy.ts
CHANGED
|
@@ -4,6 +4,29 @@ import { handleProxy as proxy } from '@specbook/core'
|
|
|
4
4
|
export interface ProxyConfig {
|
|
5
5
|
services: Record<string, { baseUrl: Record<string, string | undefined> }>
|
|
6
6
|
secretNames: Record<string, { env?: string }>
|
|
7
|
+
/**
|
|
8
|
+
* Origin of each OAuth profile's token endpoint, read from the project's own config. A sign-in
|
|
9
|
+
* exchanges its code through `<mount>/<env>/auth:<profile>/…`; the page chooses the path, never the host.
|
|
10
|
+
*/
|
|
11
|
+
tokenOrigins?: Record<string, string>
|
|
12
|
+
/**
|
|
13
|
+
* Origin of each OpenID Connect profile's discovery document, addressed as
|
|
14
|
+
* `<mount>/<env>/discovery:<profile>/…` (the token endpoint may be on another host).
|
|
15
|
+
*/
|
|
16
|
+
discoveryOrigins?: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The reserved service name a sign-in's token exchange is addressed to. */
|
|
20
|
+
export const AUTH_SERVICE_PREFIX = 'auth:'
|
|
21
|
+
/** The reserved service name a sign-in's discovery document is fetched through. */
|
|
22
|
+
export const DISCOVERY_SERVICE_PREFIX = 'discovery:'
|
|
23
|
+
|
|
24
|
+
const decode = (s: string) => {
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(s)
|
|
27
|
+
} catch {
|
|
28
|
+
return s
|
|
29
|
+
}
|
|
7
30
|
}
|
|
8
31
|
|
|
9
32
|
export interface ProxyDeps {
|
|
@@ -61,7 +84,13 @@ export async function handleProxy(
|
|
|
61
84
|
const response = await proxy(request, {
|
|
62
85
|
mount: MOUNT,
|
|
63
86
|
policy: {
|
|
64
|
-
upstream: (env, service) =>
|
|
87
|
+
upstream: (env, service) => {
|
|
88
|
+
if (service.startsWith(AUTH_SERVICE_PREFIX))
|
|
89
|
+
return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))]
|
|
90
|
+
if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
|
|
91
|
+
return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))]
|
|
92
|
+
return config.services[service]?.baseUrl[env]
|
|
93
|
+
},
|
|
65
94
|
// a developer's own machine: their local services are the point
|
|
66
95
|
allowPrivateHosts: true,
|
|
67
96
|
},
|