@sigrun/sigrun 0.2.1 → 0.3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +234 -1
  3. package/dist/cli.js +117 -19
  4. package/dist/config.js +21 -2
  5. package/dist/extension.d.ts +7 -1
  6. package/dist/harness/ambient.js +2 -0
  7. package/dist/harness/blocks/state.js +1 -0
  8. package/dist/harness/boot.js +86 -6
  9. package/dist/harness/context.js +25 -0
  10. package/dist/harness/event-bus.js +25 -14
  11. package/dist/harness/identity/accounts.js +3 -2
  12. package/dist/harness/identity/index.js +2 -1
  13. package/dist/harness/identity/invites.js +26 -9
  14. package/dist/harness/identity/link-sweep.js +47 -0
  15. package/dist/harness/identity/typed-username.js +15 -0
  16. package/dist/harness/identity/users.js +10 -7
  17. package/dist/harness/interactions.js +2 -0
  18. package/dist/harness/loader.js +57 -1
  19. package/dist/harness/migration-snapshot.js +97 -0
  20. package/dist/harness/reports.js +100 -0
  21. package/dist/harness/seam-host.js +11 -1
  22. package/dist/harness/settings.js +52 -4
  23. package/dist/jobs/docs.js +3 -0
  24. package/dist/jobs/fire.js +2 -0
  25. package/dist/jobs/parked.js +38 -0
  26. package/dist/jobs/task.js +37 -8
  27. package/dist/serve.js +23 -14
  28. package/dist/server/auth/ip.js +9 -4
  29. package/dist/server/auth/own-host.js +115 -0
  30. package/dist/server/auth/pipeline.js +64 -48
  31. package/dist/server/auth/proxy-diagnostic.js +60 -0
  32. package/dist/server/auth/proxy-identity.js +134 -0
  33. package/dist/server/auth/proxy-only.js +28 -0
  34. package/dist/server/login/resolve.js +88 -26
  35. package/dist/server/routes/admin.js +97 -0
  36. package/dist/server/routes/login.js +8 -0
  37. package/dist/server/routes/metrics.js +18 -2
  38. package/dist/server/routes/onboarding.js +97 -37
  39. package/dist/server/session-meta.js +1 -0
  40. package/dist/server/shutdown.js +93 -16
  41. package/dist/settings/registry.js +6 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
  43. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  44. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  45. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  46. package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
  47. package/node_modules/@sigrun/shared/package.json +5 -1
  48. package/package.json +14 -12
  49. package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
  50. package/webui/dist/index.html +1 -1
  51. package/dist/server/login/usernames.js +0 -15
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The C5 own-host rule (proxy-trust Task 4b): a peer that IS this host —
3
+ * loopback (127.0.0.0/8, ::1) or any current interface address — can
4
+ * reach the server without traversing the forward-auth proxy, because
5
+ * every local process shares those source addresses. Trusting such a
6
+ * peer through a CIDR RANGE would let any local process impersonate
7
+ * users, so an own-host peer vouches only when:
8
+ *
9
+ * 1. the shared secret is configured (`secret` + `secretHeader`) — the
10
+ * existing secret check then decides; or
11
+ * 2. that exact address is listed explicitly in `trustedProxies` (a
12
+ * bare address, or a /32 (IPv4) / /128 (IPv6) entry) — an explicit
13
+ * operator decision, which the boot warning surfaces.
14
+ *
15
+ * Otherwise both identity-trust paths (the request pipeline's proxy
16
+ * method and onboarding's `resolveProxyIdentity`) reject exactly the
17
+ * way an untrusted peer is rejected, with the OWN_HOST diagnostic.
18
+ */
19
+ import { networkInterfaces } from "node:os";
20
+ import { isIP } from "node:net";
21
+ import { normalizePeerAddress } from "./ip.js";
22
+ /** Interface addresses refresh at most this often (milliseconds). */
23
+ const CACHE_TTL_MS = 30_000;
24
+ let testAddresses;
25
+ let cached;
26
+ /** The seam override, or the real interface list (cached ≤ 30 s). */
27
+ function ownHostAddresses() {
28
+ if (testAddresses !== undefined)
29
+ return testAddresses;
30
+ const now = Date.now();
31
+ if (cached === undefined || now - cached.readAt >= CACHE_TTL_MS) {
32
+ const addresses = [];
33
+ for (const entries of Object.values(networkInterfaces())) {
34
+ for (const entry of entries ?? [])
35
+ addresses.push(entry.address);
36
+ }
37
+ cached = { addresses, readAt: now };
38
+ }
39
+ return cached.addresses;
40
+ }
41
+ function isLoopback(address) {
42
+ if (address === "::1")
43
+ return true;
44
+ if (isIP(address) !== 4)
45
+ return false;
46
+ return address.split(".")[0] === "127";
47
+ }
48
+ /** Loopback (127.0.0.0/8, ::1) or any current interface address; `::ffff:` unwrapped. */
49
+ export function isOwnHostAddress(address) {
50
+ if (address === undefined)
51
+ return false;
52
+ const candidate = normalizePeerAddress(address);
53
+ if (isLoopback(candidate))
54
+ return true;
55
+ return ownHostAddresses().some((entry) => normalizePeerAddress(entry) === candidate);
56
+ }
57
+ /** True when `address` is listed exactly in `cidrs`: a bare address, or a /32 (IPv4) or /128 (IPv6) entry for it. */
58
+ export function listedExactly(address, cidrs) {
59
+ const candidate = normalizePeerAddress(address);
60
+ for (const raw of cidrs) {
61
+ const entry = raw.trim();
62
+ if (entry === "")
63
+ continue;
64
+ const slash = entry.indexOf("/");
65
+ if (slash === -1) {
66
+ if (normalizePeerAddress(entry) === candidate)
67
+ return true;
68
+ continue;
69
+ }
70
+ const base = normalizePeerAddress(entry.slice(0, slash));
71
+ if (base !== candidate)
72
+ continue;
73
+ const prefix = Number(entry.slice(slash + 1));
74
+ const family = isIP(candidate);
75
+ if (family === 4 && prefix === 32)
76
+ return true;
77
+ if (family === 6 && prefix === 128)
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ /** The C5 rule: false only for an own-host peer reached through a range with no shared secret configured. */
83
+ export function ownHostPeerPermitted(address, trustedProxies, secretConfigured) {
84
+ if (!isOwnHostAddress(address))
85
+ return true;
86
+ if (secretConfigured)
87
+ return true;
88
+ return listedExactly(address, trustedProxies);
89
+ }
90
+ /** Test seam: replace the interface addresses (loopback stays implicit); `undefined` restores os.networkInterfaces(). */
91
+ export function setOwnHostAddressesForTests(addresses) {
92
+ testAddresses = addresses;
93
+ cached = undefined;
94
+ }
95
+ /** Explicitly listed own-host entries (verbatim spellings), for the boot warning. */
96
+ export function listedOwnHostEntries(trustedProxies) {
97
+ return trustedProxies.filter((entry) => {
98
+ const trimmed = entry.trim();
99
+ if (trimmed === "")
100
+ return false;
101
+ const slash = trimmed.indexOf("/");
102
+ const base = slash === -1 ? trimmed : trimmed.slice(0, slash);
103
+ if (slash !== -1) {
104
+ const prefix = Number(trimmed.slice(slash + 1));
105
+ const family = isIP(normalizePeerAddress(base));
106
+ if (family === 4 && prefix !== 32)
107
+ return false;
108
+ if (family === 6 && prefix !== 128)
109
+ return false;
110
+ if (family === 0)
111
+ return false;
112
+ }
113
+ return isOwnHostAddress(base);
114
+ });
115
+ }
@@ -43,13 +43,19 @@
43
43
  * the token's user must exist in the directory, else 401 (no log —
44
44
  * token failures are not proxy diagnostics).
45
45
  * 6. **Empty list** (the naive workstation path): the trimmed
46
- * `X-Sigrun-User` header verbatim, `admin: true`, method `"none"`.
46
+ * `X-Sigrun-User` header DERIVED to its canonical key (`admin: true`,
47
+ * method `"none"`). An unusable header (no derivable key) is a loud
48
+ * 400 `invalid_username`; an absent header behaves as before.
47
49
  */
48
50
  import { timingSafeEqual } from "node:crypto";
49
51
  import { ipAllowed } from "./ip.js";
50
52
  import { isLoopbackHost } from "./loopback.js";
53
+ import { listedOwnHostEntries, ownHostPeerPermitted } from "./own-host.js";
54
+ import { ProxyRejectReason, warnProxyRejected } from "./proxy-diagnostic.js";
55
+ import { resolveProxyUser } from "./proxy-identity.js";
51
56
  import { AdmissionRefusedError } from "../../harness/identity/users.js";
52
57
  import { effectiveAdmin } from "../../harness/identity/groups.js";
58
+ import { deriveUsername } from "@sigrun/shared/usernames";
53
59
  /** One header read: Fastify headers may be string | string[]; first wins. */
54
60
  function headerOf(request, name) {
55
61
  const value = request.headers[name.toLowerCase()];
@@ -190,22 +196,6 @@ function isPublicPath(request, ctx) {
190
196
  }
191
197
  return false;
192
198
  }
193
- /**
194
- * The proxy-auth rejection diagnostic (release-readiness Task 1, spec §3):
195
- * ONE `warn` per rejected request, naming the socket peer, the request
196
- * method, a short reason, and — only when Fastify already resolved it —
197
- * the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
198
- * must never carry API tokens, cookies, identity-header VALUES, query
199
- * strings, or the full request URL; a header NAME appears only where the
200
- * reason itself is "that configured header was absent/wrong" (its value
201
- * never does). This is diagnostic logging, not evidence that the
202
- * configured allowlist is correct.
203
- */
204
- function warnProxyRejected(request, reason) {
205
- const peer = request.socket.remoteAddress ?? "unknown";
206
- const route = request.routeOptions.url;
207
- console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}`);
208
- }
209
199
  /**
210
200
  * Stage 5a (proxy): resolve the forward-auth headers, or reply with the
211
201
  * rejection. The secret header is DELETED from the request before any
@@ -229,7 +219,17 @@ async function resolveProxy(request, ctx) {
229
219
  ctx.methods.includes("token");
230
220
  if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
231
221
  if (!attemptingToken) {
232
- warnProxyRejected(request, "untrusted proxy");
222
+ warnProxyRejected(request, ProxyRejectReason.UNTRUSTED);
223
+ }
224
+ return { status: 401 };
225
+ }
226
+ // C5: an own-host peer (loopback or a local interface address) can
227
+ // originate from ANY local process, so trust through a range alone
228
+ // never vouches — the shared secret must be configured (its check
229
+ // then decides), or the exact address must be listed explicitly.
230
+ if (!ownHostPeerPermitted(request.socket.remoteAddress, proxy.trustedProxies, proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
231
+ if (!attemptingToken) {
232
+ warnProxyRejected(request, ProxyRejectReason.OWN_HOST);
233
233
  }
234
234
  return { status: 401 };
235
235
  }
@@ -239,7 +239,7 @@ async function resolveProxy(request, ctx) {
239
239
  delete request.headers[name];
240
240
  if (presented === undefined || !timingSafeStringEqual(presented, proxy.secret)) {
241
241
  if (!attemptingToken) {
242
- warnProxyRejected(request, `missing or wrong shared secret (${proxy.secretHeader} header)`);
242
+ warnProxyRejected(request, ProxyRejectReason.secretReason(proxy.secretHeader));
243
243
  }
244
244
  return { status: 401 };
245
245
  }
@@ -247,7 +247,7 @@ async function resolveProxy(request, ctx) {
247
247
  const user = headerOf(request, proxy.userHeader);
248
248
  if (user === undefined) {
249
249
  if (!attemptingToken) {
250
- warnProxyRejected(request, `missing identity header (${proxy.userHeader})`);
250
+ warnProxyRejected(request, ProxyRejectReason.missingHeaderReason(proxy.userHeader));
251
251
  }
252
252
  return { status: 401 };
253
253
  }
@@ -255,34 +255,29 @@ async function resolveProxy(request, ctx) {
255
255
  ?.split(",")
256
256
  .map((group) => group.trim())
257
257
  .filter((group) => group !== "") ?? [];
258
- const { users } = ctx.opened.identity;
259
- // The invalid-username case (a proxy header that can't be a directory
260
- // user) is a loud 403, not a 500: the proxy vouched for an identity
261
- // this server can never represent. The warn's reason never carries the
262
- // claimed value (spec §3: no identity-header values).
263
- if (!/^[a-z0-9][a-z0-9._-]*$/.test(user)) {
264
- warnProxyRejected(request, "unusable identity claim");
265
- return { status: 403, error: "invalid_username" };
266
- }
267
- const existing = users.get(user);
268
- if (existing === undefined && !proxy.autoProvision) {
269
- warnProxyRejected(request, "untrusted identity source");
270
- return { status: 403, error: "user_not_provisioned" };
271
- }
258
+ const { users, accounts } = ctx.opened.identity;
272
259
  let record;
273
260
  try {
274
- record = existing ??
275
- (await users.add({
276
- user,
277
- ...(headerOf(request, proxy.nameHeader) === undefined
278
- ? {}
279
- : { name: headerOf(request, proxy.nameHeader) }),
280
- ...(headerOf(request, proxy.emailHeader) === undefined
281
- ? {}
282
- : { email: headerOf(request, proxy.emailHeader) }),
283
- admin: false,
284
- source: "proxy",
285
- }));
261
+ const resolved = await resolveProxyUser({ users, accounts }, user, {
262
+ ...(headerOf(request, proxy.nameHeader) === undefined
263
+ ? {}
264
+ : { name: headerOf(request, proxy.nameHeader) }),
265
+ ...(headerOf(request, proxy.emailHeader) === undefined
266
+ ? {}
267
+ : { email: headerOf(request, proxy.emailHeader) }),
268
+ }, { provision: proxy.autoProvision });
269
+ // The invalid-username case is a loud 403, not a 500: the proxy
270
+ // vouched for an identity this server can never represent. The warn
271
+ // never carries the claimed value (spec §3).
272
+ if (resolved.kind === "invalid") {
273
+ warnProxyRejected(request, ProxyRejectReason.UNUSABLE);
274
+ return { status: 403, error: "invalid_username" };
275
+ }
276
+ if (resolved.kind === "not_provisioned") {
277
+ warnProxyRejected(request, ProxyRejectReason.UNTRUSTED_IDENTITY_SOURCE);
278
+ return { status: 403, error: "user_not_provisioned" };
279
+ }
280
+ record = resolved.record;
286
281
  }
287
282
  catch (error) {
288
283
  if (error instanceof AdmissionRefusedError) {
@@ -294,7 +289,7 @@ async function resolveProxy(request, ctx) {
294
289
  // setGroups avoids the write when the values are unchanged. Effective
295
290
  // admin is computed over the UPDATED record, so a group exit revokes
296
291
  // the request's grant immediately and host-side checks agree.
297
- record = await users.setGroups(user, "proxy", groups);
292
+ record = await users.setGroups(record.user, "proxy", groups);
298
293
  return {
299
294
  user: record.user,
300
295
  ...(record.name === undefined ? {} : { name: record.name }),
@@ -332,6 +327,18 @@ export function resolveToken(request, ctx) {
332
327
  }
333
328
  /** The ONE request hook. Stages 1→5 in brief order; a stage replies or falls through. */
334
329
  export function registerAuthPipeline(app, ctx) {
330
+ // C5 boot warning: an explicitly listed own-host trust entry WITHOUT
331
+ // a shared secret is the one shape where a local process can still
332
+ // impersonate users by connecting directly. Surface it once per
333
+ // entry, at registration (a boot-time config warning, not a request
334
+ // diagnostic — the throttled warnProxyRejected does not apply).
335
+ const { proxy } = ctx.config.auth;
336
+ if (ctx.methods.includes("proxy") &&
337
+ !(proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
338
+ for (const entry of listedOwnHostEntries(proxy.trustedProxies)) {
339
+ console.warn(`sigrun: trusting local address ${entry} without a shared secret lets any local process impersonate users; set server.auth.proxy.secretHeader`);
340
+ }
341
+ }
335
342
  app.addHook("onRequest", async (request, reply) => {
336
343
  // Stage 1: Host + Origin.
337
344
  const hostStatus = checkHost(request, ctx);
@@ -378,8 +385,17 @@ export function registerAuthPipeline(app, ctx) {
378
385
  // Stage 5/6: the configured methods, in order; the empty list is
379
386
  // the naive loopback path.
380
387
  if (ctx.methods.length === 0) {
388
+ const header = headerOf(request, "x-sigrun-user");
389
+ // The header is an unchecked (operator-typed) source: derive the
390
+ // canonical key on receipt, the same rule the CLI applies before
391
+ // sending. An unusable value is a loud 400, never a verbatim
392
+ // identity; an absent header keeps today's ownerless behavior.
393
+ const user = header === undefined ? undefined : deriveUsername(header);
394
+ if (header !== undefined && user === undefined) {
395
+ return await reply.code(400).send({ error: "invalid_username" });
396
+ }
381
397
  request.principal = {
382
- user: headerOf(request, "x-sigrun-user"),
398
+ user,
383
399
  admin: true,
384
400
  service: false,
385
401
  method: "none",
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The shared proxy-auth rejection diagnostic (proxy-trust C1), extracted
3
+ * from the request pipeline so the onboarding routes can report WHY they
4
+ * rejected a proxy identity with the same line and redaction contract.
5
+ *
6
+ * ONE `warn` per rejected request, naming the socket peer, the request
7
+ * method, a short reason, and — only when Fastify already resolved it —
8
+ * the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
9
+ * must never carry API tokens, cookies, identity-header VALUES, query
10
+ * strings, or the full request URL; a header NAME appears only where the
11
+ * reason itself is "that configured header was absent/wrong" (its value
12
+ * never does). This is diagnostic logging, not evidence that the
13
+ * configured allowlist is correct.
14
+ *
15
+ * The throttle is per reason: one line per reason per 60 s, with a
16
+ * `(N suppressed)` suffix on the next emitted line for that reason —
17
+ * a flood of identical rejections cannot drown the log, and a DIFFERENT
18
+ * reason always emits immediately.
19
+ */
20
+ /** One line per reason per this window; suppressed lines count up. */
21
+ const THROTTLE_MS = 60_000;
22
+ /** The reason vocabulary (byte-identical to the pipeline's originals). */
23
+ export const ProxyRejectReason = {
24
+ UNTRUSTED: "untrusted proxy",
25
+ UNUSABLE: "unusable identity claim",
26
+ UNTRUSTED_IDENTITY_SOURCE: "untrusted identity source",
27
+ OWN_HOST: "own-host peer without shared secret",
28
+ secretReason: (header) => `missing or wrong shared secret (${header} header)`,
29
+ missingHeaderReason: (header) => `missing identity header (${header})`,
30
+ };
31
+ const throttle = new Map();
32
+ let clock = () => Date.now();
33
+ /** Test seam: inject the clock the throttle reads. */
34
+ export function setProxyDiagnosticClock(now) {
35
+ clock = now;
36
+ }
37
+ /** Test seam: forget all throttle state (and restore the real clock). */
38
+ export function resetProxyDiagnostic() {
39
+ throttle.clear();
40
+ clock = () => Date.now();
41
+ }
42
+ /**
43
+ * Emit one sanitized rejection line, throttled per reason. The line
44
+ * carries no values by construction: it is built from the method, the
45
+ * socket peer, the caller-supplied reason (a fixed string, or one naming
46
+ * a configured header), and the route template.
47
+ */
48
+ export function warnProxyRejected(request, reason) {
49
+ const now = clock();
50
+ const entry = throttle.get(reason);
51
+ if (entry !== undefined && now - entry.lastEmit < THROTTLE_MS) {
52
+ entry.suppressed += 1;
53
+ return;
54
+ }
55
+ const suppressed = entry?.suppressed ?? 0;
56
+ throttle.set(reason, { lastEmit: now, suppressed: 0 });
57
+ const peer = request.socket.remoteAddress ?? "unknown";
58
+ const route = request.routeOptions.url;
59
+ console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}${suppressed > 0 ? ` (${suppressed} suppressed)` : ""}`);
60
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Proxy identity resolution (username-derivation D3): a trusted proxy
3
+ * header names an external subject, not necessarily a directory key.
4
+ * Folded subjects are linked to users; case variants of an existing key
5
+ * adopt it, and every other new identity gets a derived, suffixed key.
6
+ *
7
+ * The request pipeline owns trust and diagnostics. The claim route owns
8
+ * its lock; only the pipeline entry below acquires `withClaimLock`.
9
+ */
10
+ import { deriveUsername, foldSubject, isUsernameKey, withSuffix, } from "@sigrun/shared/usernames";
11
+ import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
12
+ import { withClaimLock } from "./claim-lock.js";
13
+ function identityParts(header) {
14
+ const subject = foldSubject(header);
15
+ const base = deriveUsername(header);
16
+ if (subject.length > 512 || base === undefined)
17
+ return undefined;
18
+ return { subject, base };
19
+ }
20
+ function linkedUser(deps, subject) {
21
+ const link = deps.accounts.lookup("proxy", subject);
22
+ return link === undefined ? undefined : deps.users.get(link.user);
23
+ }
24
+ function isAddConflict(error) {
25
+ return error instanceof Error && /^user ".*" already exists$/.test(error.message);
26
+ }
27
+ /** Lock-free resolution body; conflicts become a signal for the pipeline's retry. */
28
+ async function provisionAttempt(deps, header, profile, opts) {
29
+ const parts = identityParts(header);
30
+ if (parts === undefined)
31
+ return { kind: "invalid" };
32
+ const linked = linkedUser(deps, parts.subject);
33
+ if (linked !== undefined)
34
+ return { kind: "user", record: linked };
35
+ if (isUsernameKey(parts.subject)) {
36
+ const existing = deps.users.get(parts.subject);
37
+ if (existing !== undefined) {
38
+ try {
39
+ await deps.accounts.link({
40
+ provider: "proxy",
41
+ subject: parts.subject,
42
+ user: existing.user,
43
+ via: "proxy",
44
+ });
45
+ return { kind: "user", record: existing };
46
+ }
47
+ catch (error) {
48
+ if (error instanceof AccountLinkConflictError)
49
+ return { kind: "retry", error };
50
+ throw error;
51
+ }
52
+ }
53
+ }
54
+ if (!opts.provision)
55
+ return { kind: "not_provisioned" };
56
+ const key = withSuffix(parts.base, (candidate) => deps.users.get(candidate) !== undefined);
57
+ try {
58
+ const record = await deps.users.add({
59
+ user: key,
60
+ ...(profile.name === undefined ? {} : { name: profile.name }),
61
+ ...(profile.email === undefined ? {} : { email: profile.email }),
62
+ admin: opts.admin ?? false,
63
+ source: "proxy",
64
+ });
65
+ try {
66
+ await deps.accounts.link({
67
+ provider: "proxy",
68
+ subject: parts.subject,
69
+ user: record.user,
70
+ via: "proxy",
71
+ });
72
+ return { kind: "user", record };
73
+ }
74
+ catch (error) {
75
+ if (error instanceof AccountLinkConflictError) {
76
+ // Never leave the user created by this attempt attached to no
77
+ // identity when the link itself was refused.
78
+ await deps.users.remove(record.user);
79
+ return { kind: "retry", error };
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (isAddConflict(error))
86
+ return { kind: "retry", error: error };
87
+ throw error;
88
+ }
89
+ }
90
+ /** One lookup/lock pass. A retry is observed only AFTER the lock returns. */
91
+ async function resolveOnce(deps, header, profile, opts) {
92
+ const parts = identityParts(header);
93
+ if (parts === undefined)
94
+ return { kind: "invalid" };
95
+ const linked = linkedUser(deps, parts.subject);
96
+ if (linked !== undefined)
97
+ return { kind: "user", record: linked };
98
+ return await withClaimLock(() => provisionAttempt(deps, header, profile, opts));
99
+ }
100
+ /** Pipeline entry: lookup outside the lock; on a miss, provision under it. */
101
+ export async function resolveProxyUser(deps, header, profile, opts) {
102
+ const first = await resolveOnce(deps, header, profile, opts);
103
+ if (first.kind !== "retry")
104
+ return first;
105
+ // A second-process conflict retries resolution once, after the claim
106
+ // section has returned. A second conflict is a real failure, not a loop.
107
+ const second = await resolveOnce(deps, header, profile, opts);
108
+ if (second.kind === "retry")
109
+ throw second.error;
110
+ return second;
111
+ }
112
+ /** Lock-free body. Callers MUST already hold `withClaimLock`. */
113
+ export async function provisionProxyUser(deps, header, profile, opts) {
114
+ const result = await provisionAttempt(deps, header, profile, opts);
115
+ if (result.kind === "retry")
116
+ throw result.error;
117
+ return result;
118
+ }
119
+ /** Read-only key computation for an already-vouched header. */
120
+ export function previewProxyUser(deps, header) {
121
+ const parts = identityParts(header);
122
+ if (parts === undefined)
123
+ return undefined;
124
+ const linked = linkedUser(deps, parts.subject);
125
+ if (linked !== undefined)
126
+ return { user: linked.user, subject: header };
127
+ if (isUsernameKey(parts.subject) && deps.users.get(parts.subject) !== undefined) {
128
+ return { user: parts.subject, subject: header };
129
+ }
130
+ return {
131
+ user: withSuffix(parts.base, (candidate) => deps.users.get(candidate) !== undefined),
132
+ subject: header,
133
+ };
134
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The C2 vouch rule for account-creating public paths (proxy-trust
3
+ * onboarding, spec Task 4): a proxy-only server bound beyond loopback
4
+ * admits accounts ONLY through a trusted proxy vouch. On such a boot
5
+ * every unvouched account-creating public path — the onboarding claim
6
+ * (setup-code and invite branches alike) and the login code-resume —
7
+ * refuses with 403 `proxy_identity_required`, and boot never mints or
8
+ * logs a setup code (it would be an unvouched account-creation path).
9
+ *
10
+ * The rule is deliberately about the CONFIGURED posture, not the
11
+ * request: the login code-resume has no proxy identity at all, so under
12
+ * `requiresProxyVouch` it always refuses. Mixed `[proxy, token]`
13
+ * deployments keep the setup-code/invite paths (token callers exist),
14
+ * and loopback binds keep them too (loopback is its own trust).
15
+ */
16
+ import { isLoopbackHost } from "./loopback.js";
17
+ /** Whether the configured auth methods are exactly proxy-only. */
18
+ export function isProxyOnly(methods) {
19
+ return methods.includes("proxy") && !methods.includes("token");
20
+ }
21
+ /**
22
+ * Whether this boot requires a proxy vouch for account creation:
23
+ * proxy-only AND bound beyond loopback. The loopback test is the shared
24
+ * one (`127.0.0.0/8`, `::1`, `localhost`; `::ffff:127.*` unwrapped).
25
+ */
26
+ export function requiresProxyVouch(methods, listenHost) {
27
+ return isProxyOnly(methods) && !isLoopbackHost(listenHost);
28
+ }
@@ -1,9 +1,85 @@
1
+ import { deriveUsername, withSuffix } from "@sigrun/shared/usernames";
1
2
  import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
2
3
  import { AdmissionRefusedError, } from "../../harness/identity/users.js";
3
4
  import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
4
5
  import { hashToken } from "../../harness/identity/tokens.js";
5
6
  import { withClaimLock } from "../auth/claim-lock.js";
6
- import { assignUsername } from "./usernames.js";
7
+ /** The first usable profile claim wins; the external subject remains exact. */
8
+ export function loginUsernameCandidate(profile, subject) {
9
+ for (const candidate of [profile.username, profile.email, subject]) {
10
+ if (candidate === undefined)
11
+ continue;
12
+ const derived = deriveUsername(candidate);
13
+ if (derived !== undefined)
14
+ return derived;
15
+ }
16
+ return "user";
17
+ }
18
+ function isAddConflict(error) {
19
+ return error instanceof Error && /^user ".*" already exists$/.test(error.message);
20
+ }
21
+ function isLinkConflict(error) {
22
+ return error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:"));
23
+ }
24
+ async function sessionForLinkedUser(input, deps) {
25
+ const existing = deps.accounts.lookup(input.provider, input.result.subject);
26
+ if (deps.users.get(existing.user) === undefined) {
27
+ return { kind: "refused", error: "not_allowed" };
28
+ }
29
+ await deps.users.setGroups(existing.user, input.provider, input.result.groups ?? []);
30
+ return { kind: "session", user: existing.user };
31
+ }
32
+ /** Lock-free provisioning body; add/link conflicts become retry signals. */
33
+ async function provisionLogin(input, deps) {
34
+ if (deps.accounts.lookup(input.provider, input.result.subject) !== undefined) {
35
+ return sessionForLinkedUser(input, deps);
36
+ }
37
+ // Admission is checked before redemption so an M1 refusal burns no invite.
38
+ deps.users.checkAdmission();
39
+ const base = loginUsernameCandidate(input.result.profile, input.result.subject);
40
+ const autoProvision = deps.policy.autoProvision === true;
41
+ const user = autoProvision
42
+ ? withSuffix(base, (name) => deps.users.get(name) !== undefined)
43
+ : base;
44
+ if (!autoProvision && deps.users.get(user) !== undefined) {
45
+ return { kind: "refused", error: "not_allowed" };
46
+ }
47
+ const grant = autoProvision
48
+ ? { admin: false }
49
+ : await deps.invites.redeem(input.code, user);
50
+ if (grant === undefined)
51
+ return { kind: "refused", error: "not_allowed" };
52
+ try {
53
+ const record = await deps.users.add({
54
+ user,
55
+ ...profileFields(input.result.profile),
56
+ admin: grant.admin,
57
+ source: "login",
58
+ });
59
+ try {
60
+ await deps.accounts.link({
61
+ provider: input.provider,
62
+ subject: input.result.subject,
63
+ user,
64
+ via: "login",
65
+ });
66
+ }
67
+ catch (error) {
68
+ if (isLinkConflict(error)) {
69
+ await deps.users.remove(record.user);
70
+ return { kind: "retry", error: error };
71
+ }
72
+ throw error;
73
+ }
74
+ await deps.users.setGroups(user, input.provider, input.result.groups ?? []);
75
+ return { kind: "session", user: record.user };
76
+ }
77
+ catch (error) {
78
+ if (isAddConflict(error))
79
+ return { kind: "retry", error: error };
80
+ throw error;
81
+ }
82
+ }
7
83
  function allowed(result, policy) {
8
84
  if (policy.allowedGroups !== undefined &&
9
85
  !policy.allowedGroups.some((group) => result.groups?.includes(group) === true))
@@ -42,13 +118,8 @@ export async function resolveLogin(input, deps) {
42
118
  return { kind: "linked", user: input.linkUser };
43
119
  }
44
120
  const existing = deps.accounts.lookup(provider, result.subject);
45
- if (existing !== undefined) {
46
- if (deps.users.get(existing.user) === undefined) {
47
- return { kind: "refused", error: "not_allowed" };
48
- }
49
- await deps.users.setGroups(existing.user, provider, result.groups ?? []);
50
- return { kind: "session", user: existing.user };
51
- }
121
+ if (existing !== undefined)
122
+ return sessionForLinkedUser(input, deps);
52
123
  const setupResolution = await withClaimLock(async () => {
53
124
  const validSetupCode = input.code !== undefined && setupCodeMatches(input.code, deps.setupCode());
54
125
  if (deps.users.isClaimed()) {
@@ -69,7 +140,7 @@ export async function resolveLogin(input, deps) {
69
140
  if (!validSetupCode)
70
141
  return { kind: "needs_code", reason: "setup" };
71
142
  const claim = deps.setupClaims.matches(provider, result.subject, input.code);
72
- const user = claim?.user ?? assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
143
+ const user = claim?.user ?? withSuffix(loginUsernameCandidate(result.profile, result.subject), (name) => deps.users.get(name) !== undefined);
73
144
  // Persist the recovery breadcrumb before creating the user. On a
74
145
  // retry, only this exact subject may finish the claim; if the user
75
146
  // write did not happen, it is retried under the recorded name.
@@ -99,23 +170,9 @@ export async function resolveLogin(input, deps) {
99
170
  if (deps.policy.autoProvision !== true && input.code === undefined) {
100
171
  return { kind: "needs_code", reason: "invite" };
101
172
  }
102
- // Admission is checked before redemption so an M1 refusal burns no invite.
103
- deps.users.checkAdmission();
104
- const user = assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
105
- const grant = deps.policy.autoProvision === true
106
- ? { admin: false }
107
- : await deps.invites.redeem(input.code, user);
108
- if (grant === undefined)
109
- return { kind: "refused", error: "not_allowed" };
110
- const record = await deps.users.add({
111
- user,
112
- ...profileFields(result.profile),
113
- admin: grant.admin,
114
- source: "login",
115
- });
116
- await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
117
- await deps.users.setGroups(user, provider, result.groups ?? []);
118
- return { kind: "session", user: record.user };
173
+ const provisioned = await withClaimLock(() => provisionLogin(input, deps));
174
+ if (provisioned.kind !== "retry")
175
+ return provisioned;
119
176
  }
120
177
  catch (error) {
121
178
  if (error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:")))
@@ -130,4 +187,9 @@ export async function resolveLogin(input, deps) {
130
187
  }
131
188
  throw error;
132
189
  }
190
+ // Conflicts from another writer retry once, only after the section returns.
191
+ const second = await withClaimLock(() => provisionLogin(input, deps));
192
+ if (second.kind === "retry")
193
+ throw second.error;
194
+ return second;
133
195
  }