hammerkit 1.23.0 → 1.25.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/optimizer/get-cache-directory.js +5 -0
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/planner/work-secret.js +6 -2
- package/dist/program.js +6 -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 +158 -0
- package/dist/remote/remote-work-commands.js +224 -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 +9 -2
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/provider-values.js +4 -0
- package/dist/server/accounts-api.js +42 -0
- package/dist/server/approvals-api.js +104 -0
- package/dist/server/definitions-api.js +163 -0
- package/dist/server/definitions.js +160 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +237 -0
- package/dist/server/git-checkout.js +115 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/plan.js +296 -0
- package/dist/server/policy.js +138 -0
- package/dist/server/proposals-api.js +145 -0
- package/dist/server/proposals.js +173 -0
- package/dist/server/repo-access.js +127 -0
- package/dist/server/repo.js +94 -0
- package/dist/server/run-environment.js +20 -0
- package/dist/server/run-plan.js +44 -0
- package/dist/server/run-request.js +85 -0
- package/dist/server/run-store.js +61 -0
- package/dist/server/run-worker-command.js +17 -0
- package/dist/server/run-worker-main.js +7 -0
- package/dist/server/run-worker.js +179 -0
- package/dist/server/runs.js +432 -0
- package/dist/server/server-command.js +61 -0
- package/dist/server/server-config.js +394 -0
- package/dist/server/server.js +434 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
package/build.schema.json
CHANGED
|
@@ -261,6 +261,10 @@
|
|
|
261
261
|
"additionalProperties": false,
|
|
262
262
|
"description": "cpu and memory requests and limits"
|
|
263
263
|
},
|
|
264
|
+
"runner": {
|
|
265
|
+
"type": "string",
|
|
266
|
+
"minLength": 1
|
|
267
|
+
},
|
|
264
268
|
"image": {
|
|
265
269
|
"type": "string"
|
|
266
270
|
},
|
|
@@ -331,6 +335,9 @@
|
|
|
331
335
|
"resources": {
|
|
332
336
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
|
|
333
337
|
},
|
|
338
|
+
"runner": {
|
|
339
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
|
|
340
|
+
},
|
|
334
341
|
"platform": {
|
|
335
342
|
"type": "object",
|
|
336
343
|
"properties": {
|
|
@@ -830,6 +837,9 @@
|
|
|
830
837
|
},
|
|
831
838
|
"labels": {
|
|
832
839
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/labels"
|
|
840
|
+
},
|
|
841
|
+
"runner": {
|
|
842
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
|
|
833
843
|
}
|
|
834
844
|
},
|
|
835
845
|
"additionalProperties": false,
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ const check_for_loop_1 = require("./executer/scheduler/check-for-loop");
|
|
|
13
13
|
const state_1 = require("./executer/state");
|
|
14
14
|
const get_scheduler_execute_result_1 = require("./executer/get-scheduler-execute-result");
|
|
15
15
|
const reset_work_tree_1 = require("./executer/reset-work-tree");
|
|
16
|
+
const location_1 = require("./remote/location");
|
|
16
17
|
const execute_work_tree_1 = require("./executer/execute-work-tree");
|
|
17
18
|
const package_1 = require("./docker/package");
|
|
18
19
|
const explain_1 = require("./cache/explain");
|
|
@@ -53,6 +54,11 @@ class Cli {
|
|
|
53
54
|
await this.prepareSecrets(true);
|
|
54
55
|
}
|
|
55
56
|
(0, check_for_loop_1.checkForLoop)(workTree);
|
|
57
|
+
const location = await (0, location_1.resolveRunLocation)(workTree, this.environment);
|
|
58
|
+
if (location.type === 'server') {
|
|
59
|
+
throw new Error(`this run is meant for server ${location.name}, but running on a server is not available yet. ` +
|
|
60
|
+
'Remove the runner, or run locally with: hammerkit remote use local');
|
|
61
|
+
}
|
|
56
62
|
const capacity = (0, plan_work_tasks_1.hasError)(workTree) ? null : await (0, resource_budget_1.getResourceCapacity)(workTree, this.environment);
|
|
57
63
|
if (capacity && type !== 'down' && (options?.resourceCheck ?? true)) {
|
|
58
64
|
rejectOversizedWork(workTree, capacity, type === 'up');
|
|
@@ -12,11 +12,17 @@ const environment_config_1 = require("../utils/environment-config");
|
|
|
12
12
|
function getStopTimeout() {
|
|
13
13
|
return (0, environment_config_1.getEnvironmentConfig)('HAMMERKIT_STOP_TIMEOUT_MS', 10000);
|
|
14
14
|
}
|
|
15
|
+
// powershell takes about half a minute to start when it has to work out its module
|
|
16
|
+
// path itself, so the one of the process is passed on
|
|
17
|
+
function shellEnvs(windows, environment) {
|
|
18
|
+
const value = environment.processEnvs['PSModulePath'];
|
|
19
|
+
return windows && value !== undefined ? { PSModulePath: value } : {};
|
|
20
|
+
}
|
|
15
21
|
async function executeCommand(status, abortSignal, cwd, command, envs, environment) {
|
|
16
22
|
const windows = (0, os_1.platform)() === 'win32';
|
|
17
23
|
return new Promise((resolve, reject) => {
|
|
18
24
|
const ps = (0, child_process_1.spawn)(command, {
|
|
19
|
-
env: { ...envs, PATH: environment.processEnvs['PATH'] },
|
|
25
|
+
env: { ...envs, ...shellEnvs(windows, environment), PATH: environment.processEnvs['PATH'] },
|
|
20
26
|
cwd,
|
|
21
27
|
shell: windows ? 'powershell.exe' : true,
|
|
22
28
|
// its own process group, so an abort reaches every process the command
|
|
@@ -14,7 +14,12 @@ function getCacheStatsFile(cacheId) {
|
|
|
14
14
|
return (0, path_1.join)(getCacheDirectory(cacheId), 'stats.json');
|
|
15
15
|
}
|
|
16
16
|
exports.getCacheStatsFile = getCacheStatsFile;
|
|
17
|
+
// `HAMMERKIT_DIR` puts hammerkit's directory (the cache) elsewhere than below the home directory;
|
|
18
|
+
// a server uses it to keep the caches of runs apart.
|
|
17
19
|
function getHammerkitDirectory() {
|
|
20
|
+
if (process.env.HAMMERKIT_DIR) {
|
|
21
|
+
return process.env.HAMMERKIT_DIR;
|
|
22
|
+
}
|
|
18
23
|
if (process.platform === 'win32') {
|
|
19
24
|
return (0, path_1.join)(process.env.APPDATA, 'hammerkit');
|
|
20
25
|
}
|
|
@@ -72,6 +72,7 @@ function parseTask(cwd, task, environment, context) {
|
|
|
72
72
|
timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
|
|
73
73
|
secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
|
|
74
74
|
resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
|
|
75
|
+
runner: task.schema.runner ?? task.scope.schema.runner ?? null,
|
|
75
76
|
};
|
|
76
77
|
if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
|
|
77
78
|
return {
|
|
@@ -66,9 +66,13 @@ function parseSecretSource(secret, ref, cwd, envs, catalog, taskAccount, label)
|
|
|
66
66
|
if (providerRef.length === 0 || providerRef.startsWith('-')) {
|
|
67
67
|
throw new Error(`${label}: a reference for ${kind} must not be empty or start with -`);
|
|
68
68
|
}
|
|
69
|
-
const accountName =
|
|
69
|
+
const accountName = catalog.explicitAccounts
|
|
70
|
+
? secret.account
|
|
71
|
+
: secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
|
|
70
72
|
if (!accountName) {
|
|
71
|
-
throw new Error(
|
|
73
|
+
throw new Error(catalog.explicitAccounts
|
|
74
|
+
? `${label}: ${kind}:${providerRef} needs account: on the secret, a server uses neither a task's account nor a default`
|
|
75
|
+
: `${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
|
|
72
76
|
}
|
|
73
77
|
const account = catalog.accounts[accountName];
|
|
74
78
|
if (!account) {
|
package/dist/program.js
CHANGED
|
@@ -25,6 +25,9 @@ const version_1 = require("./version");
|
|
|
25
25
|
const read_only_1 = require("./cache/read-only");
|
|
26
26
|
const units_1 = require("./utils/units");
|
|
27
27
|
const cache_inventory_1 = require("./cache/cache-inventory");
|
|
28
|
+
const remote_commands_1 = require("./remote/remote-commands");
|
|
29
|
+
const server_command_1 = require("./server/server-command");
|
|
30
|
+
const run_worker_command_1 = require("./server/run-worker-command");
|
|
28
31
|
async function createCli(fileName, environment, workScope) {
|
|
29
32
|
const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
|
|
30
33
|
const referencedScope = await (0, reference_parser_1.parseReferences)(ctx, scope, environment);
|
|
@@ -102,6 +105,8 @@ function parseWorkLabelScope(options) {
|
|
|
102
105
|
}
|
|
103
106
|
async function getProgram(environment, argv) {
|
|
104
107
|
const program = new commander_1.Command();
|
|
108
|
+
(0, server_command_1.registerServerCommand)(program, environment);
|
|
109
|
+
(0, run_worker_command_1.registerRunWorkerCommand)(program, environment);
|
|
105
110
|
const parsed = (0, parse_set_arguments_1.parseSetArguments)(argv);
|
|
106
111
|
const args = parsed.args;
|
|
107
112
|
environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
|
|
@@ -110,6 +115,7 @@ async function getProgram(environment, argv) {
|
|
|
110
115
|
if (fileIndex >= 0) {
|
|
111
116
|
args.splice(fileIndex, 2);
|
|
112
117
|
}
|
|
118
|
+
(0, remote_commands_1.registerRemoteCommands)(program, environment, fileName);
|
|
113
119
|
if (await environment.file.exists(fileName)) {
|
|
114
120
|
program
|
|
115
121
|
.command('ls')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HASH_PATTERN = exports.hashBundle = exports.bundleSchema = exports.isBundlePath = exports.MAX_BUNDLE_BYTES = exports.MAX_BUNDLE_FILES = exports.DEFAULT_ENTRY = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
exports.DEFAULT_ENTRY = '.hammerkit.yaml';
|
|
7
|
+
exports.MAX_BUNDLE_FILES = 200;
|
|
8
|
+
exports.MAX_BUNDLE_BYTES = 1024 * 1024;
|
|
9
|
+
// A path inside the bundle: relative, forward slashes, nothing that climbs out.
|
|
10
|
+
function isBundlePath(path) {
|
|
11
|
+
return (path.length > 0 &&
|
|
12
|
+
path.length <= 200 &&
|
|
13
|
+
!path.startsWith('/') &&
|
|
14
|
+
!path.includes('\\') &&
|
|
15
|
+
!path.includes('\0') &&
|
|
16
|
+
!path.split('/').some((part) => part === '' || part === '.' || part === '..'));
|
|
17
|
+
}
|
|
18
|
+
exports.isBundlePath = isBundlePath;
|
|
19
|
+
exports.bundleSchema = zod_1.z
|
|
20
|
+
.object({
|
|
21
|
+
entry: zod_1.z.string().default(exports.DEFAULT_ENTRY),
|
|
22
|
+
files: zod_1.z.record(zod_1.z.string()),
|
|
23
|
+
})
|
|
24
|
+
.strict()
|
|
25
|
+
.superRefine((bundle, ctx) => {
|
|
26
|
+
const paths = Object.keys(bundle.files);
|
|
27
|
+
const bad = paths.find((path) => !isBundlePath(path));
|
|
28
|
+
if (bad !== undefined) {
|
|
29
|
+
ctx.addIssue({
|
|
30
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
31
|
+
path: ['files'],
|
|
32
|
+
message: `"${bad}" is not a path inside the bundle`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (paths.length > exports.MAX_BUNDLE_FILES) {
|
|
36
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files'], message: `more than ${exports.MAX_BUNDLE_FILES} files` });
|
|
37
|
+
}
|
|
38
|
+
const bytes = paths.reduce((sum, path) => sum + Buffer.byteLength(bundle.files[path]), 0);
|
|
39
|
+
if (bytes > exports.MAX_BUNDLE_BYTES) {
|
|
40
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files'], message: `larger than ${exports.MAX_BUNDLE_BYTES} bytes` });
|
|
41
|
+
}
|
|
42
|
+
if (!isBundlePath(bundle.entry) || !Object.prototype.hasOwnProperty.call(bundle.files, bundle.entry)) {
|
|
43
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['entry'], message: `${bundle.entry} is not one of the files` });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
// The version of a bundle: the hash of its entry and of every file, in path order,
|
|
47
|
+
// so the same files always have the same version, wherever it is computed. It is
|
|
48
|
+
// the sha-256 of the JSON `[entry, [[path, sha-256 of the content], ...]]`.
|
|
49
|
+
function hashBundle(bundle) {
|
|
50
|
+
const files = Object.keys(bundle.files)
|
|
51
|
+
.sort()
|
|
52
|
+
.map((path) => [path, (0, crypto_1.createHash)('sha256').update(bundle.files[path]).digest('hex')]);
|
|
53
|
+
return `sha256:${(0, crypto_1.createHash)('sha256')
|
|
54
|
+
.update(JSON.stringify([bundle.entry, files]))
|
|
55
|
+
.digest('hex')}`;
|
|
56
|
+
}
|
|
57
|
+
exports.hashBundle = hashBundle;
|
|
58
|
+
exports.HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
59
|
+
//# sourceMappingURL=bundle.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectBundle = void 0;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
6
|
+
const schema_parser_1 = require("../schema/schema-parser");
|
|
7
|
+
const bundle_1 = require("./bundle");
|
|
8
|
+
// The bundle of a build file on this machine: the file itself and every local
|
|
9
|
+
// file it includes or references, as the server reads them when it is published.
|
|
10
|
+
// The build file is read with the parser of a run, so what it pulls in is what a
|
|
11
|
+
// run would read. Git includes are not part of it (the server fetches them, at
|
|
12
|
+
// the commit they are pinned to), and neither is anything outside of the
|
|
13
|
+
// directory of the build file, which a server could not reproduce.
|
|
14
|
+
async function collectBundle(fileName, environment) {
|
|
15
|
+
if (!(await environment.file.exists(fileName))) {
|
|
16
|
+
throw new Error(`there is no build file at ${fileName}`);
|
|
17
|
+
}
|
|
18
|
+
const entryPath = (0, path_1.resolve)(fileName);
|
|
19
|
+
const root = (0, path_1.dirname)(entryPath);
|
|
20
|
+
const { ctx } = await (0, schema_parser_1.createParseContext)(entryPath, environment);
|
|
21
|
+
const paths = new Set([entryPath]);
|
|
22
|
+
for (const scope of Object.values(ctx.files)) {
|
|
23
|
+
if (!scope.remote) {
|
|
24
|
+
paths.add((0, path_1.resolve)(scope.fileName));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const files = {};
|
|
28
|
+
for (const path of [...paths].sort()) {
|
|
29
|
+
if (!(0, resolve_git_source_1.isWithin)(root, path)) {
|
|
30
|
+
throw new Error(`${path} is outside of ${root}: a published build file can only include files below its own directory`);
|
|
31
|
+
}
|
|
32
|
+
files[(0, path_1.relative)(root, path).split(path_1.sep).join('/')] = await environment.file.read(path);
|
|
33
|
+
}
|
|
34
|
+
const result = bundle_1.bundleSchema.safeParse({ entry: (0, path_1.relative)(root, entryPath).split(path_1.sep).join('/'), files });
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
throw new Error(`the build file can't be published: ${result.error.issues[0].message}`);
|
|
37
|
+
}
|
|
38
|
+
return result.data;
|
|
39
|
+
}
|
|
40
|
+
exports.collectBundle = collectBundle;
|
|
41
|
+
//# sourceMappingURL=collect-bundle.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getAccessToken = exports.removeToken = exports.saveToken = exports.getCredentialsPath = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
8
|
+
function getCredentialsPath(environment) {
|
|
9
|
+
return environment.processEnvs.HAMMERKIT_CREDENTIALS ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'credentials.json');
|
|
10
|
+
}
|
|
11
|
+
exports.getCredentialsPath = getCredentialsPath;
|
|
12
|
+
async function readAll(environment) {
|
|
13
|
+
const path = getCredentialsPath(environment);
|
|
14
|
+
if (!(await environment.file.exists(path))) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(await environment.file.read(path));
|
|
19
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// a damaged file only costs a new sign-in
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Only the owner may read it, and it is replaced atomically. The temporary file
|
|
27
|
+
// has a name nobody can guess and is created exclusively (never through a link
|
|
28
|
+
// someone left behind), already private.
|
|
29
|
+
async function writeAll(environment, all) {
|
|
30
|
+
const path = getCredentialsPath(environment);
|
|
31
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(path), { recursive: true, mode: 0o700 });
|
|
32
|
+
const temporary = `${path}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
|
|
33
|
+
const file = await (0, promises_1.open)(temporary, 'wx', 0o600);
|
|
34
|
+
try {
|
|
35
|
+
await file.writeFile(JSON.stringify(all, null, 2));
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await file.close();
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await (0, promises_1.rename)(temporary, path);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
await (0, promises_1.unlink)(temporary).catch(() => undefined);
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// A token that others can read is as good as published: it isn't used.
|
|
49
|
+
async function assertPrivate(environment) {
|
|
50
|
+
const path = getCredentialsPath(environment);
|
|
51
|
+
if (process.platform === 'win32' || !(await environment.file.exists(path))) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const info = await (0, promises_1.stat)(path);
|
|
55
|
+
if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid())) {
|
|
56
|
+
throw new Error(`${path} must belong to you and be private, fix it with: chmod 600 ${path}, or sign in again`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function saveToken(environment, name, token) {
|
|
60
|
+
const all = await readAll(environment);
|
|
61
|
+
all[name] = token;
|
|
62
|
+
await writeAll(environment, all);
|
|
63
|
+
}
|
|
64
|
+
exports.saveToken = saveToken;
|
|
65
|
+
async function removeToken(environment, name) {
|
|
66
|
+
const all = await readAll(environment);
|
|
67
|
+
if (!all[name]) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
delete all[name];
|
|
71
|
+
await writeAll(environment, all);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
exports.removeToken = removeToken;
|
|
75
|
+
// The access token for a registered server, or the reason there is none.
|
|
76
|
+
async function getAccessToken(environment, name, server, now = Date.now()) {
|
|
77
|
+
await assertPrivate(environment);
|
|
78
|
+
const all = await readAll(environment);
|
|
79
|
+
const stored = Object.prototype.hasOwnProperty.call(all, name) ? all[name] : undefined;
|
|
80
|
+
const signIn = `sign in with: hammerkit remote login ${name}`;
|
|
81
|
+
if (!stored) {
|
|
82
|
+
throw new Error(`not signed in to ${name}, ${signIn}`);
|
|
83
|
+
}
|
|
84
|
+
if (stored.url !== server.url || stored.issuer !== server.issuer) {
|
|
85
|
+
throw new Error(`the sign-in to ${name} was made for another server, ${signIn}`);
|
|
86
|
+
}
|
|
87
|
+
if (stored.expiresAt <= now) {
|
|
88
|
+
throw new Error(`the sign-in to ${name} has expired, ${signIn}`);
|
|
89
|
+
}
|
|
90
|
+
return stored.accessToken;
|
|
91
|
+
}
|
|
92
|
+
exports.getAccessToken = getAccessToken;
|
|
93
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deviceLogin = void 0;
|
|
4
|
+
const server_client_1 = require("./server-client");
|
|
5
|
+
const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
7
|
+
const SLOW_DOWN_SECONDS = 5;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
9
|
+
const MIN_INTERVAL_SECONDS = 1;
|
|
10
|
+
const MAX_LIFETIME_SECONDS = 30 * 60;
|
|
11
|
+
const isLoopbackHost = (hostname) => ['localhost', '127.0.0.1', '[::1]'].includes(hostname);
|
|
12
|
+
// An endpoint must use the scheme of the issuer: loopback http is for a test
|
|
13
|
+
// identity provider on this machine only, never beside an https issuer.
|
|
14
|
+
function isSecure(url, issuer) {
|
|
15
|
+
if (typeof url !== 'string') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
const issuerHost = new URL(issuer);
|
|
21
|
+
return (parsed.protocol === 'https:' ||
|
|
22
|
+
(parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname) && issuerHost.protocol === 'http:'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Redirects are refused: a 307 would send the device code to another place.
|
|
29
|
+
async function fetchJson(url, init = {}) {
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new server_client_1.ServerError(`unable to reach ${url}: ${(0, server_client_1.printable)(e instanceof Error ? e.message : e)}`);
|
|
36
|
+
}
|
|
37
|
+
return { status: response.status, body: await response.json().catch(() => ({})) };
|
|
38
|
+
}
|
|
39
|
+
function postForm(url, form) {
|
|
40
|
+
return fetchJson(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
43
|
+
body: new URLSearchParams(form),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// `offline_access` asks for a refresh token, which is never kept.
|
|
47
|
+
function normalizeScope(scope) {
|
|
48
|
+
return scope
|
|
49
|
+
.split(/\s+/)
|
|
50
|
+
.filter((part) => part && part !== 'offline_access')
|
|
51
|
+
.join(' ');
|
|
52
|
+
}
|
|
53
|
+
// The token is sent to the hammerkit server next, so it has to be meant for it:
|
|
54
|
+
// a server that talked the user into a token for another service (Graph, a
|
|
55
|
+
// cloud API) gets nothing.
|
|
56
|
+
function checkToken(token, issuer, audience) {
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8'));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// handled below
|
|
63
|
+
}
|
|
64
|
+
const audiences = Array.isArray(payload?.aud) ? payload.aud : [payload?.aud];
|
|
65
|
+
if (payload?.iss !== issuer || !audiences.includes(audience)) {
|
|
66
|
+
throw new server_client_1.ServerError(`the identity provider issued a token that is not meant for ${(0, server_client_1.printable)(audience)}, it is not sent to the server`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function abortable(sleep, signal) {
|
|
70
|
+
return async (ms) => {
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
throw new server_client_1.ServerError('login was cancelled');
|
|
73
|
+
}
|
|
74
|
+
await sleep(ms);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// OAuth device authorization grant (RFC 8628) against the issuer the server
|
|
78
|
+
// names for the issuer registered with `remote add`. Works headless: nothing is
|
|
79
|
+
// opened, the user visits the address on any device. The refresh token, if the
|
|
80
|
+
// issuer sends one, is dropped on purpose: what is kept is one short-lived
|
|
81
|
+
// access token, and signing in again is the way to renew it.
|
|
82
|
+
async function deviceLogin(server, options) {
|
|
83
|
+
const sleep = abortable(options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), options.signal);
|
|
84
|
+
const now = options.now ?? Date.now;
|
|
85
|
+
const discovery = await (0, server_client_1.getDiscovery)(server);
|
|
86
|
+
const offered = discovery.issuers.find((i) => i.issuer === server.issuer);
|
|
87
|
+
if (!offered) {
|
|
88
|
+
const accepted = discovery.issuers.map((i) => (0, server_client_1.printable)(i.issuer)).join(', ');
|
|
89
|
+
throw new server_client_1.ServerError(`${server.url} does not accept tokens from ${server.issuer} (it accepts ${accepted})`);
|
|
90
|
+
}
|
|
91
|
+
if (!offered.clientId) {
|
|
92
|
+
throw new server_client_1.ServerError(`${server.url} offers no sign-in for ${server.issuer}, it needs a token obtained elsewhere`);
|
|
93
|
+
}
|
|
94
|
+
// What was pinned (by `remote add` or an earlier login) wins; the server can
|
|
95
|
+
// only offer the same, never something else to sign in with.
|
|
96
|
+
const wanted = {
|
|
97
|
+
clientId: offered.clientId,
|
|
98
|
+
audience: offered.audience,
|
|
99
|
+
scope: normalizeScope(offered.scope),
|
|
100
|
+
};
|
|
101
|
+
const pinned = {
|
|
102
|
+
clientId: server.clientId ?? wanted.clientId,
|
|
103
|
+
audience: server.audience ?? wanted.audience,
|
|
104
|
+
scope: normalizeScope(server.scope ?? wanted.scope),
|
|
105
|
+
};
|
|
106
|
+
for (const key of ['clientId', 'audience', 'scope']) {
|
|
107
|
+
if (pinned[key] !== wanted[key]) {
|
|
108
|
+
throw new server_client_1.ServerError(`${server.url} now asks for ${key} "${(0, server_client_1.printable)(wanted[key])}", but "${(0, server_client_1.printable)(pinned[key])}" is pinned. ` +
|
|
109
|
+
`If that is expected, register the server again with: hammerkit remote add <name> ${server.url} ` +
|
|
110
|
+
`--issuer ${server.issuer} --force`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
options.prompt(`signing in to ${server.url} through ${server.issuer}\n` +
|
|
114
|
+
` client ${(0, server_client_1.printable)(pinned.clientId)}, audience ${(0, server_client_1.printable)(pinned.audience)}, scope ${(0, server_client_1.printable)(pinned.scope)}`);
|
|
115
|
+
const base = server.issuer.replace(/\/+$/, '');
|
|
116
|
+
const described = await fetchJson(`${base}/.well-known/openid-configuration`);
|
|
117
|
+
const metadata = described.body;
|
|
118
|
+
if (described.status !== 200) {
|
|
119
|
+
throw new server_client_1.ServerError(`${server.issuer} answered ${described.status} for its configuration`);
|
|
120
|
+
}
|
|
121
|
+
if (metadata.issuer !== server.issuer) {
|
|
122
|
+
throw new server_client_1.ServerError(`${server.issuer} describes itself as ${(0, server_client_1.printable)(metadata.issuer)}`);
|
|
123
|
+
}
|
|
124
|
+
if (!isSecure(metadata.device_authorization_endpoint, server.issuer) ||
|
|
125
|
+
!isSecure(metadata.token_endpoint, server.issuer)) {
|
|
126
|
+
throw new server_client_1.ServerError(`${server.issuer} does not support the device authorization grant over https`);
|
|
127
|
+
}
|
|
128
|
+
const started = await postForm(metadata.device_authorization_endpoint, {
|
|
129
|
+
client_id: pinned.clientId,
|
|
130
|
+
scope: pinned.scope,
|
|
131
|
+
audience: pinned.audience,
|
|
132
|
+
});
|
|
133
|
+
const device = started.body;
|
|
134
|
+
if (started.status !== 200 || typeof device.device_code !== 'string' || typeof device.user_code !== 'string') {
|
|
135
|
+
throw new server_client_1.ServerError(`${server.issuer} refused the sign-in: ${(0, server_client_1.printable)(device.error_description ?? device.error ?? started.status)}`);
|
|
136
|
+
}
|
|
137
|
+
const address = device.verification_uri_complete ?? device.verification_uri;
|
|
138
|
+
if (!isSecure(address, server.issuer)) {
|
|
139
|
+
throw new server_client_1.ServerError(`${server.issuer} sent a sign-in address that is not https`);
|
|
140
|
+
}
|
|
141
|
+
options.prompt(`To sign in, open ${(0, server_client_1.printable)(address)} and enter the code ${(0, server_client_1.printable)(device.user_code)}`);
|
|
142
|
+
const lifetimeSeconds = typeof device.expires_in === 'number' ? device.expires_in : 600;
|
|
143
|
+
const deadline = now() + Math.min(Math.max(lifetimeSeconds, 0), MAX_LIFETIME_SECONDS) * 1000;
|
|
144
|
+
const intervalSeconds = typeof device.interval === 'number' ? device.interval : DEFAULT_INTERVAL_SECONDS;
|
|
145
|
+
let interval = Math.max(intervalSeconds, MIN_INTERVAL_SECONDS) * 1000;
|
|
146
|
+
for (;;) {
|
|
147
|
+
await sleep(interval);
|
|
148
|
+
if (now() > deadline) {
|
|
149
|
+
throw new server_client_1.ServerError('the code expired, run the login again');
|
|
150
|
+
}
|
|
151
|
+
const answer = await postForm(metadata.token_endpoint, {
|
|
152
|
+
grant_type: GRANT_TYPE,
|
|
153
|
+
device_code: device.device_code,
|
|
154
|
+
client_id: pinned.clientId,
|
|
155
|
+
});
|
|
156
|
+
if (answer.status === 200 && typeof answer.body.access_token === 'string') {
|
|
157
|
+
checkToken(answer.body.access_token, server.issuer, pinned.audience);
|
|
158
|
+
const lifetime = typeof answer.body.expires_in === 'number' ? answer.body.expires_in : 3600;
|
|
159
|
+
return { accessToken: answer.body.access_token, expiresAt: now() + lifetime * 1000, pinned };
|
|
160
|
+
}
|
|
161
|
+
switch (answer.body.error) {
|
|
162
|
+
case 'authorization_pending':
|
|
163
|
+
break;
|
|
164
|
+
case 'slow_down':
|
|
165
|
+
interval += SLOW_DOWN_SECONDS * 1000;
|
|
166
|
+
break;
|
|
167
|
+
case 'access_denied':
|
|
168
|
+
throw new server_client_1.ServerError('the sign-in was denied');
|
|
169
|
+
case 'expired_token':
|
|
170
|
+
throw new server_client_1.ServerError('the code expired, run the login again');
|
|
171
|
+
default:
|
|
172
|
+
throw new server_client_1.ServerError(`${server.issuer} refused the sign-in: ${(0, server_client_1.printable)(answer.body.error_description ?? answer.body.error ?? answer.status)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
exports.deviceLogin = deviceLogin;
|
|
177
|
+
//# sourceMappingURL=device-login.js.map
|