diff --git a/hypaware-core/plugins-workspace/central/src/identity_client.js b/hypaware-core/plugins-workspace/central/src/identity_client.js index 0e8c84c4..254a6e53 100644 --- a/hypaware-core/plugins-workspace/central/src/identity_client.js +++ b/hypaware-core/plugins-workspace/central/src/identity_client.js @@ -100,8 +100,13 @@ export class IdentityClient { // operator must re-run `hyp join` against the new server. // @ref LLP 0031#physical-layout [implements]: a re-point with no token cannot safely reuse the old identity, so loading is refused if (persisted.central_url !== undefined && persisted.central_url !== this.centralUrl) { + // A login-seeded identity re-enrolls with a fresh login, not a join + // token (LLP 0061 D3): point the operator at the seam that minted it. + const remedy = persisted.origin === 'login' + ? 'Re-run `hyp remote login` against the new server to enroll this host' + : `Run \`hyp join ${this.centralUrl} \` to enroll this host with the new server` throw new Error( - `identity central URL mismatch: persisted identity was minted by ${persisted.central_url} but the configured central server is ${this.centralUrl}. Run \`hyp join ${this.centralUrl} \` to enroll this host with the new server` + `identity central URL mismatch: persisted identity was minted by ${persisted.central_url} but the configured central server is ${this.centralUrl}. ${remedy}` ) } this.identity = persisted @@ -201,6 +206,7 @@ export class IdentityClient { // persisted identity rather than recomputing. identity.central_url = this.identity.central_url identity.bootstrap_token_fp = this.identity.bootstrap_token_fp + if (this.identity.origin !== undefined) identity.origin = this.identity.origin this.identity = identity writePersistedFile(this.persistedPath, identity) } @@ -249,7 +255,7 @@ function readPersistedFile(filePath) { if (!isPlainObject(parsed)) { throw new Error(`persisted identity ${filePath} must be an object`) } - const { jwt, expires_at, gateway_id, central_url, bootstrap_token_fp } = + const { jwt, expires_at, gateway_id, central_url, bootstrap_token_fp, origin } = /** @type {Record} */ (parsed) if (typeof jwt !== 'string' || jwt.length === 0) { throw new Error(`persisted identity ${filePath}: missing or invalid jwt`) @@ -264,6 +270,7 @@ function readPersistedFile(filePath) { const identity = { jwt, expires_at, gateway_id } if (typeof central_url === 'string') identity.central_url = central_url if (typeof bootstrap_token_fp === 'string') identity.bootstrap_token_fp = bootstrap_token_fp + if (origin === 'login') identity.origin = origin return identity } @@ -283,6 +290,14 @@ function mintChanged(persisted, centralUrl, bootstrapToken) { if (persisted.central_url !== undefined && persisted.central_url !== centralUrl) { return true } + // A login-seeded identity was minted by a human login, not by any bootstrap + // token, so its missing token fingerprint is not a mint mismatch. With the + // URL matching (above), a configured bootstrap token coexists with the login + // seed rather than re-bootstrapping over it on every daemon start. + // @ref LLP 0061#d3 [implements]: the origin marker keeps the re-enrollment guard from reading a login seed as a swapped bootstrap token + if (persisted.origin === 'login') { + return false + } return persisted.bootstrap_token_fp !== fingerprintToken(bootstrapToken) } diff --git a/hypaware-core/plugins-workspace/central/src/types.d.ts b/hypaware-core/plugins-workspace/central/src/types.d.ts index 0eee5825..d39e47d0 100644 --- a/hypaware-core/plugins-workspace/central/src/types.d.ts +++ b/hypaware-core/plugins-workspace/central/src/types.d.ts @@ -34,6 +34,15 @@ export interface PersistedIdentity { * gateway identity against a new tenant/server. */ bootstrap_token_fp?: string + /** + * Present when this identity was seeded by `hyp remote login` (a + * login-minted gateway, LLP 0061 D3) rather than a bootstrap-token + * mint. No bootstrap token was involved, so the re-enrollment guard + * must not read the missing `bootstrap_token_fp` as a mint mismatch; + * only a `central_url` change counts. Absent on bootstrap-minted + * identities. + */ + origin?: 'login' } /** Request body for `POST /v1/identity/bootstrap`. */ diff --git a/hypaware-core/smoke/flows/remote_oidc_login.js b/hypaware-core/smoke/flows/remote_oidc_login.js index 78f257a4..f99aa647 100644 --- a/hypaware-core/smoke/flows/remote_oidc_login.js +++ b/hypaware-core/smoke/flows/remote_oidc_login.js @@ -1,14 +1,18 @@ // @ts-check +import fsp from 'node:fs/promises' import http from 'node:http' +import path from 'node:path' import process from 'node:process' +import { IdentityClient } from '../../plugins-workspace/central/src/identity_client.js' import { installObservability } from '../../../src/core/observability/index.js' import { loginWithBrowser } from '../../../src/core/remote/oidc_login.js' import { readCredentials, writeSession, } from '../../../src/core/remote/credentials.js' +import { seedLoginGateway } from '../../../src/core/remote/gateway_seed.js' import { querySqlVerb } from '../../../src/core/query/verb.js' import { verbToCommand } from '../../../src/core/cli/verb_command.js' @@ -32,6 +36,10 @@ import { verbToCommand } from '../../../src/core/cli/verb_command.js' * silent refresh + persist -> a revoked refresh row drives the re-login * message. * + * The same login also mints a gateway credential (LLP 0061): the flow seeds + * it into a configured central sink and proves the sink's own IdentityClient + * loads it with no bootstrap token. + * * Asserts both the user-visible result and the `smoke_step` telemetry the * remote-oidc modules emit (Log-Driven Development). * @@ -59,7 +67,7 @@ export async function run({ harness, expect }) { fetch(url).catch(() => {}) return true } - const session = await loginWithBrowser({ identityBase, org: 'acme', openBrowser }) + const session = await loginWithBrowser({ identityBase, org: 'acme', host: 'smoke-host', openBrowser }) await writeSession(stateDir, 'prod', session) const afterLogin = await readCredentials(stateDir) @@ -67,6 +75,30 @@ export async function run({ harness, expect }) { expect.that('login: resolved org is acme', session.org, (v) => v === 'acme') expect.that('login: a refresh token was issued', session.refreshToken, (v) => typeof v === 'string' && v.length > 0) + // ----- smoke_step: gateway_seed ----- + // The same login minted a gateway credential (LLP 0061 D1); seed it into a + // configured central sink and prove the sink's own IdentityClient loads it + // with no bootstrap token (D2/D3). The query record must not carry it (D1). + // @ref LLP 0061#d2 [tests]: login seed is loaded by the sink's unchanged acquire() path, end to end + expect.that('gateway: credential captured on login', session.gateway?.gatewayId, (v) => v === 'gw-smoke') + expect.that('gateway: host label sent with the exchange', server.state.lastHost, (v) => v === 'smoke-host') + expect.that('gateway: jwt kept out of the query record', JSON.stringify(afterLogin.prod), (s) => !s.includes('gw-jwt-smoke')) + const smokeConfigPath = path.join(String(process.env.HYP_HOME), 'smoke-config.json') + await fsp.writeFile(smokeConfigPath, JSON.stringify({ + version: 2, + sinks: { fwd: { plugin: '@hypaware/central', config: { url: origin, identity: {} } } }, + })) + const seeded = await seedLoginGateway({ + stateDir, + configPath: smokeConfigPath, + targetUrl: mcpUrl, + gateway: /** @type {any} */ (session.gateway), + }) + expect.that('gateway: the origin-matched sink was seeded', seeded.map((s) => s.sink).join(','), (v) => v === 'fwd') + const sinkIdentity = new IdentityClient({ centralUrl: origin, persistedPath: seeded[0].persistedPath }) + expect.that('gateway: sink loads the seed without a bootstrap token', await sinkIdentity.acquire(), (v) => v === 'loaded') + expect.that('gateway: sink presents the login-minted jwt', await sinkIdentity.getCurrentJwt(), (v) => v === 'gw-jwt-smoke') + // ----- smoke_step: attach_query ----- const cmd = verbToCommand(querySqlVerb) const first = runQuery(mcpUrl) @@ -159,7 +191,7 @@ async function forceExpiry(stateDir, target) { * @returns {Promise<{ port: number, state: any, close: (cb: () => void) => void, closeAllConnections: () => void }>} */ function startStubServer() { - const state = { jwtSeq: 0, validJwt: '', refreshToken: 'rt-smoke', refreshRevoked: false, refreshCalls: 0 } + const state = { jwtSeq: 0, validJwt: '', refreshToken: 'rt-smoke', refreshRevoked: false, refreshCalls: 0, lastHost: '' } const mint = () => { state.jwtSeq += 1 state.validJwt = `jwt-${state.jwtSeq}` @@ -191,7 +223,13 @@ function startStubServer() { readBody(req).then((body) => { const grant = body.grant_type if (grant === 'authorization_code') { - return json({ session_id: 'sess-1', refresh_token: state.refreshToken, access_jwt: mint(), expires_at: FUTURE, org: 'acme' }) + state.lastHost = typeof body.host === 'string' ? body.host : '' + // A login-configured server also mints a gateway credential on the + // same response (LLP 0061); the refresh grant never carries it. + return json({ + session_id: 'sess-1', refresh_token: state.refreshToken, access_jwt: mint(), expires_at: FUTURE, org: 'acme', + gateway_jwt: 'gw-jwt-smoke', gateway_expires_at: FUTURE, gateway_id: 'gw-smoke', + }) } if (grant === 'refresh_token') { state.refreshCalls += 1 diff --git a/llp/0061-login-minted-gateway-client.decision.md b/llp/0061-login-minted-gateway-client.decision.md new file mode 100644 index 00000000..b30981ec --- /dev/null +++ b/llp/0061-login-minted-gateway-client.decision.md @@ -0,0 +1,217 @@ +# LLP 0061: Login-minted gateway credential on the client + +**Type:** Decision +**Status:** Implemented +**Systems:** CLI, Onboarding, Sinks, Gateway +**Author:** Kenny / Claude +**Date:** 2026-07-01 +**Related:** LLP 0033, LLP 0049, LLP 0058, LLP 0031 + +> The server side of login-minted gateway enrollment already shipped: +> hypaware-server now provisions a gateway on a successful human login and returns +> its credential (`gateway_jwt` / `gateway_expires_at` / `gateway_id`) on the same +> `POST /v1/identity/token` response. This is the client half: teaching `hyp remote +> login` to capture that credential and seed it where the `central` forward sink's +> identity client reads it, so a logged-in user can forward logs with no +> out-of-band bootstrap-token distribution. These are the client-local forks; the +> architecture is decided in the server repo. + +## Summary + +This is the **client half of chunk 4** (login-minted gateway enrollment). The +architecture was decided server-side and lives in the hypaware-server repo: +`../hypaware-server/llp/0020-login-minted-gateway.decision.md` (the decision), +`0021-...design.md`, and `0022-...plan.md` (the shipped server plan). That work +added `enrollLoginGateway`, minted a gateway JWT in the `authorization_code` +grant, and stamped the gateway registry with `origin: 'login'`. + +This document does **not** re-decide that architecture. It records only the +**client-local** choices. Everything cross-cutting (why the server owns `org`, why +a login mints an ordinary registry row so the ingest path needs no new code, why +the host label is advisory) defers up to server LLP 0020. Server LLP 0020 D7 names +the client obligation; this doc resolves how the client meets it. + +## Context: what exists today + +The client already has the two credential stores this chunk bridges; it extends +them rather than inventing a third. + +- **Query-scoped store** (`src/core/remote/credentials.js`): the `0600` + `remote-credentials.json`, per-target, discriminated by `kind` (LLP 0058 D4). A + browser login writes an `oidc` record `{ refreshToken, accessJwt, expiresAt, org }` + via `writeSession` / `commitSession`. This is the **human query** credential. +- **Forward-scoped identity** (`hypaware-core/plugins-workspace/central/src/identity_client.js`): + the `central` sink's `IdentityClient` holds a gateway JWT and manages its full + lifecycle. `acquire()` loads a persisted `identity.json` (`{ jwt, expires_at, + gateway_id, central_url, bootstrap_token_fp? }`) or, if none exists, `bootstrap()`s + with a configured `bootstrap_token`. `doRefresh()` re-mints via `POST + /v1/identity/refresh` (bearer = the gateway JWT), and `sink.js` `postNdjson()` + drives the 401 → refresh → retry loop. This is the **forwarder** credential. +- **The login flow** (`src/core/cli/remote_commands.js` → `oidc_login.js` → + `identity_client.js` `exchangeCode()`): today `exchangeCode()` reads only + `refresh_token` / `access_jwt` / `expires_at` / `org` from the token response and + returns an `OidcSession`. The new `gateway_*` fields land unread. +- **Net-new for this chunk:** nothing on the client captures the gateway credential, + and nothing bridges a login into the forward-scoped identity store. + +## Decisions + +### D1: One login mints two credentials with two scopes and two stores + + +A single `hyp remote login ` yields two independent credentials: the +**human session** (refresh token + access JWT → the query path) and the +**login-minted gateway** (`gateway_jwt` / `gateway_expires_at` / `gateway_id` → the +forward path). They differ in scope and lifetime and must not be conflated. + +`exchangeCode()` captures the three `gateway_*` fields and carries them on +`OidcSession`, but they are **not** written into the query `oidc` record. The +query record stays exactly `{ kind, refreshToken, accessJwt, expiresAt, org }` (LLP +0058 D4); the gateway fields route to the forward store (D2). Keeping them out of +the query record preserves the LLP 0033 stakes argument: what lands in +`remote-credentials.json` is query-scoped only. The refresh grant +(`grant_type=refresh_token`) never carries `gateway_*` (the server mints a gateway +only on `authorization_code`), so `refreshSession()` is unchanged. + +### D2: The gateway credential seeds the central sink's persisted identity; the forward path is unchanged + + +The `central` sink already loads and refreshes a persisted gateway identity. A +login-seeded credential is simply that `identity.json` pre-populated: `{ jwt: +gateway_jwt, expires_at: gateway_expires_at, gateway_id, central_url, origin: +'login' }`. With the file present, `acquire()` finds a persisted identity, skips +`bootstrap()`, and the existing `doRefresh()` / 401-retry path carries it unchanged +- because a login-minted gateway is an ordinary registry row that refreshes through +the same `POST /v1/identity/refresh` a bootstrap-minted gateway uses (server LLP +0020 D2). + +**No new forward code, no gateway-specific refresh grant.** The single change on the +forward side is teaching the seed-writer to produce this file; `IdentityClient` and +`sink.js` are untouched except for widening the persisted shape. + +### D3: A login seed takes precedence over bootstrap; the bootstrap seam stays for zero-touch + + +`acquire()` bootstraps only when no persisted identity exists. Seeding the file at +login means the sink never needs a `bootstrap_token` configured: the logged-in user +is the enrollment. The `bootstrap_token` path (LLP 0033 / server LLP 0008) is +**retained unchanged** for the unattended / MDM / device-cert case that has no human +to trigger a login (server LLP 0020 D7). Login-seeded and bootstrap-seeded +identities coexist and are indistinguishable to `doRefresh()`; an `origin` marker on +the persisted record distinguishes them for the re-enrollment guard (D4) and for +diagnostics. + +### D4: The seed carries `central_url` provenance so the existing re-point guards apply + + +`acquire()`'s re-enrollment and cross-tenant guards (LLP 0031) key on `central_url` +and `bootstrap_token_fp`. A login seed **must** stamp `central_url` so that +re-pointing the sink at a different server is refused exactly as it is for a +bootstrap-minted identity: reusing the old gateway JWT against a new server would +file this server's data under the other server's `gateway_id`. A login re-seed +against the **same** central URL overwrites the persisted file; the server dedups to +the same `gateway_id` (server LLP 0020 D6), so re-login is idempotent on identity and +only advances the expiry. A login seed must not silently clobber a bootstrap identity +minted by a different token or URL - it goes through the same mint-changed path, not +around it. + +### D5: `hyp remote login` writes the seed to the sink's persisted path, resolved from the target + + +The login command and the sink run under the same `HYP_HOME`, so the login command +resolves the target's forward-identity path (the sink's `persistedPath`) and writes +the seed there directly, under the same atomic-write discipline the sink uses. This +keeps the credential where `acquire()` already reads it (server LLP 0020 D7: "writes +the returned gateway JWT where the central forward sink's identity client reads it") +rather than inventing a handoff. The exact path derivation is a design-level detail +(see open questions). + +### D6: The host label defaults to the machine hostname, overridable + + +Resolving the server's open question (server LLP 0020): `hyp remote login` sends +`host: os.hostname()` in the token exchange by default, overridable with an explicit +`--host