@monster-orange/den 0.4.4 → 0.4.5
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/CHANGELOG.md +169 -0
- package/README.md +10 -9
- package/docs/distributions.md +223 -26
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +102 -35
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -7,7 +7,10 @@ import * as net from "net";
|
|
|
7
7
|
import * as path from "path";
|
|
8
8
|
import { must, sh } from "./exec.js";
|
|
9
9
|
import {
|
|
10
|
+
droppedEdgeArgs,
|
|
10
11
|
EDGE_ROOT_LABEL,
|
|
12
|
+
edgeCommand,
|
|
13
|
+
edgeCommandDrift,
|
|
11
14
|
edgeNames,
|
|
12
15
|
renderEdgeCompose,
|
|
13
16
|
renderEdgeDynamic,
|
|
@@ -226,9 +229,58 @@ export function assertEdgeOwnedHere(
|
|
|
226
229
|
);
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
/** A container's command (`docker inspect .Config.Cmd`), or null. */
|
|
233
|
+
export function runningEdgeCommand(name: string): string[] | null {
|
|
234
|
+
const r = sh("docker", [
|
|
235
|
+
"inspect",
|
|
236
|
+
"--type",
|
|
237
|
+
"container",
|
|
238
|
+
"-f",
|
|
239
|
+
"{{json .Config.Cmd}}",
|
|
240
|
+
name,
|
|
241
|
+
]);
|
|
242
|
+
if (r.code !== 0) return null;
|
|
243
|
+
try {
|
|
244
|
+
const v: unknown = JSON.parse(r.out);
|
|
245
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string")
|
|
246
|
+
? (v as string[])
|
|
247
|
+
: null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What `edge up` says before it writes compose.yml (SEC-2a): the `edge.args`
|
|
255
|
+
* it drops (Den owns the entrypoints' trust), and, when the running edge's
|
|
256
|
+
* command differs from the new one, that it recreates the edge once and
|
|
257
|
+
* why. Pure: `running` is the running container's command, or null.
|
|
258
|
+
*/
|
|
259
|
+
export function edgeUpNotices(
|
|
260
|
+
cfg: EdgeConfig,
|
|
261
|
+
running: readonly string[] | null,
|
|
262
|
+
): string[] {
|
|
263
|
+
const out: string[] = [];
|
|
264
|
+
const dropped = droppedEdgeArgs(cfg);
|
|
265
|
+
if (dropped.length)
|
|
266
|
+
out.push(
|
|
267
|
+
`edge.args: ignoring ${dropped.join(", ")}: Den sets the entrypoints' ` +
|
|
268
|
+
"forwarded-header trust itself (its own networks and loopback), and " +
|
|
269
|
+
"PROXY protocol trust from edge.proxy_protocol.trusted_ips. Remove it " +
|
|
270
|
+
"from the config to silence this.",
|
|
271
|
+
);
|
|
272
|
+
const drift = edgeCommandDrift(running, edgeCommand(cfg));
|
|
273
|
+
if (drift.length)
|
|
274
|
+
out.push(
|
|
275
|
+
`${edgeNames(cfg.org).container}'s settings changed (${drift.join(", ")}): ` +
|
|
276
|
+
"recreating it once. Routes drop only while it restarts.",
|
|
277
|
+
);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
229
281
|
export async function edgeUp(
|
|
230
282
|
cfg: EdgeConfig,
|
|
231
|
-
opts: { takeOver?: boolean } = {},
|
|
283
|
+
opts: { takeOver?: boolean; log?: (line: string) => void } = {},
|
|
232
284
|
) {
|
|
233
285
|
// Before anything is written or started: an edge of this name that
|
|
234
286
|
// another state dir owns is never replaced.
|
|
@@ -261,6 +313,10 @@ export async function edgeUp(
|
|
|
261
313
|
const included = syncIncluded(cfg);
|
|
262
314
|
write(path.join(cfg.stateDir, "dynamic", GENERATED), renderEdgeDynamic(cfg));
|
|
263
315
|
const compose = path.join(cfg.stateDir, "compose.yml");
|
|
316
|
+
// SEC-2a: say what changed before compose recreates the edge for it.
|
|
317
|
+
const log = opts.log ?? ((l: string) => console.log(l));
|
|
318
|
+
for (const line of edgeUpNotices(cfg, runningEdgeCommand(n.container)))
|
|
319
|
+
log(line);
|
|
264
320
|
write(compose, renderEdgeCompose(cfg, realOrSelf(cfg.stateDir)));
|
|
265
321
|
must("docker", ["compose", "-f", compose, "up", "-d", "--remove-orphans"]);
|
|
266
322
|
return { certs, included };
|
package/src/shared/edge/types.ts
CHANGED
|
@@ -73,6 +73,16 @@ export interface EdgeConfig {
|
|
|
73
73
|
mounts: Record<string, string>;
|
|
74
74
|
/** Extra external Docker networks the edge joins. */
|
|
75
75
|
networks: string[];
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Extra Traefik static-config arguments. Den owns the entrypoints'
|
|
78
|
+
* forwarded-header and PROXY-protocol trust (SEC-2a): an argument that
|
|
79
|
+
* sets either is dropped, with a warning at `edge up`.
|
|
80
|
+
*/
|
|
77
81
|
args: string[];
|
|
82
|
+
/**
|
|
83
|
+
* `edge.proxy_protocol.trusted_ips` (SEC-2a): the front proxies (IPs or
|
|
84
|
+
* CIDRs) whose PROXY protocol header the entrypoints believe, so a TCP
|
|
85
|
+
* passthrough in front can pass on the real client. Empty: none.
|
|
86
|
+
*/
|
|
87
|
+
proxyProtocolTrustedIps?: string[];
|
|
78
88
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the MO "Den" GitHub App, as Den uses it (SEC-2 spec, "The MO Den
|
|
3
|
+
* GitHub App"). Den uses only its public client id, through the device flow,
|
|
4
|
+
* acting as the person: no client secret and no private key, ever. With the
|
|
5
|
+
* app's "Expire user authorization tokens" off, no refresh (which would need
|
|
6
|
+
* the secret) is ever needed, so Den never asks for `offline_access`.
|
|
7
|
+
*
|
|
8
|
+
* `web.github_client_id` (config/den.yml, or config/local/user.yml) replaces
|
|
9
|
+
* the default below; it is the one place the MO app's id lives.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The "Monster Orange Den" GitHub App's client id (owner Monster-Orange,
|
|
14
|
+
* slug monster-orange-den). Public by design: it ships in Den. A
|
|
15
|
+
* distribution sets its own app with `web.github_client_id`; an empty
|
|
16
|
+
* value there turns Sign in with GitHub off.
|
|
17
|
+
*/
|
|
18
|
+
export const MO_DEN_GITHUB_CLIENT_ID = "Iv23liKL12gnN6WvRV5b";
|
|
19
|
+
|
|
20
|
+
/** What a person reads when the app issues expiring tokens (never the token). */
|
|
21
|
+
export function expiringTokenWarning(): string {
|
|
22
|
+
return (
|
|
23
|
+
"The GitHub App issues expiring user tokens (its answer carried expires_in " +
|
|
24
|
+
"or a refresh_token). Sign-in still works: Den uses the token once and never " +
|
|
25
|
+
"keeps it. To fix it, switch off \u201cExpire user authorization tokens\u201d in " +
|
|
26
|
+
"the app's settings (GitHub \u203a Settings \u203a Developer settings \u203a GitHub Apps)."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where a person enters the device code. */
|
|
31
|
+
export const GITHUB_DEVICE_PAGE = "https://github.com/login/device";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tests only: a fake GitHub on loopback (`http://localhost:<port>`), for the
|
|
35
|
+
* device-flow endpoints and `GET /user` alike. Ignored unless it is plain
|
|
36
|
+
* http on localhost or 127.0.0.1, so it can never point Den at another host.
|
|
37
|
+
*/
|
|
38
|
+
export const GITHUB_TEST_BASE_ENV = "DEN_TEST_GITHUB_URL";
|
|
39
|
+
|
|
40
|
+
export interface GithubEndpoints {
|
|
41
|
+
deviceCode: string;
|
|
42
|
+
accessToken: string;
|
|
43
|
+
user: string;
|
|
44
|
+
/** A test's fake GitHub is in use (GITHUB_TEST_BASE_ENV). */
|
|
45
|
+
fake: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A loopback http base URL, without a trailing slash, or null. */
|
|
49
|
+
function loopbackBase(raw: string | undefined): string | null {
|
|
50
|
+
if (!raw) return null;
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(raw);
|
|
53
|
+
if (u.protocol !== "http:") return null;
|
|
54
|
+
if (u.hostname !== "localhost" && u.hostname !== "127.0.0.1") return null;
|
|
55
|
+
return u.origin;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** GitHub's device-flow and user endpoints (or a test's fake GitHub). */
|
|
62
|
+
export function githubEndpoints(
|
|
63
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
64
|
+
): GithubEndpoints {
|
|
65
|
+
const fake = loopbackBase(env[GITHUB_TEST_BASE_ENV]);
|
|
66
|
+
if (fake)
|
|
67
|
+
return {
|
|
68
|
+
deviceCode: `${fake}/login/device/code`,
|
|
69
|
+
accessToken: `${fake}/login/oauth/access_token`,
|
|
70
|
+
user: `${fake}/user`,
|
|
71
|
+
fake: true,
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
deviceCode: "https://github.com/login/device/code",
|
|
75
|
+
accessToken: "https://github.com/login/oauth/access_token",
|
|
76
|
+
user: "https://api.github.com/user",
|
|
77
|
+
fake: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The client id in use: `web.github_client_id`, else the MO app's. */
|
|
82
|
+
export function githubClientId(configured: string | undefined): string {
|
|
83
|
+
return (configured ?? MO_DEN_GITHUB_CLIENT_ID).trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a GitHub button says while no client id is configured. */
|
|
87
|
+
export function noClientIdMessage(cli: string): string {
|
|
88
|
+
return (
|
|
89
|
+
"Sign in with GitHub needs a GitHub App client id, and none is configured. " +
|
|
90
|
+
"Set web.github_client_id in config/local/user.yml (or the distribution's " +
|
|
91
|
+
`config), then restart the server (\`${cli} web restart\`). The app's setup ` +
|
|
92
|
+
"is in docs/distributions.md, “Signing in with GitHub”."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: GitHub's device flow, for a GitHub App with only its public client
|
|
3
|
+
* id (no secret), and `GET /user` for who approved it.
|
|
4
|
+
*
|
|
5
|
+
* 1. POST /login/device/code (client_id): a device code (kept here), a
|
|
6
|
+
* user code (shown to the person), how long both last, and the poll
|
|
7
|
+
* interval. No scope is asked for: identity needs none, and
|
|
8
|
+
* `offline_access` would force an expiring token.
|
|
9
|
+
* 2. POST /login/oauth/access_token (client_id, device_code, grant_type
|
|
10
|
+
* device_code) every `interval` seconds until it answers a token, or
|
|
11
|
+
* `authorization_pending` (keep going), `slow_down` (wait longer),
|
|
12
|
+
* `expired_token` or `access_denied` (stop).
|
|
13
|
+
* 3. GET /user with the token: its numeric `id` and `login`, nothing else.
|
|
14
|
+
* The caller drops the token right after.
|
|
15
|
+
*
|
|
16
|
+
* A token answer with `expires_in` or a `refresh_token` means the app has
|
|
17
|
+
* "Expire user authorization tokens" on: the result says so (`expiring`),
|
|
18
|
+
* for the caller's one warning; the sign-in still completes.
|
|
19
|
+
*
|
|
20
|
+
* Every call is one request on a fresh connection (no keep-alive), with a
|
|
21
|
+
* timeout. Nothing here logs; a token, device code or user code is never in
|
|
22
|
+
* an error message.
|
|
23
|
+
*/
|
|
24
|
+
import * as http from "http";
|
|
25
|
+
import * as https from "https";
|
|
26
|
+
import { githubEndpoints, type GithubEndpoints } from "./github-app.js";
|
|
27
|
+
|
|
28
|
+
export interface DeviceCode {
|
|
29
|
+
deviceCode: string;
|
|
30
|
+
userCode: string;
|
|
31
|
+
/** Seconds the codes last. */
|
|
32
|
+
expiresIn: number;
|
|
33
|
+
/** Seconds between polls. */
|
|
34
|
+
interval: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PollResult =
|
|
38
|
+
| {
|
|
39
|
+
kind: "token";
|
|
40
|
+
token: string;
|
|
41
|
+
/**
|
|
42
|
+
* The answer carried `expires_in` or a `refresh_token`: the app has
|
|
43
|
+
* "Expire user authorization tokens" on, a misconfiguration (Den
|
|
44
|
+
* never refreshes; it uses the token once). Never the token itself.
|
|
45
|
+
*/
|
|
46
|
+
expiring: boolean;
|
|
47
|
+
}
|
|
48
|
+
| { kind: "pending" }
|
|
49
|
+
| { kind: "slow_down"; interval?: number }
|
|
50
|
+
| { kind: "expired" }
|
|
51
|
+
| { kind: "denied" }
|
|
52
|
+
| { kind: "error"; message: string };
|
|
53
|
+
|
|
54
|
+
export interface GithubIdentity {
|
|
55
|
+
id: number;
|
|
56
|
+
login: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The three calls, swappable in a unit test. */
|
|
60
|
+
export interface GithubDeviceClient {
|
|
61
|
+
start(clientId: string): Promise<DeviceCode>;
|
|
62
|
+
poll(clientId: string, deviceCode: string): Promise<PollResult>;
|
|
63
|
+
user(token: string): Promise<GithubIdentity>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TIMEOUT_MS = 15_000;
|
|
67
|
+
const UA = "den-device-flow";
|
|
68
|
+
|
|
69
|
+
function request(
|
|
70
|
+
url: string,
|
|
71
|
+
opts: { method: string; headers: Record<string, string>; body?: string },
|
|
72
|
+
): Promise<{ status: number; body: string }> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const u = new URL(url);
|
|
75
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
76
|
+
const req = lib.request(
|
|
77
|
+
u,
|
|
78
|
+
{
|
|
79
|
+
method: opts.method,
|
|
80
|
+
agent: false,
|
|
81
|
+
timeout: TIMEOUT_MS,
|
|
82
|
+
headers: {
|
|
83
|
+
"user-agent": UA,
|
|
84
|
+
connection: "close",
|
|
85
|
+
...opts.headers,
|
|
86
|
+
...(opts.body !== undefined
|
|
87
|
+
? { "content-length": String(Buffer.byteLength(opts.body)) }
|
|
88
|
+
: {}),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
(res) => {
|
|
92
|
+
let out = "";
|
|
93
|
+
res.setEncoding("utf-8");
|
|
94
|
+
res.on("data", (d: string) => (out += d));
|
|
95
|
+
res.on("end", () =>
|
|
96
|
+
resolve({ status: res.statusCode ?? 0, body: out }),
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
req.on("timeout", () => req.destroy(new Error("GitHub did not answer")));
|
|
101
|
+
req.on("error", (e) =>
|
|
102
|
+
reject(new Error(`GitHub unreachable: ${e.message}`)),
|
|
103
|
+
);
|
|
104
|
+
req.end(opts.body);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parse(body: string): Record<string, unknown> {
|
|
109
|
+
try {
|
|
110
|
+
const v = JSON.parse(body) as unknown;
|
|
111
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const form = (o: Record<string, string>) => new URLSearchParams(o).toString();
|
|
118
|
+
const FORM_HEADERS = {
|
|
119
|
+
accept: "application/json",
|
|
120
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** The real GitHub (or a test's fake one, githubEndpoints). */
|
|
124
|
+
export function githubDeviceClient(
|
|
125
|
+
ep: GithubEndpoints = githubEndpoints(),
|
|
126
|
+
): GithubDeviceClient {
|
|
127
|
+
return {
|
|
128
|
+
async start(clientId) {
|
|
129
|
+
const r = await request(ep.deviceCode, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: FORM_HEADERS,
|
|
132
|
+
body: form({ client_id: clientId }),
|
|
133
|
+
});
|
|
134
|
+
const j = parse(r.body);
|
|
135
|
+
if (
|
|
136
|
+
r.status !== 200 ||
|
|
137
|
+
typeof j.device_code !== "string" ||
|
|
138
|
+
typeof j.user_code !== "string"
|
|
139
|
+
) {
|
|
140
|
+
const why =
|
|
141
|
+
typeof j.error === "string"
|
|
142
|
+
? j.error === "device_flow_disabled"
|
|
143
|
+
? "the GitHub App's device flow is off (tick “Enable Device Flow”)"
|
|
144
|
+
: j.error === "unauthorized_client" ||
|
|
145
|
+
j.error === "incorrect_client_credentials"
|
|
146
|
+
? "GitHub doesn't know this client id (web.github_client_id)"
|
|
147
|
+
: `GitHub answered ${j.error}`
|
|
148
|
+
: `GitHub answered ${r.status}`;
|
|
149
|
+
throw new Error(why);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
deviceCode: j.device_code,
|
|
153
|
+
userCode: j.user_code,
|
|
154
|
+
expiresIn: Number(j.expires_in) > 0 ? Number(j.expires_in) : 900,
|
|
155
|
+
interval: Number(j.interval) >= 0 ? Number(j.interval) : 5,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
async poll(clientId, deviceCode) {
|
|
159
|
+
let r: { status: number; body: string };
|
|
160
|
+
try {
|
|
161
|
+
r = await request(ep.accessToken, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: FORM_HEADERS,
|
|
164
|
+
body: form({
|
|
165
|
+
client_id: clientId,
|
|
166
|
+
device_code: deviceCode,
|
|
167
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { kind: "error", message: (e as Error).message };
|
|
172
|
+
}
|
|
173
|
+
const j = parse(r.body);
|
|
174
|
+
if (typeof j.access_token === "string" && j.access_token)
|
|
175
|
+
return {
|
|
176
|
+
kind: "token",
|
|
177
|
+
token: j.access_token,
|
|
178
|
+
expiring: j.expires_in !== undefined || j.refresh_token !== undefined,
|
|
179
|
+
};
|
|
180
|
+
switch (j.error) {
|
|
181
|
+
case "authorization_pending":
|
|
182
|
+
return { kind: "pending" };
|
|
183
|
+
case "slow_down":
|
|
184
|
+
return {
|
|
185
|
+
kind: "slow_down",
|
|
186
|
+
...(Number(j.interval) > 0 ? { interval: Number(j.interval) } : {}),
|
|
187
|
+
};
|
|
188
|
+
case "expired_token":
|
|
189
|
+
return { kind: "expired" };
|
|
190
|
+
case "access_denied":
|
|
191
|
+
return { kind: "denied" };
|
|
192
|
+
default:
|
|
193
|
+
return {
|
|
194
|
+
kind: "error",
|
|
195
|
+
message:
|
|
196
|
+
typeof j.error === "string"
|
|
197
|
+
? `GitHub answered ${j.error}`
|
|
198
|
+
: `GitHub answered ${r.status}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
async user(token) {
|
|
203
|
+
const r = await request(ep.user, {
|
|
204
|
+
method: "GET",
|
|
205
|
+
headers: {
|
|
206
|
+
accept: "application/vnd.github+json",
|
|
207
|
+
authorization: `Bearer ${token}`,
|
|
208
|
+
"x-github-api-version": "2022-11-28",
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
const j = parse(r.body);
|
|
212
|
+
if (
|
|
213
|
+
r.status !== 200 ||
|
|
214
|
+
typeof j.id !== "number" ||
|
|
215
|
+
!Number.isSafeInteger(j.id) ||
|
|
216
|
+
typeof j.login !== "string"
|
|
217
|
+
)
|
|
218
|
+
throw new Error(`GitHub's /user answered ${r.status}`);
|
|
219
|
+
return { id: j.id, login: j.login };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `github:<numeric id>`: how Den names a GitHub account. */
|
|
225
|
+
export function githubPrincipal(id: number): `github:${number}` {
|
|
226
|
+
return `github:${id}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type DeviceFlowOutcome =
|
|
230
|
+
| { kind: "ok"; identity: GithubIdentity; expiringToken?: boolean }
|
|
231
|
+
| { kind: "expired" }
|
|
232
|
+
| { kind: "denied" }
|
|
233
|
+
| { kind: "error"; message: string };
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The whole flow in one call (the CLI's `web owner link`): start, show the
|
|
237
|
+
* code (`onCode`), poll at GitHub's interval (`sleep`, injected in a test),
|
|
238
|
+
* read the identity, drop the token. `minIntervalMs` is the floor between
|
|
239
|
+
* polls (GitHub's own interval is never shorter than it says).
|
|
240
|
+
*/
|
|
241
|
+
export async function runDeviceFlow(opts: {
|
|
242
|
+
clientId: string;
|
|
243
|
+
client?: GithubDeviceClient;
|
|
244
|
+
onCode: (code: DeviceCode) => void;
|
|
245
|
+
sleep?: (ms: number) => Promise<void>;
|
|
246
|
+
now?: () => number;
|
|
247
|
+
minIntervalMs?: number;
|
|
248
|
+
}): Promise<DeviceFlowOutcome> {
|
|
249
|
+
const client = opts.client ?? githubDeviceClient();
|
|
250
|
+
const sleep =
|
|
251
|
+
opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
|
252
|
+
const now = opts.now ?? Date.now;
|
|
253
|
+
const code = await client.start(opts.clientId);
|
|
254
|
+
opts.onCode(code);
|
|
255
|
+
const deadline = now() + code.expiresIn * 1000;
|
|
256
|
+
const floor = opts.minIntervalMs ?? 1000;
|
|
257
|
+
let interval = Math.max(code.interval * 1000, floor);
|
|
258
|
+
let errors = 0;
|
|
259
|
+
for (;;) {
|
|
260
|
+
await sleep(interval);
|
|
261
|
+
if (now() >= deadline) return { kind: "expired" };
|
|
262
|
+
const r = await client.poll(opts.clientId, code.deviceCode);
|
|
263
|
+
if (r.kind === "token") {
|
|
264
|
+
// The token lives only for this one call.
|
|
265
|
+
const identity = await client.user(r.token);
|
|
266
|
+
return {
|
|
267
|
+
kind: "ok",
|
|
268
|
+
identity,
|
|
269
|
+
...(r.expiring ? { expiringToken: true } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (r.kind === "pending") continue;
|
|
273
|
+
if (r.kind === "slow_down") {
|
|
274
|
+
interval = Math.max(
|
|
275
|
+
r.interval !== undefined ? r.interval * 1000 : interval + 5000,
|
|
276
|
+
floor,
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (r.kind === "error") {
|
|
281
|
+
if (++errors >= 5) return { kind: "error", message: r.message };
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
return r;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -52,11 +52,34 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Overwrite the field; an item that isn't there yet is created (a Password item). */
|
|
55
56
|
async set(ref: SecretRef, value: string): Promise<void> {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
const vault = ref.vault ? ["--vault", ref.vault] : [];
|
|
58
|
+
const field = `${ref.field}[password]=${value}`;
|
|
59
|
+
try {
|
|
60
|
+
await execFileAsync("op", ["item", "edit", ref.item, ...vault, field], {
|
|
61
|
+
timeout: this.timeout,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
try {
|
|
65
|
+
await execFileAsync(
|
|
66
|
+
"op",
|
|
67
|
+
[
|
|
68
|
+
"item",
|
|
69
|
+
"create",
|
|
70
|
+
"--category",
|
|
71
|
+
"password",
|
|
72
|
+
"--title",
|
|
73
|
+
ref.item,
|
|
74
|
+
...vault,
|
|
75
|
+
field,
|
|
76
|
+
],
|
|
77
|
+
{ timeout: this.timeout },
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
async preflight(): Promise<PreflightResult> {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the owner's GitHub identity. `<state dir>/web-owner.json`, mode
|
|
3
|
+
* 0600: `{ v: 1, owner: "github:<numeric id>", login, linkedAt }`. The id
|
|
4
|
+
* is what sign-in compares; the login is for display only (a login can be
|
|
5
|
+
* renamed, the id can't). No token is ever stored: the device flow's token
|
|
6
|
+
* is dropped right after `GET /user`.
|
|
7
|
+
*
|
|
8
|
+
* Written by `<cli> web owner link` and Settings › Access; removed by
|
|
9
|
+
* `web owner unlink`. Re-read whenever it changes on disk.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from "crypto";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { getStateDir } from "./paths.js";
|
|
15
|
+
|
|
16
|
+
export const WEB_OWNER_FILE = "web-owner.json";
|
|
17
|
+
|
|
18
|
+
export interface WebOwner {
|
|
19
|
+
/** `github:<numeric id>`. */
|
|
20
|
+
owner: `github:${number}`;
|
|
21
|
+
/** The GitHub login at link time, for display. */
|
|
22
|
+
login: string;
|
|
23
|
+
/** ISO time it was linked. */
|
|
24
|
+
linkedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function webOwnerPath(stateDir: string = getStateDir()): string {
|
|
28
|
+
return path.join(stateDir, WEB_OWNER_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
32
|
+
|
|
33
|
+
/** The linked owner, or null (none, or a file that isn't one). */
|
|
34
|
+
export function readWebOwner(
|
|
35
|
+
stateDir: string = getStateDir(),
|
|
36
|
+
): WebOwner | null {
|
|
37
|
+
let raw: unknown;
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(fs.readFileSync(webOwnerPath(stateDir), "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const o = raw as Partial<WebOwner> | null;
|
|
44
|
+
if (
|
|
45
|
+
!o ||
|
|
46
|
+
typeof o.owner !== "string" ||
|
|
47
|
+
!/^github:[1-9][0-9]{0,15}$/.test(o.owner) ||
|
|
48
|
+
typeof o.login !== "string" ||
|
|
49
|
+
!LOGIN.test(o.login)
|
|
50
|
+
)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
owner: o.owner,
|
|
54
|
+
login: o.login,
|
|
55
|
+
linkedAt: typeof o.linkedAt === "string" ? o.linkedAt : "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Link `id` (`login` for display) as the owner: atomic, 0600. */
|
|
60
|
+
export function writeWebOwner(
|
|
61
|
+
identity: { id: number; login: string },
|
|
62
|
+
stateDir: string = getStateDir(),
|
|
63
|
+
now: Date = new Date(),
|
|
64
|
+
): WebOwner {
|
|
65
|
+
if (!Number.isSafeInteger(identity.id) || identity.id <= 0)
|
|
66
|
+
throw new Error("a GitHub account id is a positive integer");
|
|
67
|
+
if (!LOGIN.test(identity.login))
|
|
68
|
+
throw new Error("that is not a GitHub login");
|
|
69
|
+
const rec: WebOwner = {
|
|
70
|
+
owner: `github:${identity.id}`,
|
|
71
|
+
login: identity.login,
|
|
72
|
+
linkedAt: now.toISOString(),
|
|
73
|
+
};
|
|
74
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const file = webOwnerPath(stateDir);
|
|
76
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ v: 1, ...rec })}\n`, {
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
fs.renameSync(tmp, file);
|
|
81
|
+
return rec;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Unlink the owner; the one it was, or null when none was linked. */
|
|
85
|
+
export function clearWebOwner(
|
|
86
|
+
stateDir: string = getStateDir(),
|
|
87
|
+
): WebOwner | null {
|
|
88
|
+
const was = readWebOwner(stateDir);
|
|
89
|
+
fs.rmSync(webOwnerPath(stateDir), { force: true });
|
|
90
|
+
return was;
|
|
91
|
+
}
|