Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions hypaware-core/plugins-workspace/central/src/identity_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} <token>\` 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} <token>\` 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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<string, unknown>} */ (parsed)
if (typeof jwt !== 'string' || jwt.length === 0) {
throw new Error(`persisted identity ${filePath}: missing or invalid jwt`)
Expand All @@ -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
}

Expand All @@ -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)
}

Expand Down
9 changes: 9 additions & 0 deletions hypaware-core/plugins-workspace/central/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down
44 changes: 41 additions & 3 deletions hypaware-core/smoke/flows/remote_oidc_login.js
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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).
*
Expand Down Expand Up @@ -59,14 +67,38 @@ 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)
expect.that('login: session stored as kind oidc', afterLogin.prod?.kind, (v) => v === 'oidc')
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)
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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
Expand Down
Loading