hammerkit 1.23.0 → 1.24.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/build.schema.json +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/program.js +4 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +154 -0
- package/dist/remote/remote-work-commands.js +152 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +4 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HostExecutor = exports.passedEnvironment = void 0;
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const cli_1 = require("../cli");
|
|
7
|
+
const get_file_context_1 = require("../file/get-file-context");
|
|
8
|
+
const log_1 = require("../log");
|
|
9
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
10
|
+
const parse_label_arguments_1 = require("../parser/parse-label-arguments");
|
|
11
|
+
const schema_parser_1 = require("../schema/schema-parser");
|
|
12
|
+
const reference_parser_1 = require("../schema/reference-parser");
|
|
13
|
+
const work_scope_parser_1 = require("../schema/work-scope-parser");
|
|
14
|
+
const work_item_status_1 = require("../planner/work-item-status");
|
|
15
|
+
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
16
|
+
const redact_1 = require("../utils/redact");
|
|
17
|
+
const empty_writable_1 = require("../utils/empty-writable");
|
|
18
|
+
const units_1 = require("../utils/units");
|
|
19
|
+
const git_checkout_1 = require("./git-checkout");
|
|
20
|
+
// What a task on the server sees of the server's own environment: the tools it
|
|
21
|
+
// needs to find and reach docker, nothing the operator keeps there for the server.
|
|
22
|
+
const PASSED_ENVS = [
|
|
23
|
+
'PATH',
|
|
24
|
+
'HOME',
|
|
25
|
+
'LANG',
|
|
26
|
+
'LC_ALL',
|
|
27
|
+
'TMPDIR',
|
|
28
|
+
'DOCKER_HOST',
|
|
29
|
+
'DOCKER_CONFIG',
|
|
30
|
+
'SSL_CERT_FILE',
|
|
31
|
+
'HTTP_PROXY',
|
|
32
|
+
'HTTPS_PROXY',
|
|
33
|
+
'NO_PROXY',
|
|
34
|
+
'http_proxy',
|
|
35
|
+
'https_proxy',
|
|
36
|
+
'no_proxy',
|
|
37
|
+
'SSH_AUTH_SOCK',
|
|
38
|
+
// Windows: without these powershell and git can not find their profile
|
|
39
|
+
'SystemRoot',
|
|
40
|
+
'SystemDrive',
|
|
41
|
+
'windir',
|
|
42
|
+
'ComSpec',
|
|
43
|
+
'PATHEXT',
|
|
44
|
+
'USERPROFILE',
|
|
45
|
+
'APPDATA',
|
|
46
|
+
'LOCALAPPDATA',
|
|
47
|
+
'TEMP',
|
|
48
|
+
'TMP',
|
|
49
|
+
'ProgramData',
|
|
50
|
+
'ProgramFiles',
|
|
51
|
+
'ProgramFiles(x86)',
|
|
52
|
+
// powershell is very slow to start without it
|
|
53
|
+
'PSModulePath',
|
|
54
|
+
];
|
|
55
|
+
// one log line of a task is cut here, the registry caps how many events a run keeps
|
|
56
|
+
const MAX_LOG_TEXT = 8 * 1024;
|
|
57
|
+
function passedEnvironment(envs) {
|
|
58
|
+
const passed = {};
|
|
59
|
+
for (const name of PASSED_ENVS) {
|
|
60
|
+
if (envs[name] !== undefined) {
|
|
61
|
+
passed[name] = envs[name];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return passed;
|
|
65
|
+
}
|
|
66
|
+
exports.passedEnvironment = passedEnvironment;
|
|
67
|
+
/**
|
|
68
|
+
* Runs the tasks of a run on this machine with the engine of `hammerkit run`.
|
|
69
|
+
*
|
|
70
|
+
* The working directory is made for the run and removed afterwards. Its build
|
|
71
|
+
* file is the published version the run is pinned to, written over the code of the
|
|
72
|
+
* source, so the commands are the published ones whatever the source says. The
|
|
73
|
+
* local cache is shared by all runs, a task is keyed by its definition and
|
|
74
|
+
* sources and not by where they are.
|
|
75
|
+
*/
|
|
76
|
+
class HostExecutor {
|
|
77
|
+
constructor(options) {
|
|
78
|
+
this.options = options;
|
|
79
|
+
}
|
|
80
|
+
async execute(run, context) {
|
|
81
|
+
const { definitions, config, environment } = this.options;
|
|
82
|
+
const version = definitions.find(run.definition.name, run.definition.hash);
|
|
83
|
+
const bundle = version ? await definitions.files(version) : undefined;
|
|
84
|
+
if (!version || !bundle) {
|
|
85
|
+
throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} is no longer on this server`);
|
|
86
|
+
}
|
|
87
|
+
if (run.source.type !== 'ref') {
|
|
88
|
+
throw new Error('runs from an upload are not supported by this server yet, use a git ref');
|
|
89
|
+
}
|
|
90
|
+
const repository = config.sources[run.definition.name]?.git;
|
|
91
|
+
if (!repository) {
|
|
92
|
+
throw new Error(`this server has no repository for ${run.definition.name} to run ${run.source.ref} from, ` +
|
|
93
|
+
`the operator sets sources.${run.definition.name}.git`);
|
|
94
|
+
}
|
|
95
|
+
const abortCtrl = new AbortController();
|
|
96
|
+
const abort = () => abortCtrl.abort();
|
|
97
|
+
if (context.signal.aborted) {
|
|
98
|
+
abort();
|
|
99
|
+
}
|
|
100
|
+
context.signal.addEventListener('abort', abort, { once: true });
|
|
101
|
+
const root = (0, path_1.join)(this.options.directory, run.id);
|
|
102
|
+
const checkout = (0, path_1.join)(root, 'source');
|
|
103
|
+
try {
|
|
104
|
+
const passed = passedEnvironment(environment.processEnvs);
|
|
105
|
+
const commit = await (0, git_checkout_1.checkoutRef)(repository, run.source.ref, checkout, passed, abortCtrl.signal);
|
|
106
|
+
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `checked out ${commit}` });
|
|
107
|
+
await this.writeBundle(checkout, bundle.files);
|
|
108
|
+
await this.runTasks(run, context, (0, path_1.join)(checkout, version.entry), checkout, passed, abortCtrl);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
context.signal.removeEventListener('abort', abort);
|
|
112
|
+
await (0, promises_1.rm)(root, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// The published files go over the checkout; nothing may land outside of it or in
|
|
116
|
+
// its git directory.
|
|
117
|
+
async writeBundle(directory, files) {
|
|
118
|
+
for (const [path, content] of Object.entries(files)) {
|
|
119
|
+
const target = (0, path_1.resolve)(directory, path);
|
|
120
|
+
if (!(0, resolve_git_source_1.isWithin)(directory, target) || /^\.git(\/|$)/.test(path)) {
|
|
121
|
+
throw new Error(`the published file ${JSON.stringify(path)} can not be written into the source`);
|
|
122
|
+
}
|
|
123
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(target), { recursive: true });
|
|
124
|
+
await (0, promises_1.writeFile)(target, content);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async runTasks(run, context, entry, cwd, passed, abortCtrl) {
|
|
128
|
+
const secrets = (0, redact_1.createSecretRegistry)();
|
|
129
|
+
const status = (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets);
|
|
130
|
+
// what the engine would print to a terminal is not wanted, its status console
|
|
131
|
+
// is what is streamed: it is redacted with the secrets of this run
|
|
132
|
+
const environment = {
|
|
133
|
+
processEnvs: passed,
|
|
134
|
+
abortCtrl,
|
|
135
|
+
cwd,
|
|
136
|
+
file: (0, get_file_context_1.getFileContext)(cwd),
|
|
137
|
+
console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
|
|
138
|
+
status,
|
|
139
|
+
secrets,
|
|
140
|
+
stdout: (0, empty_writable_1.emptyWritable)(),
|
|
141
|
+
stderr: (0, empty_writable_1.emptyWritable)(),
|
|
142
|
+
stdoutColumns: 120,
|
|
143
|
+
};
|
|
144
|
+
const listener = status.on((message) => {
|
|
145
|
+
if (message.type === 'console') {
|
|
146
|
+
context.emit({
|
|
147
|
+
type: 'log',
|
|
148
|
+
task: message.context.name,
|
|
149
|
+
stream: message.console,
|
|
150
|
+
text: message.message.slice(0, MAX_LOG_TEXT),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (message.level === 'error' || message.level === 'warn') {
|
|
154
|
+
context.emit({
|
|
155
|
+
type: 'log',
|
|
156
|
+
task: message.context.name,
|
|
157
|
+
stream: 'stderr',
|
|
158
|
+
text: message.message.slice(0, MAX_LOG_TEXT),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
const options = run.options;
|
|
164
|
+
const execOptions = {
|
|
165
|
+
logMode: 'live',
|
|
166
|
+
cacheDefault: options.cache ?? 'checksum',
|
|
167
|
+
cacheReadOnly: options.cacheReadOnly ?? false,
|
|
168
|
+
timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
|
|
169
|
+
skipDeps: options.skipDeps !== false,
|
|
170
|
+
};
|
|
171
|
+
// the tasks in the order they were named; by labels when none is
|
|
172
|
+
const scopes = run.tasks.length > 0
|
|
173
|
+
? run.tasks.map((task) => ({
|
|
174
|
+
name: task,
|
|
175
|
+
scope: { taskName: task, environmentName: options.env ?? null },
|
|
176
|
+
}))
|
|
177
|
+
: [
|
|
178
|
+
{
|
|
179
|
+
name: 'labels',
|
|
180
|
+
scope: {
|
|
181
|
+
filterLabels: labels(options.filter),
|
|
182
|
+
excludeLabels: labels(options.exclude),
|
|
183
|
+
environmentName: options.env ?? null,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
for (const { name, scope } of scopes) {
|
|
188
|
+
const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(entry, environment);
|
|
189
|
+
const workTree = (0, work_scope_parser_1.getWorkContext)(await (0, reference_parser_1.parseReferences)(ctx, parsed, environment), scope, environment);
|
|
190
|
+
const cli = (0, cli_1.getCli)(workTree, environment);
|
|
191
|
+
if (cli.tasks().length === 0) {
|
|
192
|
+
throw new Error(`no tasks found for ${name}`);
|
|
193
|
+
}
|
|
194
|
+
const execution = cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
|
|
195
|
+
const seen = new Map();
|
|
196
|
+
const watch = execution.state.on('run-events', () => reportTasks(context, execution.state.current, seen));
|
|
197
|
+
try {
|
|
198
|
+
const result = await execution.start();
|
|
199
|
+
reportTasks(context, execution.state.current, seen);
|
|
200
|
+
if (!result.success) {
|
|
201
|
+
throw new Error(context.signal.aborted ? 'the run was cancelled' : `${name} failed`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
watch.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
listener.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
exports.HostExecutor = HostExecutor;
|
|
215
|
+
function labels(values) {
|
|
216
|
+
return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
|
|
217
|
+
}
|
|
218
|
+
// A task event when a task has moved on since the last look.
|
|
219
|
+
function reportTasks(context, workTree, seen) {
|
|
220
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
221
|
+
const state = task.state.current;
|
|
222
|
+
let event = null;
|
|
223
|
+
if (state.type === 'running') {
|
|
224
|
+
event = 'running';
|
|
225
|
+
}
|
|
226
|
+
else if (state.type === 'completed') {
|
|
227
|
+
event = state.skipped ? null : state.cached ? 'cached' : 'succeeded';
|
|
228
|
+
}
|
|
229
|
+
else if (state.type === 'error' || state.type === 'crash') {
|
|
230
|
+
event = 'failed';
|
|
231
|
+
}
|
|
232
|
+
if (event && seen.get(task.name) !== event) {
|
|
233
|
+
seen.set(task.name, event);
|
|
234
|
+
context.emit({ type: 'task', task: task.name, state: event });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkoutRef = exports.isValidRef = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const util_1 = require("util");
|
|
7
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
8
|
+
// Anything but plain network and local transports is refused: `ext::` runs a command.
|
|
9
|
+
const ALLOWED_PROTOCOLS = 'file:git:http:https:ssh';
|
|
10
|
+
// A branch, a tag or a commit. It is passed after `--` as well, but a ref that
|
|
11
|
+
// looks like an option, has a `..` or odd characters is not one of the three.
|
|
12
|
+
const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
|
|
13
|
+
function isValidRef(ref) {
|
|
14
|
+
return (REF_PATTERN.test(ref) && !ref.includes('..') && !ref.includes('//') && !ref.endsWith('/') && !ref.endsWith('.lock'));
|
|
15
|
+
}
|
|
16
|
+
exports.isValidRef = isValidRef;
|
|
17
|
+
/**
|
|
18
|
+
* Fetches one commit of a repository into `directory` and checks it out, and
|
|
19
|
+
* answers which commit that is. The repository is the operator's, the ref is the
|
|
20
|
+
* caller's. Credentials come from the git configuration of the server, there is no
|
|
21
|
+
* prompt a run could hang on.
|
|
22
|
+
*/
|
|
23
|
+
async function checkoutRef(repository, ref, directory, env, signal) {
|
|
24
|
+
if (!isValidRef(ref)) {
|
|
25
|
+
throw new Error(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
|
|
26
|
+
}
|
|
27
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
28
|
+
const run = (args) => git(args, env, signal, `${repository} at ${ref}`);
|
|
29
|
+
await run(['init', '--quiet', directory]);
|
|
30
|
+
await run(['-C', directory, 'fetch', '--quiet', '--depth', '1', '--', repository, ref]);
|
|
31
|
+
await run(['-C', directory, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', 'FETCH_HEAD']);
|
|
32
|
+
return (await run(['-C', directory, 'rev-parse', 'HEAD'])).trim();
|
|
33
|
+
}
|
|
34
|
+
exports.checkoutRef = checkoutRef;
|
|
35
|
+
async function git(args, baseEnv, signal, what) {
|
|
36
|
+
const env = {};
|
|
37
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
38
|
+
if (value !== undefined) {
|
|
39
|
+
env[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
43
|
+
env.GIT_ALLOW_PROTOCOL = ALLOWED_PROTOCOLS;
|
|
44
|
+
try {
|
|
45
|
+
return (await execFileAsync('git', args, { env, signal })).stdout;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
const error = e;
|
|
49
|
+
if (error.code === 'ABORT_ERR') {
|
|
50
|
+
throw new Error(`fetching ${what} was cancelled`);
|
|
51
|
+
}
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
throw new Error(`unable to fetch ${what}: git is not installed`);
|
|
54
|
+
}
|
|
55
|
+
const reason = (error.stderr ?? '')
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter((line) => line.length > 0)
|
|
59
|
+
.pop() ?? error.message;
|
|
60
|
+
throw new Error(`unable to fetch ${what}: ${reason}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=git-checkout.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readJson = exports.MAX_BODY_BYTES = exports.HttpError = exports.send = void 0;
|
|
4
|
+
function send(res, status, body, headers = {}) {
|
|
5
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
|
|
6
|
+
res.end(JSON.stringify(body));
|
|
7
|
+
}
|
|
8
|
+
exports.send = send;
|
|
9
|
+
// An answer to the caller, thrown from wherever the request turns out to be wrong.
|
|
10
|
+
class HttpError extends Error {
|
|
11
|
+
constructor(status, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.HttpError = HttpError;
|
|
17
|
+
exports.MAX_BODY_BYTES = 64 * 1024;
|
|
18
|
+
async function readJson(req, limit = exports.MAX_BODY_BYTES) {
|
|
19
|
+
if (!/^application\/json(;|$)/i.test(req.headers['content-type'] ?? '')) {
|
|
20
|
+
throw new HttpError(415, 'send application/json');
|
|
21
|
+
}
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let size = 0;
|
|
24
|
+
for await (const chunk of req) {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > limit) {
|
|
27
|
+
throw new HttpError(413, `the request is larger than ${limit} bytes`);
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new HttpError(400, 'the body is not valid JSON');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.readJson = readJson;
|
|
39
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TokenVerifier = exports.quote = exports.TokenError = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
// The reason is for the operator's log, never for the caller.
|
|
6
|
+
class TokenError extends Error {
|
|
7
|
+
}
|
|
8
|
+
exports.TokenError = TokenError;
|
|
9
|
+
// Values from a token are written to the operator's log before the signature
|
|
10
|
+
// is checked, so they are quoted and cut: they can't start a log line of their own.
|
|
11
|
+
function quote(value) {
|
|
12
|
+
return JSON.stringify(String(value).slice(0, 80));
|
|
13
|
+
}
|
|
14
|
+
exports.quote = quote;
|
|
15
|
+
// Asymmetric algorithms only: there is no shared secret, and `none` and HS*
|
|
16
|
+
// are never accepted.
|
|
17
|
+
const ALGORITHMS = {
|
|
18
|
+
RS256: { hash: 'sha256', kty: 'RSA' },
|
|
19
|
+
RS384: { hash: 'sha384', kty: 'RSA' },
|
|
20
|
+
RS512: { hash: 'sha512', kty: 'RSA' },
|
|
21
|
+
PS256: { hash: 'sha256', kty: 'RSA', pss: true },
|
|
22
|
+
ES256: { hash: 'sha256', kty: 'EC', crv: 'P-256' },
|
|
23
|
+
ES384: { hash: 'sha384', kty: 'EC', crv: 'P-384' },
|
|
24
|
+
ES512: { hash: 'sha512', kty: 'EC', crv: 'P-521' },
|
|
25
|
+
};
|
|
26
|
+
const CLOCK_SKEW_SECONDS = 60;
|
|
27
|
+
const KEYS_TTL_MS = 10 * 60 * 1000;
|
|
28
|
+
// an unknown kid refreshes the keys, but not more often than this
|
|
29
|
+
const REFRESH_COOLDOWN_MS = 60 * 1000;
|
|
30
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
31
|
+
// after a failed load the issuer is left alone for this long, instead of being hit by every request
|
|
32
|
+
const FAILURE_BACKOFF_MS = 10 * 1000;
|
|
33
|
+
// keys that could not be refreshed are still used for this long past their TTL, so a short IdP outage does not lock everyone out
|
|
34
|
+
const STALE_KEYS_MS = 60 * 60 * 1000;
|
|
35
|
+
class TokenVerifier {
|
|
36
|
+
constructor(issuers, options = {}) {
|
|
37
|
+
this.issuers = issuers;
|
|
38
|
+
this.keys = new Map();
|
|
39
|
+
this.loading = new Map();
|
|
40
|
+
this.failures = new Map();
|
|
41
|
+
const fetcher = options.fetch ?? fetch;
|
|
42
|
+
this.now = options.now ?? Date.now;
|
|
43
|
+
this.fetchJson = async (url) => {
|
|
44
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new TokenError(`${url} answered ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
return response.json();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async verify(token) {
|
|
52
|
+
const parts = token.split('.');
|
|
53
|
+
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
|
54
|
+
throw new TokenError('not a signed JWT');
|
|
55
|
+
}
|
|
56
|
+
const header = decodeObject(parts[0], 'header');
|
|
57
|
+
const payload = decodeObject(parts[1], 'payload');
|
|
58
|
+
const algorithm = typeof header.alg === 'string' ? ALGORITHMS[header.alg] : undefined;
|
|
59
|
+
if (!algorithm) {
|
|
60
|
+
throw new TokenError(`algorithm ${quote(header.alg)} is not accepted`);
|
|
61
|
+
}
|
|
62
|
+
const issuer = this.issuers.find((i) => i.issuer === payload.iss);
|
|
63
|
+
if (!issuer) {
|
|
64
|
+
throw new TokenError(`issuer ${quote(payload.iss)} is not accepted`);
|
|
65
|
+
}
|
|
66
|
+
const jwk = await this.findKey(issuer, algorithm, typeof header.kid === 'string' ? header.kid : undefined);
|
|
67
|
+
const signature = Buffer.from(parts[2], 'base64url');
|
|
68
|
+
const data = Buffer.from(`${parts[0]}.${parts[1]}`);
|
|
69
|
+
const key = (0, crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
|
|
70
|
+
if (!verifySignature(algorithm, data, key, signature)) {
|
|
71
|
+
throw new TokenError('signature does not match');
|
|
72
|
+
}
|
|
73
|
+
return this.readClaims(issuer, payload);
|
|
74
|
+
}
|
|
75
|
+
readClaims(issuer, payload) {
|
|
76
|
+
const now = this.now() / 1000;
|
|
77
|
+
if (typeof payload.exp !== 'number') {
|
|
78
|
+
throw new TokenError('token has no expiry');
|
|
79
|
+
}
|
|
80
|
+
if (payload.exp + CLOCK_SKEW_SECONDS < now) {
|
|
81
|
+
throw new TokenError('token expired');
|
|
82
|
+
}
|
|
83
|
+
if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SECONDS > now) {
|
|
84
|
+
throw new TokenError('token not valid yet');
|
|
85
|
+
}
|
|
86
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
87
|
+
if (!audiences.includes(issuer.audience)) {
|
|
88
|
+
throw new TokenError(`token is not meant for ${issuer.audience}`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
|
|
91
|
+
throw new TokenError('token has no subject');
|
|
92
|
+
}
|
|
93
|
+
const groups = payload[issuer.groupsClaim];
|
|
94
|
+
const claims = {};
|
|
95
|
+
for (const [name, value] of Object.entries(payload)) {
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
claims[name] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
iss: issuer.issuer,
|
|
102
|
+
sub: payload.sub,
|
|
103
|
+
groups: Array.isArray(groups)
|
|
104
|
+
? groups.filter((g) => typeof g === 'string')
|
|
105
|
+
: typeof groups === 'string'
|
|
106
|
+
? [groups]
|
|
107
|
+
: [],
|
|
108
|
+
claims,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async findKey(issuer, algorithm, kid) {
|
|
112
|
+
const known = this.keys.get(issuer.issuer);
|
|
113
|
+
const stale = !known || this.now() - known.loadedAt > KEYS_TTL_MS;
|
|
114
|
+
let loaded;
|
|
115
|
+
if (stale) {
|
|
116
|
+
try {
|
|
117
|
+
loaded = await this.loadKeys(issuer);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (!known || this.now() - known.loadedAt > KEYS_TTL_MS + STALE_KEYS_MS) {
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
loaded = known;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
loaded = known;
|
|
128
|
+
}
|
|
129
|
+
let jwk = pickKey(loaded.keys, algorithm, kid);
|
|
130
|
+
if (!jwk && !stale && this.now() - loaded.loadedAt > REFRESH_COOLDOWN_MS) {
|
|
131
|
+
// the issuer may have rotated its keys since they were cached
|
|
132
|
+
try {
|
|
133
|
+
loaded = await this.loadKeys(issuer);
|
|
134
|
+
jwk = pickKey(loaded.keys, algorithm, kid);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// keep the cached keys: the token simply has no key we know
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!jwk) {
|
|
141
|
+
throw new TokenError(`issuer has no ${algorithm.kty} key${kid ? ` ${quote(kid)}` : ''}`);
|
|
142
|
+
}
|
|
143
|
+
return jwk;
|
|
144
|
+
}
|
|
145
|
+
// One load per issuer at a time, and none right after a failed one.
|
|
146
|
+
loadKeys(issuer) {
|
|
147
|
+
const running = this.loading.get(issuer.issuer);
|
|
148
|
+
if (running) {
|
|
149
|
+
return running;
|
|
150
|
+
}
|
|
151
|
+
const failure = this.failures.get(issuer.issuer);
|
|
152
|
+
if (failure && this.now() - failure.at < FAILURE_BACKOFF_MS) {
|
|
153
|
+
return Promise.reject(failure.error);
|
|
154
|
+
}
|
|
155
|
+
const load = this.fetchKeys(issuer)
|
|
156
|
+
.then((loaded) => {
|
|
157
|
+
this.failures.delete(issuer.issuer);
|
|
158
|
+
return loaded;
|
|
159
|
+
})
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
this.failures.set(issuer.issuer, { at: this.now(), error });
|
|
162
|
+
throw error;
|
|
163
|
+
})
|
|
164
|
+
.finally(() => this.loading.delete(issuer.issuer));
|
|
165
|
+
this.loading.set(issuer.issuer, load);
|
|
166
|
+
return load;
|
|
167
|
+
}
|
|
168
|
+
async fetchKeys(issuer) {
|
|
169
|
+
const base = issuer.issuer.replace(/\/+$/, '');
|
|
170
|
+
const metadata = (await this.fetchJson(`${base}/.well-known/openid-configuration`));
|
|
171
|
+
if (metadata.issuer !== issuer.issuer) {
|
|
172
|
+
throw new TokenError(`issuer metadata names ${quote(metadata.issuer)}`);
|
|
173
|
+
}
|
|
174
|
+
const jwksUri = typeof metadata.jwks_uri === 'string' ? metadata.jwks_uri : '';
|
|
175
|
+
if (!isSecureUrl(jwksUri)) {
|
|
176
|
+
throw new TokenError(`jwks_uri ${quote(jwksUri)} is not https`);
|
|
177
|
+
}
|
|
178
|
+
const jwks = (await this.fetchJson(jwksUri));
|
|
179
|
+
const loaded = { keys: Array.isArray(jwks.keys) ? jwks.keys : [], loadedAt: this.now() };
|
|
180
|
+
this.keys.set(issuer.issuer, loaded);
|
|
181
|
+
return loaded;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.TokenVerifier = TokenVerifier;
|
|
185
|
+
function pickKey(keys, algorithm, kid) {
|
|
186
|
+
const candidates = keys.filter((jwk) => jwk.kty === algorithm.kty &&
|
|
187
|
+
jwk.use !== 'enc' &&
|
|
188
|
+
(algorithm.crv === undefined || jwk.crv === algorithm.crv));
|
|
189
|
+
if (kid !== undefined) {
|
|
190
|
+
return candidates.find((jwk) => jwk.kid === kid);
|
|
191
|
+
}
|
|
192
|
+
// a token without kid is only unambiguous when the issuer has one such key
|
|
193
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
194
|
+
}
|
|
195
|
+
function verifySignature(algorithm, data, key, signature) {
|
|
196
|
+
if (algorithm.kty === 'EC') {
|
|
197
|
+
return (0, crypto_1.verify)(algorithm.hash, data, { key, dsaEncoding: 'ieee-p1363' }, signature);
|
|
198
|
+
}
|
|
199
|
+
if (algorithm.pss) {
|
|
200
|
+
return (0, crypto_1.verify)(algorithm.hash, data, { key, padding: crypto_1.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto_1.constants.RSA_PSS_SALTLEN_DIGEST }, signature);
|
|
201
|
+
}
|
|
202
|
+
return (0, crypto_1.verify)(algorithm.hash, data, key, signature);
|
|
203
|
+
}
|
|
204
|
+
function decodeObject(part, what) {
|
|
205
|
+
try {
|
|
206
|
+
const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
207
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// reported below
|
|
213
|
+
}
|
|
214
|
+
throw new TokenError(`token ${what} is not a JSON object`);
|
|
215
|
+
}
|
|
216
|
+
function isSecureUrl(value) {
|
|
217
|
+
try {
|
|
218
|
+
const url = new URL(value);
|
|
219
|
+
return (url.protocol === 'https:' ||
|
|
220
|
+
(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getGrant = exports.isAdmin = exports.maySeeDefinitions = exports.mayPropose = exports.mayPublish = exports.matches = void 0;
|
|
4
|
+
function matches(entitlement, principal) {
|
|
5
|
+
if (entitlement.subject) {
|
|
6
|
+
return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
|
|
7
|
+
}
|
|
8
|
+
if (entitlement.group) {
|
|
9
|
+
return entitlement.group.iss === principal.iss && principal.groups.includes(entitlement.group.name);
|
|
10
|
+
}
|
|
11
|
+
if (entitlement.claims) {
|
|
12
|
+
return (entitlement.claims.iss === principal.iss &&
|
|
13
|
+
Object.entries(entitlement.claims.match).every(([name, value]) => principal.claims[name] === value));
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
exports.matches = matches;
|
|
18
|
+
function mayPublish(grant, name) {
|
|
19
|
+
return grant.publish.includes('*') || grant.publish.includes(name);
|
|
20
|
+
}
|
|
21
|
+
exports.mayPublish = mayPublish;
|
|
22
|
+
function mayPropose(grant, name) {
|
|
23
|
+
return grant.propose.includes('*') || grant.propose.includes(name);
|
|
24
|
+
}
|
|
25
|
+
exports.mayPropose = mayPropose;
|
|
26
|
+
// A caller who can neither run nor publish anything sees nothing of the server's
|
|
27
|
+
// build files either. Admins see them, they have to know what they approve.
|
|
28
|
+
function maySeeDefinitions(grant) {
|
|
29
|
+
return grant.accounts.length > 0 || grant.request.length > 0 || grant.publish.length > 0 || grant.propose.length > 0;
|
|
30
|
+
}
|
|
31
|
+
exports.maySeeDefinitions = maySeeDefinitions;
|
|
32
|
+
// Admin of an account decides about the runs on it.
|
|
33
|
+
function isAdmin(grant, account) {
|
|
34
|
+
return account !== null && grant.accounts.includes(account);
|
|
35
|
+
}
|
|
36
|
+
exports.isAdmin = isAdmin;
|
|
37
|
+
// Default deny: a caller without a matching entitlement gets no account.
|
|
38
|
+
function getGrant(entitlements, principal) {
|
|
39
|
+
const matched = [];
|
|
40
|
+
const accounts = [];
|
|
41
|
+
const requested = [];
|
|
42
|
+
const publish = [];
|
|
43
|
+
const propose = [];
|
|
44
|
+
let defaultAccount = null;
|
|
45
|
+
let source = null;
|
|
46
|
+
entitlements.forEach((entitlement, index) => {
|
|
47
|
+
if (!matches(entitlement, principal)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
matched.push(index);
|
|
51
|
+
for (const account of entitlement.accounts) {
|
|
52
|
+
if (!accounts.includes(account)) {
|
|
53
|
+
accounts.push(account);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const account of entitlement.request ?? []) {
|
|
57
|
+
if (!requested.includes(account)) {
|
|
58
|
+
requested.push(account);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const name of entitlement.publish ?? []) {
|
|
62
|
+
if (!publish.includes(name)) {
|
|
63
|
+
publish.push(name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const name of entitlement.propose ?? []) {
|
|
67
|
+
if (!propose.includes(name)) {
|
|
68
|
+
propose.push(name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
defaultAccount = defaultAccount ?? entitlement.default ?? null;
|
|
72
|
+
if (entitlement.source) {
|
|
73
|
+
source = source ? source.filter((s) => entitlement.source?.includes(s)) : [...entitlement.source];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// admin wins over request when two entitlements give both
|
|
77
|
+
const request = requested.filter((account) => !accounts.includes(account));
|
|
78
|
+
const usable = [...accounts, ...request];
|
|
79
|
+
return {
|
|
80
|
+
accounts,
|
|
81
|
+
request,
|
|
82
|
+
publish,
|
|
83
|
+
propose,
|
|
84
|
+
default: defaultAccount ?? (usable.length === 1 ? usable[0] : null),
|
|
85
|
+
source: source ?? ['upload', 'ref'],
|
|
86
|
+
matched,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
exports.getGrant = getGrant;
|
|
90
|
+
//# sourceMappingURL=policy.js.map
|