@monster-orange/den 0.4.19 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/README.md +7 -7
- package/docs/distributions.md +121 -13
- package/package.json +1 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +108 -17
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/service-command.ts +16 -0
- package/src/ui/cli/web-command.ts +74 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +148 -13
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +11 -1
- package/src/ui/server/public/i18n/ja.js +11 -1
- package/src/ui/server/public/settings-registry.js +23 -1
- package/src/ui/server/public/shell.js +11 -1
- package/src/ui/server/route-classes.ts +7 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the 1Password service-account token comes from, and why `op` failed.
|
|
3
|
+
*
|
|
4
|
+
* A Den running as a service (launchd, systemd) does not read a login shell,
|
|
5
|
+
* so `op` has no OP_SERVICE_ACCOUNT_TOKEN of its own. `secrets.onepassword.token`
|
|
6
|
+
* in config/local/user.yml (per machine, never the distribution's) says
|
|
7
|
+
* where to read one:
|
|
8
|
+
*
|
|
9
|
+
* keychain:<service> macOS: `security find-generic-password -s <service> -w`
|
|
10
|
+
* file:<path> a file whose mode is exactly 600; `~` expands
|
|
11
|
+
* env:<VAR> an env var of this process (the default is
|
|
12
|
+
* env:OP_SERVICE_ACCOUNT_TOKEN)
|
|
13
|
+
*
|
|
14
|
+
* The token is read when a ref is resolved, never cached, and goes only into
|
|
15
|
+
* the `op` child's env. Nothing here returns it in text: every message names
|
|
16
|
+
* the source, never the token.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile, execFileSync } from "child_process";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import { promisify } from "util";
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
export const OP_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN";
|
|
27
|
+
const SECURITY_TIMEOUT_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
export type OpTokenSource =
|
|
30
|
+
| { kind: "keychain"; service: string }
|
|
31
|
+
| { kind: "file"; path: string }
|
|
32
|
+
| { kind: "env"; name: string };
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_OP_TOKEN_SOURCE: OpTokenSource = {
|
|
35
|
+
kind: "env",
|
|
36
|
+
name: OP_TOKEN_ENV,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SHAPE =
|
|
40
|
+
"must be keychain:<service>, file:<path> or env:<VAR> (for example env:OP_SERVICE_ACCOUNT_TOKEN)";
|
|
41
|
+
|
|
42
|
+
/** Why `raw` is not a valid token source, or null. Never echoes `raw`. */
|
|
43
|
+
export function opTokenSourceError(raw: unknown): string | null {
|
|
44
|
+
if (typeof raw !== "string") return SHAPE;
|
|
45
|
+
const m = /^(keychain|file|env):(.*)$/s.exec(raw);
|
|
46
|
+
if (!m) return SHAPE;
|
|
47
|
+
const [, kind, rest] = m;
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
if (rest.trim() === "" || /[\u0000-\u001f]/.test(rest))
|
|
50
|
+
return `${kind}: needs a value on one line; ${SHAPE}`;
|
|
51
|
+
if (kind === "env" && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rest))
|
|
52
|
+
return `env: needs an env var name; ${SHAPE}`;
|
|
53
|
+
if (kind === "keychain" && rest.startsWith("-"))
|
|
54
|
+
return `keychain: service name must not start with "-"; ${SHAPE}`;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The source `raw` names (unset: the default). Throws, naming `where`. */
|
|
59
|
+
export function parseOpTokenSource(
|
|
60
|
+
raw: unknown,
|
|
61
|
+
where = "secrets.onepassword.token",
|
|
62
|
+
): OpTokenSource {
|
|
63
|
+
if (raw === undefined || raw === null) return DEFAULT_OP_TOKEN_SOURCE;
|
|
64
|
+
const err = opTokenSourceError(raw);
|
|
65
|
+
if (err) throw new Error(`${where} ${err}`);
|
|
66
|
+
const s = raw as string;
|
|
67
|
+
const i = s.indexOf(":");
|
|
68
|
+
const kind = s.slice(0, i);
|
|
69
|
+
const rest = s.slice(i + 1);
|
|
70
|
+
if (kind === "keychain") return { kind, service: rest };
|
|
71
|
+
if (kind === "file") return { kind, path: rest };
|
|
72
|
+
return { kind: "env", name: rest };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** "keychain: <service>", "file: <path>" or "env: <VAR>": what people see. */
|
|
76
|
+
export function describeOpTokenSource(src: OpTokenSource): string {
|
|
77
|
+
if (src.kind === "keychain") return `keychain: ${src.service}`;
|
|
78
|
+
if (src.kind === "file") return `file: ${src.path}`;
|
|
79
|
+
return `env: ${src.name}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type OpTokenResult =
|
|
83
|
+
| { ok: true; token: string }
|
|
84
|
+
| { ok: false; reason: string };
|
|
85
|
+
|
|
86
|
+
export interface OpTokenDeps {
|
|
87
|
+
env?: NodeJS.ProcessEnv;
|
|
88
|
+
platform?: NodeJS.Platform;
|
|
89
|
+
home?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expandHome(p: string, home: string): string {
|
|
93
|
+
if (p === "~") return home;
|
|
94
|
+
if (p.startsWith("~/")) return path.join(home, p.slice(2));
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fail(src: OpTokenSource, why: string): OpTokenResult {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: `token source ${describeOpTokenSource(src)}: ${why}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readTokenFile(src: { kind: "file"; path: string }, home: string) {
|
|
106
|
+
const file = expandHome(src.path, home);
|
|
107
|
+
let st: fs.Stats;
|
|
108
|
+
try {
|
|
109
|
+
st = fs.statSync(file);
|
|
110
|
+
} catch {
|
|
111
|
+
return fail(src, "file not found or not readable");
|
|
112
|
+
}
|
|
113
|
+
if (!st.isFile()) return fail(src, "not a regular file");
|
|
114
|
+
const mode = st.mode & 0o777;
|
|
115
|
+
if (mode !== 0o600)
|
|
116
|
+
return fail(
|
|
117
|
+
src,
|
|
118
|
+
`file mode is ${mode.toString(8).padStart(3, "0")}; it must be 600 (chmod 600 the file)`,
|
|
119
|
+
);
|
|
120
|
+
let text: string;
|
|
121
|
+
try {
|
|
122
|
+
text = fs.readFileSync(file, "utf-8");
|
|
123
|
+
} catch {
|
|
124
|
+
return fail(src, "file not readable");
|
|
125
|
+
}
|
|
126
|
+
const token = text.replace(/[\r\n]+$/, "");
|
|
127
|
+
return token ? ({ ok: true, token } as const) : fail(src, "file is empty");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fromEnv(src: { kind: "env"; name: string }, env: NodeJS.ProcessEnv) {
|
|
131
|
+
const v = env[src.name];
|
|
132
|
+
return v
|
|
133
|
+
? ({ ok: true, token: v } as const)
|
|
134
|
+
: fail(src, "env var is not set");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const NOT_MAC = "the macOS keychain is not available on this system";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read the token now. Never cached. The failure reason names the source,
|
|
141
|
+
* never the token.
|
|
142
|
+
*/
|
|
143
|
+
export async function readOpToken(
|
|
144
|
+
src: OpTokenSource,
|
|
145
|
+
deps: OpTokenDeps = {},
|
|
146
|
+
): Promise<OpTokenResult> {
|
|
147
|
+
const env = deps.env ?? process.env;
|
|
148
|
+
const home = deps.home ?? os.homedir();
|
|
149
|
+
if (src.kind === "env") return fromEnv(src, env);
|
|
150
|
+
if (src.kind === "file") return readTokenFile(src, home);
|
|
151
|
+
if ((deps.platform ?? process.platform) !== "darwin")
|
|
152
|
+
return fail(src, NOT_MAC);
|
|
153
|
+
try {
|
|
154
|
+
const { stdout } = await execFileAsync(
|
|
155
|
+
"security",
|
|
156
|
+
["find-generic-password", "-s", src.service, "-w"],
|
|
157
|
+
{ timeout: SECURITY_TIMEOUT_MS, env },
|
|
158
|
+
);
|
|
159
|
+
const token = String(stdout).replace(/[\r\n]+$/, "");
|
|
160
|
+
return token ? { ok: true, token } : fail(src, "keychain item is empty");
|
|
161
|
+
} catch {
|
|
162
|
+
return fail(src, "keychain item not found or not readable");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether the source yields a token, for `doctor`: the same checks, but a
|
|
168
|
+
* keychain item is only looked up (no `-w`), so nothing is read and no
|
|
169
|
+
* keychain prompt can appear. Returns the reason, or null when it works.
|
|
170
|
+
*/
|
|
171
|
+
export function opTokenSourceProblem(
|
|
172
|
+
src: OpTokenSource,
|
|
173
|
+
deps: OpTokenDeps = {},
|
|
174
|
+
): string | null {
|
|
175
|
+
const env = deps.env ?? process.env;
|
|
176
|
+
const home = deps.home ?? os.homedir();
|
|
177
|
+
if (src.kind === "env") {
|
|
178
|
+
const r = fromEnv(src, env);
|
|
179
|
+
return r.ok ? null : r.reason;
|
|
180
|
+
}
|
|
181
|
+
if (src.kind === "file") {
|
|
182
|
+
const r = readTokenFile(src, home);
|
|
183
|
+
return r.ok ? null : r.reason;
|
|
184
|
+
}
|
|
185
|
+
if ((deps.platform ?? process.platform) !== "darwin")
|
|
186
|
+
return `token source ${describeOpTokenSource(src)}: ${NOT_MAC}`;
|
|
187
|
+
try {
|
|
188
|
+
execFileSync("security", ["find-generic-password", "-s", src.service], {
|
|
189
|
+
timeout: SECURITY_TIMEOUT_MS,
|
|
190
|
+
env,
|
|
191
|
+
stdio: "ignore",
|
|
192
|
+
});
|
|
193
|
+
return null;
|
|
194
|
+
} catch {
|
|
195
|
+
return `token source ${describeOpTokenSource(src)}: keychain item not found`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type OpFailureCode =
|
|
200
|
+
| "no_token"
|
|
201
|
+
| "no_access"
|
|
202
|
+
| "not_found"
|
|
203
|
+
| "rate_limited"
|
|
204
|
+
| "token_source"
|
|
205
|
+
| "unknown";
|
|
206
|
+
|
|
207
|
+
export interface OpFailureInfo {
|
|
208
|
+
code: OpFailureCode;
|
|
209
|
+
/** Plain words, safe to show. */
|
|
210
|
+
text: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Map `op`'s stderr to one plain reason. Pure. `redact` lists strings (the
|
|
215
|
+
* token) to blank out of the unknown case's summary.
|
|
216
|
+
*/
|
|
217
|
+
export function mapOpFailure(
|
|
218
|
+
stderr: string,
|
|
219
|
+
redact: readonly string[] = [],
|
|
220
|
+
): OpFailureInfo {
|
|
221
|
+
const s = stderr ?? "";
|
|
222
|
+
if (
|
|
223
|
+
/not signed in|no active session|service account token|sign ?in|session (has )?expired|authenticat|no accounts? (configured|found)/i.test(
|
|
224
|
+
s,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return {
|
|
228
|
+
code: "no_token",
|
|
229
|
+
text: "1Password has no service-account token or session for this process",
|
|
230
|
+
};
|
|
231
|
+
if (/rate.?limit|too many requests|\b429\b/i.test(s))
|
|
232
|
+
return { code: "rate_limited", text: "1Password rate limited the request" };
|
|
233
|
+
if (
|
|
234
|
+
/isn't a vault|is not a vault|don't have access|do not have access|doesn't have access|not authorized|forbidden|permission|\b403\b|access denied/i.test(
|
|
235
|
+
s,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return { code: "no_access", text: "no access to the vault" };
|
|
239
|
+
if (
|
|
240
|
+
/isn't an item|isn't a field|is not an item|not found|no such|could not find|couldn't find|doesn't exist|does not exist|no (item|field)|more than one item/i.test(
|
|
241
|
+
s,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
return { code: "not_found", text: "item or field not found" };
|
|
245
|
+
let line =
|
|
246
|
+
s
|
|
247
|
+
.split(/\r?\n/)
|
|
248
|
+
.map((l) =>
|
|
249
|
+
l.replace(/^\[\w+\]\s*\d{4}\/\d\d\/\d\d \d\d:\d\d:\d\d\s*/, "").trim(),
|
|
250
|
+
)
|
|
251
|
+
.find((l) => l) ?? "";
|
|
252
|
+
for (const r of redact) if (r) line = line.split(r).join("[hidden]");
|
|
253
|
+
line = line.replace(/op:\/\/\S+/g, "[ref]");
|
|
254
|
+
if (line.length > 100) line = `${line.slice(0, 100)}...`;
|
|
255
|
+
return {
|
|
256
|
+
code: "unknown",
|
|
257
|
+
text: line ? `1Password failed: ${line}` : "1Password failed (no detail)",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A failed 1Password read, with a reason that is safe to show. */
|
|
262
|
+
export class OpFailure extends Error {
|
|
263
|
+
constructor(
|
|
264
|
+
readonly code: OpFailureCode,
|
|
265
|
+
message: string,
|
|
266
|
+
) {
|
|
267
|
+
super(message);
|
|
268
|
+
this.name = "OpFailure";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -80,6 +80,8 @@ export function assertSecretEnvSpec(
|
|
|
80
80
|
export interface SecretEnvMissing {
|
|
81
81
|
key: string;
|
|
82
82
|
ref: string;
|
|
83
|
+
/** Why, in plain words, when the provider said (never a value). */
|
|
84
|
+
reason?: string;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export type SecretEnvResolution =
|
|
@@ -87,8 +89,10 @@ export type SecretEnvResolution =
|
|
|
87
89
|
| { ok: false; missing: SecretEnvMissing[]; message: string };
|
|
88
90
|
|
|
89
91
|
/**
|
|
90
|
-
* The failure text for unresolved keys: key, ref and provider,
|
|
91
|
-
*
|
|
92
|
+
* The failure text for unresolved keys: key, ref and provider, and the
|
|
93
|
+
* provider's reason when it gave one (a mapped phrase such as "no access to
|
|
94
|
+
* the vault"), never a value and never its raw error output (which could
|
|
95
|
+
* echo one).
|
|
92
96
|
*/
|
|
93
97
|
export function describeSecretEnvMissing(
|
|
94
98
|
target: string,
|
|
@@ -96,7 +100,8 @@ export function describeSecretEnvMissing(
|
|
|
96
100
|
provider: string,
|
|
97
101
|
): string {
|
|
98
102
|
const lines = missing.map(
|
|
99
|
-
(m) =>
|
|
103
|
+
(m) =>
|
|
104
|
+
` ${m.key} (ref ${m.ref}, provider ${provider}): not resolved${m.reason ? `: ${m.reason}` : ""}`,
|
|
100
105
|
);
|
|
101
106
|
return (
|
|
102
107
|
`${target}: ${missing.length} secret_env key(s) could not be resolved; not starting it:\n` +
|
|
@@ -120,17 +125,25 @@ export async function resolveSecretEnv(
|
|
|
120
125
|
entries.map(async ([key, raw]) => {
|
|
121
126
|
if (secretEnvRefError(raw)) return { key, raw, value: null };
|
|
122
127
|
let value: string | null = null;
|
|
128
|
+
let reason: string | undefined;
|
|
123
129
|
try {
|
|
124
|
-
|
|
130
|
+
const ref = parseSecretEnvRef(raw);
|
|
131
|
+
if (provider.tryGetWithReason) {
|
|
132
|
+
({ value, reason } = await provider.tryGetWithReason(ref));
|
|
133
|
+
} else value = await provider.tryGet(ref);
|
|
125
134
|
} catch {
|
|
126
135
|
value = null;
|
|
127
136
|
}
|
|
128
|
-
return { key, raw, value };
|
|
137
|
+
return { key, raw, value, reason };
|
|
129
138
|
}),
|
|
130
139
|
);
|
|
131
140
|
const missing = results
|
|
132
141
|
.filter((r) => !r.value)
|
|
133
|
-
.map((r) => ({
|
|
142
|
+
.map((r) => ({
|
|
143
|
+
key: r.key,
|
|
144
|
+
ref: String(r.raw),
|
|
145
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
146
|
+
}));
|
|
134
147
|
if (missing.length)
|
|
135
148
|
return {
|
|
136
149
|
ok: false,
|
|
@@ -27,6 +27,7 @@ import { DopplerProvider } from "./doppler.js";
|
|
|
27
27
|
import { EnvProvider } from "./env.js";
|
|
28
28
|
import { NoneProvider } from "./none.js";
|
|
29
29
|
import { OnePasswordProvider } from "./onepassword.js";
|
|
30
|
+
import { parseOpTokenSource } from "./op-token.js";
|
|
30
31
|
import type { SecretsConfig } from "./index.js";
|
|
31
32
|
import type { SecretRef, SecretsProvider } from "./types.js";
|
|
32
33
|
|
|
@@ -162,6 +163,11 @@ const onepassword: SecretsStrategy = {
|
|
|
162
163
|
// From the distribution's config or the engineer's shell; never a
|
|
163
164
|
// built-in account.
|
|
164
165
|
account: c.onepassword?.account,
|
|
166
|
+
// Per machine (user.yml); validated when the config is read.
|
|
167
|
+
tokenSource:
|
|
168
|
+
c.onepassword?.token === undefined
|
|
169
|
+
? undefined
|
|
170
|
+
: parseOpTokenSource(c.onepassword.token),
|
|
165
171
|
timeoutMs: OP_CLI_TIMEOUT_MS,
|
|
166
172
|
}),
|
|
167
173
|
cli: { bin: "op", formula: "1password-cli" },
|
|
@@ -50,6 +50,14 @@ export interface SecretsProvider {
|
|
|
50
50
|
get(ref: SecretRef): Promise<string>;
|
|
51
51
|
/** Read a secret, returning null instead of throwing. */
|
|
52
52
|
tryGet(ref: SecretRef): Promise<string | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Like tryGet, plus a plain-words reason when it is null (op's own: no
|
|
55
|
+
* token, no access to the vault, not found, rate limited). Optional. The
|
|
56
|
+
* reason never carries a secret value or a token.
|
|
57
|
+
*/
|
|
58
|
+
tryGetWithReason?(
|
|
59
|
+
ref: SecretRef,
|
|
60
|
+
): Promise<{ value: string | null; reason?: string }>;
|
|
53
61
|
/** Write (create or overwrite) a secret. Throws on failure. */
|
|
54
62
|
set(ref: SecretRef, value: string): Promise<void>;
|
|
55
63
|
/** Check the backend is usable before `den up`/`restart`. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SEC-1b (SEC-1 spec §2): the browser's sign-in.
|
|
3
3
|
*
|
|
4
|
-
* - The login code: one-time, valid for
|
|
4
|
+
* - The login code: one-time, valid for `web.login_link_ttl` (D16: 5
|
|
5
|
+
* minutes unless set; it was 60 s), derived from web.secret.
|
|
5
6
|
* `v1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(secret, the rest). The
|
|
6
7
|
* CLI mints it (it reads the secret as the same OS user); the server
|
|
7
8
|
* checks the mac in constant time and the expiry, and records the nonce
|
|
@@ -24,7 +25,12 @@ import * as yaml from "yaml";
|
|
|
24
25
|
import { parseMachineName, parseSshUser } from "./cli-command.js";
|
|
25
26
|
import { getStateDir } from "./paths.js";
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
/** D16: how long a sign-in link lasts unless `web.login_link_ttl` says. */
|
|
29
|
+
export const LOGIN_CODE_TTL_MS = 5 * 60_000;
|
|
30
|
+
/** The longest `web.login_link_ttl` may be (and the most a code is believed). */
|
|
31
|
+
export const LOGIN_CODE_MAX_TTL_MS = 60 * 60_000;
|
|
32
|
+
/** The shortest `web.login_link_ttl` may be. */
|
|
33
|
+
export const LOGIN_CODE_MIN_TTL_MS = 30_000;
|
|
28
34
|
/** Clock skew allowed between the CLI that mints a code and the server. */
|
|
29
35
|
const LOGIN_CODE_SKEW_MS = 5_000;
|
|
30
36
|
const LOGIN_CONTEXT = "den-login-v1:";
|
|
@@ -57,13 +63,14 @@ function mac(secret: Buffer, body: string): Buffer {
|
|
|
57
63
|
.digest();
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
/** A fresh login code, valid for
|
|
66
|
+
/** A fresh login code, valid for `ttlMs` (web.login_link_ttl) from `now`. */
|
|
61
67
|
export function mintLoginCode(
|
|
62
68
|
secret: Buffer,
|
|
63
69
|
now: number = Date.now(),
|
|
70
|
+
ttlMs: number = LOGIN_CODE_TTL_MS,
|
|
64
71
|
): string {
|
|
65
72
|
const nonce = randomBytes(16).toString("base64url");
|
|
66
|
-
const body = `v1.${nonce}.${(now +
|
|
73
|
+
const body = `v1.${nonce}.${(now + ttlMs).toString(36)}`;
|
|
67
74
|
return `${body}.${mac(secret, body).toString("base64url")}`;
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -72,7 +79,8 @@ export type LoginCodeCheck =
|
|
|
72
79
|
| { ok: false; reason: "malformed" | "bad_mac" | "expired" };
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
|
-
* Whether `code` is one this secret minted and still in its
|
|
82
|
+
* Whether `code` is one this secret minted and still in its time (at most
|
|
83
|
+
* LOGIN_CODE_MAX_TTL_MS: web.login_link_ttl's ceiling). The mac is
|
|
76
84
|
* compared in constant time. Single use is the caller's (consumeCode).
|
|
77
85
|
*/
|
|
78
86
|
export function checkLoginCode(
|
|
@@ -93,7 +101,7 @@ export function checkLoginCode(
|
|
|
93
101
|
if (
|
|
94
102
|
!Number.isFinite(expiresAt) ||
|
|
95
103
|
now >= expiresAt ||
|
|
96
|
-
expiresAt - now >
|
|
104
|
+
expiresAt - now > LOGIN_CODE_MAX_TTL_MS + LOGIN_CODE_SKEW_MS
|
|
97
105
|
)
|
|
98
106
|
return { ok: false, reason: "expired" };
|
|
99
107
|
return { ok: true, nonce: m[2], expiresAt };
|
|
@@ -201,6 +209,77 @@ export interface WebAuthConfig {
|
|
|
201
209
|
* Den runs as. "": no account on the page.
|
|
202
210
|
*/
|
|
203
211
|
sshUser?: string;
|
|
212
|
+
/**
|
|
213
|
+
* `web.owner_github` (D17, user.yml only): the GitHub login of the person
|
|
214
|
+
* who runs this instance, recorded from `gh` at `web up` or `service
|
|
215
|
+
* install`. With no owner linked, that login's GitHub sign-in, from any
|
|
216
|
+
* machine, becomes the owner.
|
|
217
|
+
*/
|
|
218
|
+
ownerGithub?: string;
|
|
219
|
+
/**
|
|
220
|
+
* `web.allowed_github_org` (D17; the distribution config, or user.yml):
|
|
221
|
+
* with no owner linked, a member of this GitHub org who proves they are
|
|
222
|
+
* at this machine ("This is my computer") becomes the owner.
|
|
223
|
+
*/
|
|
224
|
+
allowedGithubOrg?: string;
|
|
225
|
+
/** The file `web.allowed_github_org` came from. */
|
|
226
|
+
allowedGithubOrgFile?: string;
|
|
227
|
+
/**
|
|
228
|
+
* `web.login_link_ttl` (D16; user.yml over the distribution config): how
|
|
229
|
+
* long a sign-in link lasts, in ms. Unset: LOGIN_CODE_TTL_MS (5 minutes).
|
|
230
|
+
*/
|
|
231
|
+
loginLinkTtlMs: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A GitHub login (or org name): letters, digits, single dashes, ≤ 39. */
|
|
235
|
+
const GITHUB_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
236
|
+
|
|
237
|
+
/** `web.owner_github` or `web.allowed_github_org`: a GitHub name, or unset. */
|
|
238
|
+
export function parseGithubName(
|
|
239
|
+
v: unknown,
|
|
240
|
+
key: string,
|
|
241
|
+
file: string,
|
|
242
|
+
): string | undefined {
|
|
243
|
+
if (v === undefined || v === null) return undefined;
|
|
244
|
+
const s = typeof v === "string" ? v.trim().replace(/^@/, "") : "";
|
|
245
|
+
if (s === "" && typeof v === "string") return undefined;
|
|
246
|
+
if (!GITHUB_NAME.test(s))
|
|
247
|
+
throw new Error(
|
|
248
|
+
`${key} (${file}) must be a GitHub name (letters, digits and dashes), got ${JSON.stringify(v)}`,
|
|
249
|
+
);
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `web.login_link_ttl`: `90s`, `5m`, `1h`, or a number of seconds, between
|
|
255
|
+
* 30 s and 60 min. Unset: undefined. Throws naming the key otherwise.
|
|
256
|
+
*/
|
|
257
|
+
export function parseLoginLinkTtl(
|
|
258
|
+
v: unknown,
|
|
259
|
+
file: string,
|
|
260
|
+
): number | undefined {
|
|
261
|
+
if (v === undefined || v === null) return undefined;
|
|
262
|
+
const m =
|
|
263
|
+
typeof v === "number"
|
|
264
|
+
? [String(v), String(v), "s"]
|
|
265
|
+
: typeof v === "string"
|
|
266
|
+
? /^\s*(\d+)\s*(s|m|h)?\s*$/i.exec(v)
|
|
267
|
+
: null;
|
|
268
|
+
const ms = m
|
|
269
|
+
? Number(m[1]) *
|
|
270
|
+
({ s: 1000, m: 60_000, h: 3_600_000 } as Record<string, number>)[
|
|
271
|
+
(m[2] ?? "s").toLowerCase()
|
|
272
|
+
]
|
|
273
|
+
: NaN;
|
|
274
|
+
if (
|
|
275
|
+
!Number.isFinite(ms) ||
|
|
276
|
+
ms < LOGIN_CODE_MIN_TTL_MS ||
|
|
277
|
+
ms > LOGIN_CODE_MAX_TTL_MS
|
|
278
|
+
)
|
|
279
|
+
throw new Error(
|
|
280
|
+
`web.login_link_ttl (${file}) must be a time between 30s and 60m (such as 5m), got ${JSON.stringify(v)}`,
|
|
281
|
+
);
|
|
282
|
+
return ms;
|
|
204
283
|
}
|
|
205
284
|
|
|
206
285
|
/** `web.edge_unattested`'s values. */
|
|
@@ -300,7 +379,34 @@ export function readWebAuthConfig(
|
|
|
300
379
|
user.ssh_user !== undefined
|
|
301
380
|
? parseSshUser(user.ssh_user)
|
|
302
381
|
: parseSshUser(team.ssh_user);
|
|
382
|
+
const ownerGithub = parseGithubName(
|
|
383
|
+
user.owner_github,
|
|
384
|
+
"web.owner_github",
|
|
385
|
+
"config/local/user.yml",
|
|
386
|
+
);
|
|
387
|
+
const userOrg = parseGithubName(
|
|
388
|
+
user.allowed_github_org,
|
|
389
|
+
"web.allowed_github_org",
|
|
390
|
+
"config/local/user.yml",
|
|
391
|
+
);
|
|
392
|
+
const teamOrg = parseGithubName(
|
|
393
|
+
team.allowed_github_org,
|
|
394
|
+
"web.allowed_github_org",
|
|
395
|
+
configFile,
|
|
396
|
+
);
|
|
397
|
+
const allowedGithubOrg = userOrg ?? teamOrg;
|
|
398
|
+
const ttl =
|
|
399
|
+
parseLoginLinkTtl(user.login_link_ttl, "config/local/user.yml") ??
|
|
400
|
+
parseLoginLinkTtl(team.login_link_ttl, configFile);
|
|
303
401
|
return {
|
|
402
|
+
...(ownerGithub ? { ownerGithub } : {}),
|
|
403
|
+
...(allowedGithubOrg
|
|
404
|
+
? {
|
|
405
|
+
allowedGithubOrg,
|
|
406
|
+
allowedGithubOrgFile: userOrg ? "config/local/user.yml" : configFile,
|
|
407
|
+
}
|
|
408
|
+
: {}),
|
|
409
|
+
loginLinkTtlMs: ttl ?? LOGIN_CODE_TTL_MS,
|
|
304
410
|
...(machineName ? { machineName } : {}),
|
|
305
411
|
...(sshUser !== undefined ? { sshUser } : {}),
|
|
306
412
|
allowPlainHttpRemote: plain === true,
|