From f8a382a9b56053d9998a2f22e642a41a1963a511 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 30 Oct 2026 16:47:22 -0700 Subject: [PATCH 1/3] feat(tokens): sign a canonical meta.machineId on History assertions POST /v1/tokens/relayhistory-assertion now takes a machineId and signs it as meta.machineId, so ingest can bind a courier's token to the machine (the manifest key's m= component) on manifest POST and status. - Canonical form: 1-128 chars of [A-Za-z0-9._-], no leading '.', no relay_ alias prefix, exact bytes. Anything needing trimming, case folding or alias stripping is refused (400 invalid_machine_id), never rewritten. null, "" and non-strings are malformed, not missing. - Missing machineId: 400 machineId_required, unless the default-off compat flag RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID is exactly "true" (chart: relayhistory.allowMissingMachineId). Such tokens have no meta.machineId and their audit row is flagged machineIdMissing: "true". - The audit row records machineId. - Trust: RelayAuth validates form only and signs what the holder of the dedicated relayauth:assertion:create:relayhistory key (Cloud's broker) asserts after its own authorization, as for orgId/workspaceId/ sponsorId. The route still requires that key over x-api-key (bearer refused), so couriers and other callers cannot choose it. Documented in docs/self-hosting.md. - Not widened: org/workspace/sponsor handling is unchanged. The known cross-org property of this route (orgId/sponsorId from the body) is left as is; a separate hardening is proposed in the PR. Rollout: required by default, so the broker must send machineId before this deploys, or the compat flag must be on for the window. Review: adversarial pass (no new caller can mint, malformed/alias/ whitespace/unicode/oversize values refused under both flag states, flag enables only on exact "true", injected body meta/org fields ignored, claims org/wks/sponsor unchanged) and contextual pass (existing tests, selfhost smoke script, Helm chart, env wiring, docs updated). Validation (local; hosted CI runs on PRs to main): - relayhistory-assertion tests: 114/114 (27 new) - npm test (turbo 16/16): server 714/714, sdk 169/169, ai 70/70, core 19/19, migrate 18/18, relayauth 12/12, types 1/1, root 8/8 - server test:e2e: 40 pass, 0 fail, 93 skipped - turbo typecheck --force 11/11, turbo build --force 9/9 - helm template renders the env only with allowMissingMachineId=true; bash -n scripts/selfhost-smoke.sh OK Co-Authored-By: Claude Opus 5.5 --- charts/relayauth/templates/deployment.yaml | 4 + charts/relayauth/values.yaml | 5 + docs/self-hosting.md | 31 +++ .../selfhost-keys-issuer-bootstrap.test.ts | 1 + packages/server/src/__tests__/test-helpers.ts | 1 + .../server/src/__tests__/tokens-route.test.ts | 211 ++++++++++++++++++ packages/server/src/env.ts | 8 + packages/server/src/routes/tokens.ts | 75 +++++++ packages/server/src/server.ts | 3 + scripts/selfhost-smoke.sh | 2 +- 10 files changed, 340 insertions(+), 1 deletion(-) diff --git a/charts/relayauth/templates/deployment.yaml b/charts/relayauth/templates/deployment.yaml index 875cdcc0..a9e5041c 100644 --- a/charts/relayauth/templates/deployment.yaml +++ b/charts/relayauth/templates/deployment.yaml @@ -64,6 +64,10 @@ spec: - name: RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN value: "true" {{- end }} + {{- if (.Values.relayhistory).allowMissingMachineId }} + - name: RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID + value: "true" + {{- end }} {{- with .Values.allowedOrigins }} - name: ALLOWED_ORIGINS value: {{ . | quote }} diff --git a/charts/relayauth/values.yaml b/charts/relayauth/values.yaml index b7689a07..23883b1c 100644 --- a/charts/relayauth/values.yaml +++ b/charts/relayauth/values.yaml @@ -35,6 +35,11 @@ relayhistory: # http://relayhistory.relayhistory.svc:8080. Public names/IPs still need # https. Sets RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN=true. allowPrivateHttpOrigin: false + # Compat only, keep false: let History assertions mint without a machineId + # (no meta.machineId; audit flags machineIdMissing). For the rollout window + # before the control plane sends machineId. Sets + # RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID=true. + allowMissingMachineId: false # Pre-created Secret holding keys/secrets (never rendered by this chart). # Each entry under secretKeys names a key inside that Secret; set an optional diff --git a/docs/self-hosting.md b/docs/self-hosting.md index dff37d59..1efbbff5 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -26,6 +26,7 @@ its `_FILE` variant. `_FILE` handling lives only in the Node entrypoint | `RELAYAUTH_BOOTSTRAP_API_KEY_ORG` | no | Org for a newly created bootstrap key (default `org_selfhost`). An existing key is never moved; a warning is logged if its org differs. | | `ALLOWED_ORIGINS` | no | Comma-separated CORS allowlist. | | `RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN` | no | Exactly `true` lets History assertions bind to a plain-`http` `historyOrigin` on a private host (see below). Any other value, or unset, keeps the hosted rule. Chart: `relayhistory.allowPrivateHttpOrigin`. | +| `RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID` | no | Compat only. Exactly `true` lets History assertions mint without `machineId` (see "Machine binding" below). Any other value, or unset, requires it. Chart: `relayhistory.allowMissingMachineId`. | ### Keys and `kid` @@ -85,6 +86,36 @@ before it becomes the `aud`. `https` rules are unchanged. The flag widens only which http hosts are accepted. It does not make a public http origin acceptable, so turn it on only where History is reached over a trusted private network. +### Machine binding (`meta.machineId`) + +Every History assertion carries the courier machine it was minted for as +`meta.machineId`, so ingest can check that a manifest POST or status call comes +from the machine named in the manifest key's `m=` component. The request body +field is `machineId`: + +- 1 to 128 characters of `A-Za-z0-9._-`, the same form as the `m=` component; +- no leading `.`, and no `relay_` prefix (machine ids have no RelayAuth alias); +- exact bytes: values that would need trimming, case folding or prefix + stripping are refused (400 `invalid_machine_id`), never rewritten. + +A missing `machineId` is refused with 400 `machineId_required`. Only for the +rollout window before the control plane sends it, set +`RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID=true`; tokens minted +that way have no `meta.machineId` and their audit row carries +`machineIdMissing: "true"`. Turn it off again once the control plane sends +`machineId`. + +**Who is trusted to assert it.** RelayAuth validates the form only. It cannot +know which machine a courier runs on, so it signs the `machineId` that the +holder of the dedicated `relayauth:assertion:create:relayhistory` API key sends +(the control plane / Cloud's broker), exactly as it already signs that caller's +`orgId`, `workspaceId` and `sponsorId`. That caller must establish the machine +from its own authenticated session with the courier before asking, and must +never pass through a value the courier chose without that check. The route +accepts only that dedicated key over `x-api-key` (a bearer token carrying the +scope is refused with `assertion_key_required`), so couriers and other callers +cannot choose a `machineId`. Never hand the assertion key to a courier. + ## Docker Release images are published for Linux amd64 and arm64 at diff --git a/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts b/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts index 0dc533d1..5a6c9882 100644 --- a/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts +++ b/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts @@ -205,6 +205,7 @@ test("bootstrap api key is seeded idempotently, hash-only, and mints History ass orgId: "org_a", workspaceId: "ws_a", sponsorId: "user_a", + machineId: "machine-a", scopes: ["rth:sync"], expiresIn: 60, historyOrigin: "http://127.0.0.1:18780", diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index b61f157f..78262455 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -49,6 +49,7 @@ const PASS_THROUGH_TEST_BINDINGS = [ "RELAYAUTH_STORAGE_CAPACITY_CRITICAL_RATIO", "RELAYAUTH_STORAGE_CAPACITY_SHED_RATIO", "RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN", + "RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID", "RELAYAUTH_ISSUER", "BASE_URL", ] as const satisfies readonly (keyof TestBindings)[]; diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 3f10eb08..b05d54b4 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -35,6 +35,7 @@ import { seedStoredIdentity, seedWorkspaceContext, TEST_RS256_PRIVATE_KEY_PEM, + TEST_RS256_PUBLIC_KEY_PEM, } from "./test-helpers.js"; type JwtHeader = { @@ -2263,6 +2264,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:sync", "rth:read"], expiresIn: 45, }, @@ -2307,6 +2309,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { requestedOrgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), + machineId: "machine-a", }); assert.equal( await countStoredTokens(app), @@ -2338,6 +2341,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { actorOrgId: "org_tokens_route", sponsorId: "user_cloud_login", grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), + machineId: "machine-a", }); }, ); @@ -2361,6 +2365,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], }, headers: { "x-api-key": assertionKey.key }, @@ -2391,6 +2396,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], }, headers: authHeaders, @@ -2422,6 +2428,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read", "rth:admin"], }, headers: { @@ -2455,6 +2462,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], expiresIn: 61, }, @@ -2486,6 +2494,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], historyOrigin, }, @@ -2706,6 +2715,208 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { }); }); +function verifyRs256Token(token: string): boolean { + const [header, payload, signature] = token.split("."); + return crypto.verify( + "RSA-SHA256", + Buffer.from(`${header}.${payload}`), + TEST_RS256_PUBLIC_KEY_PEM, + Buffer.from(signature ?? "", "base64url"), + ); +} + +test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => { + const assertionScope = "relayauth:assertion:create:relayhistory"; + const compatOn = { + RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID: "true", + }; + const baseBody = { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:sync"], + }; + + async function harness(bindings?: Parameters[0]) { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: ["relayauth:api-key:manage:*", assertionScope], + }, + bindings, + }); + const assertionKey = await issueApiKey(app, authHeaders, [assertionScope]); + const mint = (body: Record, headers?: HeadersInit) => + requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { + body, + headers: headers ?? { "x-api-key": assertionKey.key }, + }); + return { app, authHeaders, assertionKey, mint }; + } + + async function lastAssertionAudit(app: ReturnType) { + const row = await app.storage.DB.prepare( + `SELECT metadata_json FROM audit_logs + WHERE resource = 'relayhistory-assertion' + ORDER BY timestamp DESC LIMIT 1`, + ).first<{ metadata_json: string }>(); + return JSON.parse(row?.metadata_json ?? "{}") as Record; + } + + for (const machineId of [ + "machine-a", + "M", + "unknown-origin-42", + "host.example_01", + "a".repeat(128), + "019a1f3c-8e2b-7c41-9d3a-5b6f0e2a7c18", + ]) { + await t.test(`signs a canonical machineId (${machineId.slice(0, 24)})`, async () => { + const { app, mint } = await harness(); + const body = await assertJsonResponse( + await mint({ ...baseBody, machineId }), + 201, + ); + assert.equal(verifyRs256Token(body.accessToken), true); + const claims = decodeJwtJsonSegment(body.accessToken, 1); + assert.equal(claims.meta?.machineId, machineId); + assert.equal((await lastAssertionAudit(app)).machineId, machineId); + }); + } + + for (const machineId of [ + "", + " machine-a", + "machine-a ", + "machine a", + "machine/a", + "m=machine-a", + ".hidden", + "..", + ".", + "relay_machine-a", + "a".repeat(129), + "machine-\u00e4", + "machine-a\n", + null, + 42, + true, + ["machine-a"], + { id: "machine-a" }, + ]) { + await t.test(`refuses a malformed machineId (${JSON.stringify(machineId)})`, async () => { + for (const bindings of [undefined, compatOn]) { + const { app, mint } = await harness(bindings); + await assertJsonResponse( + await mint({ ...baseBody, machineId }), + 400, + (body) => { + assert.equal(body.code, "invalid_machine_id"); + }, + ); + assert.equal(await countStoredTokens(app), 0); + } + }); + } + + await t.test("refuses a missing machineId by default", async () => { + for (const value of [undefined, "", "1", "TRUE", "yes", "false"]) { + const { app, mint } = await harness( + value === undefined + ? undefined + : { RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID: value }, + ); + await assertJsonResponse(await mint(baseBody), 400, (body) => { + assert.equal(body.code, "machineId_required"); + }); + assert.equal(await countStoredTokens(app), 0); + } + }); + + await t.test("allows a missing machineId only behind the exact compat flag", async () => { + const { app, mint } = await harness(compatOn); + const body = await assertJsonResponse( + await mint(baseBody), + 201, + ); + const claims = decodeJwtJsonSegment(body.accessToken, 1); + assert.equal(claims.meta?.machineId, undefined); + assert.equal("machineId" in (claims.meta ?? {}), false); + const audit = await lastAssertionAudit(app); + assert.equal(audit.machineIdMissing, "true"); + assert.equal(audit.machineId, undefined); + + // With the flag on, a supplied machineId is still validated and signed. + const withMachine = await assertJsonResponse( + await mint({ ...baseBody, machineId: "machine-a" }), + 201, + ); + assert.equal( + decodeJwtJsonSegment(withMachine.accessToken, 1).meta?.machineId, + "machine-a", + ); + }); + + await t.test("machineId does not widen who may mint or for which org", async () => { + const { app, authHeaders, assertionKey, mint } = await harness(); + // The claim is only ever minted through the dedicated assertion key. + await assertJsonResponse( + await mint({ ...baseBody, machineId: "machine-a" }, authHeaders), + 403, + (body) => { + assert.equal(body.code, "assertion_key_required"); + }, + ); + const otherKey = await issueApiKey(app, authHeaders, ["relayauth:api-key:manage:*"]); + const wrongScope = await mint( + { ...baseBody, machineId: "machine-a" }, + { "x-api-key": otherKey.key }, + ); + assert.equal(wrongScope.status, 403); + + // A machine id never stands in for, or rewrites, org/workspace/sponsor. + for (const [field, code] of [ + ["orgId", "orgId_required"], + ["workspaceId", "workspaceId_required"], + ["sponsorId", "sponsorId_required"], + ] as const) { + const { [field]: _omitted, ...partial } = baseBody; + await assertJsonResponse( + await mint({ ...partial, machineId: "org_relayhistory_target" }), + 400, + (body) => { + assert.equal(body.code, code); + }, + ); + } + const body = await assertJsonResponse( + await mint({ + ...baseBody, + machineId: "org_other", + meta: { machineId: "injected", org: "org_other" }, + org: "org_other", + }), + 201, + ); + const claims = decodeJwtJsonSegment(body.accessToken, 1); + assert.equal(claims.org, baseBody.orgId); + assert.equal(claims.wks, baseBody.workspaceId); + assert.equal(claims.sponsorId, baseBody.sponsorId); + assert.equal(claims.meta?.requestedOrgId, baseBody.orgId); + assert.equal(claims.meta?.actorOrgId, "org_tokens_route"); + assert.equal(claims.meta?.actorId, "api_key:" + assertionKey.apiKey.id); + assert.equal(claims.meta?.machineId, "org_other"); + assert.deepEqual(Object.keys(claims.meta ?? {}).sort(), [ + "actorId", + "actorOrgId", + "grantedScopes", + "machineId", + "requestedOrgId", + "tokenClass", + "workspaceId", + ]); + }); +}); + test("POST /v1/tokens/refresh", async (t) => { await t.test( "refreshes a RS256 token pair without requiring a bearer token", diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index 84e90321..aad7131f 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -33,6 +33,14 @@ export type AppConfig = { * keeps the hosted rule: https, or http only on loopback. */ RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN?: string; + /** + * Compat opt-in, default off: exactly "true" lets + * `POST /v1/tokens/relayhistory-assertion` mint without `machineId` (the + * token then has no `meta.machineId` and its audit row is flagged + * `machineIdMissing`). Unset/anything else requires a canonical machineId. + * Intended only for the rollout window before the broker sends machineId. + */ + RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID?: string; RELAYAUTH_ENV_STAGE?: string; /** JSON object keyed by org id. See SponsorFederationConfig. */ RELAYAUTH_SPONSOR_FEDERATIONS?: string; diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index fa7619be..6a6f4b81 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -101,6 +101,17 @@ type RelayhistoryAssertionRequest = { * History. Omitted: the legacy shared `relayhistory` audience. */ historyOrigin?: unknown; + /** + * Canonical id of the courier machine (the writer) the assertion is for, + * signed as `meta.machineId` so ingest can bind the token to the manifest + * key's `m=` component. Same form as that component: 1 to 128 characters of + * `A-Za-z0-9._-`, no leading `.`, no `relay_` alias prefix, exact bytes (no + * trimming or case folding). RelayAuth checks the form only: the caller (the + * holder of the dedicated assertion key, i.e. Cloud's broker) is trusted to + * have authorized the machine before asking. Required unless + * RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID=true. + */ + machineId?: unknown; }; type RevokeTokenRequest = { @@ -210,6 +221,9 @@ const RELAYHISTORY_ASSERTION_SCOPE = "relayauth:assertion:create:relayhistory"; const RELAYHISTORY_ASSERTION_AUDIENCE = "relayhistory"; const MAX_RELAYHISTORY_ASSERTION_TTL_SECONDS = 60; const RELAYHISTORY_ASSERTION_SCOPES = ["rth:read", "rth:sync"] as const; +/** Mirrors the manifest key's `m=` component ([A-Za-z0-9._-]{1,128}). */ +const RELAYHISTORY_MACHINE_ID_SHAPE = /^[A-Za-z0-9._-]{1,128}$/u; +const RELAYHISTORY_MACHINE_ID_ALIAS_PREFIX = "relay_"; tokens.post("/", async (c) => { const auth = await authenticateAndAuthorizeFromContext( @@ -755,6 +769,9 @@ tokens.post("/relayhistory-assertion", async (c) => { const request = normalizeRelayhistoryAssertionRequest(body, { allowPrivateHttpOrigin: c.env.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN === "true", + }, { + allowMissingMachineId: + c.env.RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID === "true", }); if (!request.ok) { return c.json({ error: request.error, code: request.code }, request.status); @@ -768,6 +785,7 @@ tokens.post("/relayhistory-assertion", async (c) => { scopes: request.scopes, expiresIn: request.expiresIn, historyOrigin: request.historyOrigin, + machineId: request.machineId, actorId: auth.claims.sub, actorOrgId: auth.claims.org, }); @@ -1385,6 +1403,7 @@ async function issueRelayhistoryAssertion( scopes: string[]; expiresIn: number; historyOrigin?: string; + machineId?: string; actorId: string; actorOrgId: string; }, @@ -1418,6 +1437,7 @@ async function issueRelayhistoryAssertion( ...(options.historyOrigin ? { historyOrigin: options.historyOrigin } : {}), + ...(options.machineId ? { machineId: options.machineId } : {}), }, }; @@ -1432,6 +1452,7 @@ async function issueRelayhistoryAssertion( sponsorId: options.sponsorId, tokenId: claims.jti, scopes: options.scopes, + machineId: options.machineId, }), }); @@ -1529,6 +1550,7 @@ function createAssertionAuditEntry(options: { sponsorId: string; tokenId: string; scopes: string[]; + machineId?: string; }): AuditLogWriteEntry { return { id: crypto.randomUUID(), @@ -1544,6 +1566,10 @@ function createAssertionAuditEntry(options: { actorOrgId: options.actorOrgId, sponsorId: options.sponsorId, grantedScopes: JSON.stringify(options.scopes), + // A compat-flag mint without a machine is flagged so it stays reviewable. + ...(options.machineId + ? { machineId: options.machineId } + : { machineIdMissing: "true" }), }, timestamp: new Date().toISOString(), }; @@ -1935,6 +1961,7 @@ function normalizeExpiresIn(value: unknown): number { function normalizeRelayhistoryAssertionRequest( value: RelayhistoryAssertionRequest, originPolicy: RelayhistoryOriginPolicy = {}, + machinePolicy: { allowMissingMachineId?: boolean } = {}, ): | { ok: true; @@ -1944,6 +1971,7 @@ function normalizeRelayhistoryAssertionRequest( scopes: string[]; expiresIn: number; historyOrigin?: string; + machineId?: string; } | { ok: false; error: string; code: string; status: 400 } { const orgId = normalizeOptionalString(value.orgId); @@ -2009,6 +2037,11 @@ function normalizeRelayhistoryAssertionRequest( }; } + const machineId = normalizeRelayhistoryMachineId(value.machineId, machinePolicy); + if (!machineId.ok) { + return machineId; + } + return { ok: true, orgId, @@ -2017,9 +2050,51 @@ function normalizeRelayhistoryAssertionRequest( scopes: scopes.scopes, expiresIn: expiresIn.expiresIn, ...(historyOrigin.origin ? { historyOrigin: historyOrigin.origin } : {}), + ...(machineId.machineId ? { machineId: machineId.machineId } : {}), }; } +/** + * Canonical machine id: exactly the manifest key's `m=` component form. The + * value is taken byte for byte; anything that would need trimming, case + * folding or alias stripping is refused rather than rewritten, so one machine + * has exactly one signed spelling. Only an absent field counts as missing; + * `null`, `""` and non-strings are malformed. + */ +export function normalizeRelayhistoryMachineId( + value: unknown, + policy: { allowMissingMachineId?: boolean } = {}, +): + | { ok: true; machineId?: string } + | { ok: false; error: string; code: string; status: 400 } { + if (value === undefined) { + if (policy.allowMissingMachineId) { + return { ok: true }; + } + return { + ok: false, + error: "machineId is required", + code: "machineId_required", + status: 400, + }; + } + if ( + typeof value !== "string" || + !RELAYHISTORY_MACHINE_ID_SHAPE.test(value) || + value.startsWith(".") || + value.startsWith(RELAYHISTORY_MACHINE_ID_ALIAS_PREFIX) + ) { + return { + ok: false, + error: + "machineId must be 1 to 128 characters of A-Za-z0-9._-, not starting with '.' or 'relay_'", + code: "invalid_machine_id", + status: 400, + }; + } + return { ok: true, machineId: value }; +} + /** * Raw shape of a bare origin: `scheme://authority` with at most one trailing * slash. The authority may not contain userinfo (`@`), percent escapes, a diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index a8f1d436..e2fc9ca8 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -525,6 +525,9 @@ export async function startServer(options: StartServerOptions = {}) { RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN: options.config?.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN ?? process.env.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN, + RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID: + options.config?.RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID + ?? process.env.RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID, RELAYAUTH_ENV_STAGE: options.config?.RELAYAUTH_ENV_STAGE ?? process.env.RELAYAUTH_ENV_STAGE, RELAYAUTH_SPONSOR_FEDERATIONS: options.config?.RELAYAUTH_SPONSOR_FEDERATIONS ?? process.env.RELAYAUTH_SPONSOR_FEDERATIONS, diff --git a/scripts/selfhost-smoke.sh b/scripts/selfhost-smoke.sh index 1df14cae..6206112e 100755 --- a/scripts/selfhost-smoke.sh +++ b/scripts/selfhost-smoke.sh @@ -118,7 +118,7 @@ post_assertion() { -X POST "$BASE_URL/v1/tokens/relayhistory-assertion" \ -H 'content-type: application/json' \ -H "x-api-key: $(tr -d '\n' < "$SECRETS/bootstrap-api-key")" \ - --data "{\"orgId\":\"org_smoke\",\"workspaceId\":\"ws_smoke\",\"sponsorId\":\"user_smoke\",\"scopes\":[\"rth:sync\",\"rth:read\"],\"expiresIn\":60,\"historyOrigin\":\"$1\"}" + --data "{\"orgId\":\"org_smoke\",\"workspaceId\":\"ws_smoke\",\"sponsorId\":\"user_smoke\",\"machineId\":\"machine-smoke\",\"scopes\":[\"rth:sync\",\"rth:read\"],\"expiresIn\":60,\"historyOrigin\":\"$1\"}" } # mint_assertion [origin] -> prints the access token From 2dd88a12cf8f15d0efb6880111527e20722bdd6c Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 31 Oct 2026 00:27:21 -0700 Subject: [PATCH 2/3] fix(tokens): keep machine binding consumer-enforced --- charts/relayauth/templates/deployment.yaml | 4 -- charts/relayauth/values.yaml | 5 -- docs/self-hosting.md | 20 ++++---- packages/server/src/__tests__/test-helpers.ts | 1 - .../server/src/__tests__/tokens-route.test.ts | 46 ++++++------------- packages/server/src/env.ts | 8 ---- packages/server/src/routes/tokens.ts | 22 ++------- packages/server/src/server.ts | 3 -- 8 files changed, 27 insertions(+), 82 deletions(-) diff --git a/charts/relayauth/templates/deployment.yaml b/charts/relayauth/templates/deployment.yaml index 414f8ea2..ae50aa37 100644 --- a/charts/relayauth/templates/deployment.yaml +++ b/charts/relayauth/templates/deployment.yaml @@ -64,10 +64,6 @@ spec: - name: RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN value: "true" {{- end }} - {{- if (.Values.relayhistory).allowMissingMachineId }} - - name: RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID - value: "true" - {{- end }} {{- if (.Values.sessionDirectory).issuance }} - name: RELAYAUTH_SESSION_DIRECTORY_ISSUANCE value: "true" diff --git a/charts/relayauth/values.yaml b/charts/relayauth/values.yaml index 88b5273f..287d9cbd 100644 --- a/charts/relayauth/values.yaml +++ b/charts/relayauth/values.yaml @@ -35,11 +35,6 @@ relayhistory: # http://relayhistory.relayhistory.svc:8080. Public names/IPs still need # https. Sets RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN=true. allowPrivateHttpOrigin: false - # Compat only, keep false: let History assertions mint without a machineId - # (no meta.machineId; audit flags machineIdMissing). For the rollout window - # before the control plane sends machineId. Sets - # RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID=true. - allowMissingMachineId: false # Second issuer for session directory search tokens. Off by default. Enabling it # needs an issuer that differs from the general one and diff --git a/docs/self-hosting.md b/docs/self-hosting.md index b05519c9..49c263a6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -26,7 +26,6 @@ its `_FILE` variant. `_FILE` handling lives only in the Node entrypoint | `RELAYAUTH_BOOTSTRAP_API_KEY_ORG` | no | Org for a newly created bootstrap key (default `org_selfhost`). An existing key is never moved; a warning is logged if its org differs. | | `ALLOWED_ORIGINS` | no | Comma-separated CORS allowlist. | | `RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN` | no | Exactly `true` lets History assertions bind to a plain-`http` `historyOrigin` on a private host (see below). Any other value, or unset, keeps the hosted rule. Chart: `relayhistory.allowPrivateHttpOrigin`. | -| `RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID` | no | Compat only. Exactly `true` lets History assertions mint without `machineId` (see "Machine binding" below). Any other value, or unset, requires it. Chart: `relayhistory.allowMissingMachineId`. | ### Keys and `kid` @@ -88,22 +87,21 @@ so turn it on only where History is reached over a trusted private network. ### Machine binding (`meta.machineId`) -Every History assertion carries the courier machine it was minted for as -`meta.machineId`, so ingest can check that a manifest POST or status call comes -from the machine named in the manifest key's `m=` component. The request body -field is `machineId`: +History assertions for courier writes carry the machine they were minted for +as `meta.machineId`, so ingest can check that a manifest POST or status call +comes from the machine named in the manifest key's `m=` component. Read-only +and control-plane History paths may omit the request body field because they do +not represent a courier machine. When `machineId` is supplied it is: - 1 to 128 characters of `A-Za-z0-9._-`, the same form as the `m=` component; - no leading `.`, and no `relay_` prefix (machine ids have no RelayAuth alias); - exact bytes: values that would need trimming, case folding or prefix stripping are refused (400 `invalid_machine_id`), never rewritten. -A missing `machineId` is refused with 400 `machineId_required`. Only for the -rollout window before the control plane sends it, set -`RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID=true`; tokens minted -that way have no `meta.machineId` and their audit row carries -`machineIdMissing: "true"`. Turn it off again once the control plane sends -`machineId`. +An omitted `machineId` produces no `meta.machineId` claim and the audit row is +marked `machineIdMissing: "true"`. Consumers of machine-bound operations must +require the claim and match it to their own authenticated machine context; +RelayAuth cannot infer that context for read-only or control-plane callers. **Who is trusted to assert it.** RelayAuth validates the form only. It cannot know which machine a courier runs on, so it signs the `machineId` that the diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 5d7ad401..233a2211 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -57,7 +57,6 @@ const PASS_THROUGH_TEST_BINDINGS = [ "RELAYAUTH_STORAGE_CAPACITY_CRITICAL_RATIO", "RELAYAUTH_STORAGE_CAPACITY_SHED_RATIO", "RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN", - "RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID", "RELAYAUTH_ISSUER", "RELAYAUTH_SESSION_DIRECTORY_ISSUANCE", "RELAYAUTH_SESSION_DIRECTORY_ISSUER", diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 33b64514..973365c3 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -2916,9 +2916,6 @@ function verifyRs256Token(token: string): boolean { test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => { const assertionScope = "relayauth:assertion:create:relayhistory"; - const compatOn = { - RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID: "true", - }; const baseBody = { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", @@ -2926,12 +2923,11 @@ test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => { scopes: ["rth:sync"], }; - async function harness(bindings?: Parameters[0]) { + async function harness() { const { app, authHeaders } = await createHarness({ authClaims: { scopes: ["relayauth:api-key:manage:*", assertionScope], }, - bindings, }); // Broker keys are provisioned out of band. Current main deliberately // refuses bearer/admin callers that try to delegate this literal scope. @@ -2999,36 +2995,20 @@ test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => { { id: "machine-a" }, ]) { await t.test(`refuses a malformed machineId (${JSON.stringify(machineId)})`, async () => { - for (const bindings of [undefined, compatOn]) { - const { app, mint } = await harness(bindings); - await assertJsonResponse( - await mint({ ...baseBody, machineId }), - 400, - (body) => { - assert.equal(body.code, "invalid_machine_id"); - }, - ); - assert.equal(await countStoredTokens(app), 0); - } - }); - } - - await t.test("refuses a missing machineId by default", async () => { - for (const value of [undefined, "", "1", "TRUE", "yes", "false"]) { - const { app, mint } = await harness( - value === undefined - ? undefined - : { RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID: value }, + const { app, mint } = await harness(); + await assertJsonResponse( + await mint({ ...baseBody, machineId }), + 400, + (body) => { + assert.equal(body.code, "invalid_machine_id"); + }, ); - await assertJsonResponse(await mint(baseBody), 400, (body) => { - assert.equal(body.code, "machineId_required"); - }); assert.equal(await countStoredTokens(app), 0); - } - }); + }); + } - await t.test("allows a missing machineId only behind the exact compat flag", async () => { - const { app, mint } = await harness(compatOn); + await t.test("allows and audits an omitted machineId for non-machine paths", async () => { + const { app, mint } = await harness(); const body = await assertJsonResponse( await mint(baseBody), 201, @@ -3040,7 +3020,7 @@ test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => { assert.equal(audit.machineIdMissing, "true"); assert.equal(audit.machineId, undefined); - // With the flag on, a supplied machineId is still validated and signed. + // A supplied machineId is still validated and signed exactly. const withMachine = await assertJsonResponse( await mint({ ...baseBody, machineId: "machine-a" }), 201, diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index 0f666907..9a5fe732 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -48,14 +48,6 @@ export type AppConfig = { * keeps the hosted rule: https, or http only on loopback. */ RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN?: string; - /** - * Compat opt-in, default off: exactly "true" lets - * `POST /v1/tokens/relayhistory-assertion` mint without `machineId` (the - * token then has no `meta.machineId` and its audit row is flagged - * `machineIdMissing`). Unset/anything else requires a canonical machineId. - * Intended only for the rollout window before the broker sends machineId. - */ - RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID?: string; RELAYAUTH_ENV_STAGE?: string; /** * "true" serves the unauthenticated /v1/observer live event stream (a local diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 152bdac9..5711c74f 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -126,8 +126,9 @@ type RelayhistoryAssertionRequest = { * `A-Za-z0-9._-`, no leading `.`, no `relay_` alias prefix, exact bytes (no * trimming or case folding). RelayAuth checks the form only: the caller (the * holder of the dedicated assertion key, i.e. Cloud's broker) is trusted to - * have authorized the machine before asking. Required unless - * RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID=true. + * have authorized the machine before asking. Optional because read-only and + * control-plane History paths do not represent a courier machine; consumers + * of machine-bound writes must require and match the signed claim. */ machineId?: unknown; }; @@ -813,9 +814,6 @@ tokens.post("/relayhistory-assertion", async (c) => { const request = normalizeRelayhistoryAssertionRequest(body, { allowPrivateHttpOrigin: c.env.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN === "true", - }, { - allowMissingMachineId: - c.env.RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID === "true", }); if (!request.ok) { return c.json({ error: request.error, code: request.code }, request.status); @@ -2146,7 +2144,6 @@ function normalizeExpiresIn(value: unknown): number { function normalizeRelayhistoryAssertionRequest( value: RelayhistoryAssertionRequest, originPolicy: RelayhistoryOriginPolicy = {}, - machinePolicy: { allowMissingMachineId?: boolean } = {}, ): | { ok: true; @@ -2222,7 +2219,7 @@ function normalizeRelayhistoryAssertionRequest( }; } - const machineId = normalizeRelayhistoryMachineId(value.machineId, machinePolicy); + const machineId = normalizeRelayhistoryMachineId(value.machineId); if (!machineId.ok) { return machineId; } @@ -2248,20 +2245,11 @@ function normalizeRelayhistoryAssertionRequest( */ export function normalizeRelayhistoryMachineId( value: unknown, - policy: { allowMissingMachineId?: boolean } = {}, ): | { ok: true; machineId?: string } | { ok: false; error: string; code: string; status: 400 } { if (value === undefined) { - if (policy.allowMissingMachineId) { - return { ok: true }; - } - return { - ok: false, - error: "machineId is required", - code: "machineId_required", - status: 400, - }; + return { ok: true }; } if ( typeof value !== "string" || diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 44f688cf..46fd954c 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -551,9 +551,6 @@ export async function startServer(options: StartServerOptions = {}) { RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN: options.config?.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN ?? process.env.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN, - RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID: - options.config?.RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID - ?? process.env.RELAYAUTH_ALLOW_RELAYHISTORY_ASSERTION_WITHOUT_MACHINE_ID, RELAYAUTH_ENV_STAGE: options.config?.RELAYAUTH_ENV_STAGE ?? process.env.RELAYAUTH_ENV_STAGE, RELAYAUTH_OBSERVER_ENABLED: options.config?.RELAYAUTH_OBSERVER_ENABLED ?? process.env.RELAYAUTH_OBSERVER_ENABLED, From 92a8da72d1a28bed7fbb61ca328e86e42ccb5ff2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 31 Oct 2026 00:29:16 -0700 Subject: [PATCH 3/3] docs(tokens): clarify missing machine audit signal --- packages/server/src/routes/tokens.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 5711c74f..98d24579 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -1749,7 +1749,8 @@ function createAssertionAuditEntry(options: { actorOrgId: options.actorOrgId, sponsorId: options.sponsorId, grantedScopes: JSON.stringify(options.scopes), - // A compat-flag mint without a machine is flagged so it stays reviewable. + // Keep omission explicit for review; correlate with grantedScopes and + // the consumer operation because non-machine paths legitimately omit it. ...(options.machineId ? { machineId: options.machineId } : { machineIdMissing: "true" }),