Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
a7064ea
LLP 0046-0048: multi-tenant OIDC login on the client (design docs)
platypii Jun 29, 2026
0cc12cc
remote/oidc: PKCE + ephemeral loopback receiver (T1, T2) (#197)
platypii Jun 29, 2026
c4c4ca4
remote/oidc: identity client + browser opener + login orchestrator (T…
platypii Jun 29, 2026
44d88fc
remote/oidc: discriminated credential record + session-aware resolve …
platypii Jun 29, 2026
b25785c
remote/oidc: browser mode for hyp remote login (T6) (#200)
platypii Jun 29, 2026
197ce4a
remote/oidc: silent refresh + one-shot 401 retry on the attach path (…
platypii Jun 29, 2026
a83a3ec
remote/oidc: hermetic smoke + LLP follow-ups (T8) (#202)
platypii Jun 29, 2026
8910de1
remote/oidc: open the browser via rundll32 on win32
platypii Jun 29, 2026
eca56c2
remote/oidc: refresh OIDC sessions on the stdio proxy path
platypii Jun 29, 2026
841cce9
remote/oidc: tolerate a refresh response that omits org
platypii Jun 29, 2026
044b3d7
remote/oidc: point empty-stdin login at --browser; unify arg parsing
platypii Jun 29, 2026
8a1a4fa
remote/oidc: use Node's native base64url in PKCE
platypii Jun 29, 2026
03fba6b
mcp: retry oidc refresh after notification auth failures
platypii Jun 29, 2026
6586709
Fix no-browser remote login review issues
platypii Jun 29, 2026
a5689c9
remote/oidc: harden loopback against a malformed request target
platypii Jun 29, 2026
1a3f04b
remote/oidc: don't silently discard a piped token under --no-browser
platypii Jun 29, 2026
5f492b6
remote/oidc: point a failed browser login at the headless escape hatches
platypii Jun 29, 2026
6d61bf7
remote/oidc: persist a rotated refresh token from the refresh grant
platypii Jun 29, 2026
093ec1f
remote/oidc: preserve sibling credential records the writer can't nor…
platypii Jun 29, 2026
b14ce3e
mcp/proxy: make the startup credential probe a non-refreshing presenc…
platypii Jun 29, 2026
a4c3323
mcp/proxy: surface a failed forced refresh, not a bare HTTP 401
platypii Jun 29, 2026
b70e02d
remote/oidc: give the refresh-retry policy primitives one home
platypii Jun 29, 2026
42fedbf
remote/oidc: cache the parsed credential file between reads
platypii Jun 29, 2026
ede2969
remote/oidc: map invalid_grant by error code, not HTTP 401
platypii Jun 29, 2026
cad0c81
remote: lift the defensive safeText reader into one shared helper
platypii Jun 29, 2026
eb61be8
remote/oidc: declare the fs.Stats type via @import, not inline
platypii Jun 29, 2026
1511615
mcp: give the 401 refresh-and-retry policy one home
platypii Jun 29, 2026
89350da
remote/oidc: sanitize the OAuth error code from the loopback callback
platypii Jun 29, 2026
11b13a6
remote/oidc: tolerate a concurrent refresh-token rotation
platypii Jun 29, 2026
4e1c4a9
remote/login: drop the redundant !tokenFile guard
platypii Jun 29, 2026
8d4d572
mcp: share isAuthStatus instead of inlining the 401/403 check
platypii Jun 29, 2026
2e5737b
remote/oidc: address review findings on the login + attach paths
platypii Jun 29, 2026
af03983
remote/oidc: keep a refreshable session whose cached JWT is gone
platypii Jun 30, 2026
43f8938
remote/oidc: bound the /token request with a timeout
platypii Jun 30, 2026
6ccf8fe
mcp/proxy: do not advise re-login for an env/static credential 401
platypii Jun 30, 2026
a887c51
mcp/remote-verb: guard a missing global fetch up front
platypii Jun 30, 2026
24b136c
remote/loopback: close the callback socket so login does not linger
platypii Jun 30, 2026
1887932
remote/oidc: phrase the browser-open line as an attempt
platypii Jun 30, 2026
d079a06
smoke/remote-oidc: force expiry through the cache-busting write path
platypii Jun 30, 2026
dae52f8
remote/loopback: send Connection: close on stray 404/400 replies
platypii Jun 30, 2026
e7b1a61
remote/credentials: serialize writes with a lock and harden the refre…
platypii Jun 30, 2026
c0dd7fa
remote/attach: unify dead-credential and no-fetch guidance across ver…
platypii Jun 30, 2026
6482d06
remote/identity: treat a 401 or non-JSON refresh failure as session-e…
platypii Jun 30, 2026
d8de797
remote/login: --no-browser always selects the browser flow
platypii Jun 30, 2026
2cb0cc3
remote/credentials: serialize oidc refresh with a compare-and-swap co…
platypii Jun 30, 2026
ae39cb5
remote/loopback: surface a provider error before the state check
platypii Jun 30, 2026
5515127
remote/commands: guard static-login and remove against the new lock t…
platypii Jun 30, 2026
5662d2f
remote/identity: treat an empty 2xx token body as transient, not miss…
platypii Jun 30, 2026
82b5fe0
remote/credentials: drop an empty static token on read
platypii Jun 30, 2026
da6aabd
mcp: share one MCP request-header builder across client and proxy
platypii Jun 30, 2026
7390eed
remote/credentials: don't resurrect a session removed during refresh
platypii Jun 30, 2026
4cb8898
remote/identity: don't borrow refresh wording on an authorization_cod…
platypii Jun 30, 2026
5ed5eef
remote/login: don't blame the browser flow when only the session writ…
platypii Jun 30, 2026
d2a3c4b
remote/credentials: name the target in the static-token rejection gui…
platypii Jun 30, 2026
5ae4185
remote/credentials: make OIDC refresh single-flight under a real lock
platypii Jun 30, 2026
43727a4
remote/loopback: ignore non-matching callbacks instead of aborting th…
platypii Jun 30, 2026
7cccc26
remote/login: accept --org=/--token-file= equals form
platypii Jun 30, 2026
fe1922a
remote/credentials: replace the liveness-probe lock with an age-stale…
platypii Jun 30, 2026
20688e8
remote/credentials: re-validate the refresh decision against disk und…
platypii Jun 30, 2026
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
258 changes: 258 additions & 0 deletions hypaware-core/smoke/flows/remote_oidc_login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// @ts-check

import http from 'node:http'
import process from 'node:process'

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 { querySqlVerb } from '../../../src/core/query/verb.js'
import { verbToCommand } from '../../../src/core/cli/verb_command.js'

/**
* @import { AddressInfo } from 'node:net'
* @import { IncomingMessage } from 'node:http'
*/

/**
* Hermetic smoke for the multi-tenant OIDC client login (LLP 0046-0048). One
* in-process server plays both roles against a single origin:
*
* - the identity surface `<origin>/v1/identity/{login/start,token}` (signs
* real per-call tokens), and
* - the MCP endpoint `<origin>/mcp` (accepts only the current access JWT).
*
* The flow drives the full chunk-2 path in a temp HYP_HOME:
*
* browser login (scripted opener -> loopback redirect) -> session stored as
* kind: 'oidc' -> query attaches the access JWT -> a forced expiry drives a
* silent refresh + persist -> a revoked refresh row drives the re-login
* message.
*
* Asserts both the user-visible result and the `smoke_step` telemetry the
* remote-oidc modules emit (Log-Driven Development).
*
* @param {{ harness: any, expect: any }} args
* @ref LLP 0046#d5 [tests]: silent refresh + re-login on the attach path, end to end against a stub identity server
*/
export async function run({ harness, expect }) {
const obs = installObservability()
if (!obs.tracer.provider) {
throw new Error('remote_oidc_login: tracer provider not installed - expected HYP_DEV_TELEMETRY=1')
}

const server = await startStubServer()
const origin = `http://127.0.0.1:${server.port}`
const mcpUrl = `${origin}/mcp`
const identityBase = `${origin}/v1/identity`
const stateDir = harness.stateDir

try {
// ----- smoke_step: browser_login -----
// A scripted opener: instead of launching a browser, GET the start URL.
// The stub 302s to the loopback redirect_uri with a code, which the real
// loopback receiver catches.
const openBrowser = (/** @type {string} */ url) => {
fetch(url).catch(() => {})
return true
}
const session = await loginWithBrowser({ identityBase, org: 'acme', 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: attach_query -----
const cmd = verbToCommand(querySqlVerb)
const first = runQuery(mcpUrl)
let code = await cmd.run(['SELECT 1', '--remote', 'prod', '--format', 'json'], first.ctx)
expect.that('attach: query exits 0 with the access JWT', code, (v) => v === 0)
expect.that('attach: rows returned', first.out.join(''), (s) => s.includes('"n": 1') || s.includes('"n":1'))
expect.that('attach: no refresh needed on a fresh session', server.state.refreshCalls, (v) => v === 0)

// ----- smoke_step: silent_refresh -----
// Force the stored access JWT to look expired; the next query must refresh.
await forceExpiry(stateDir, 'prod')
const second = runQuery(mcpUrl)
code = await cmd.run(['SELECT 1', '--remote', 'prod', '--format', 'json'], second.ctx)
expect.that('refresh: query still exits 0 after silent refresh', code, (v) => v === 0)
expect.that('refresh: exactly one refresh happened', server.state.refreshCalls, (v) => v === 1)
const afterRefresh = await readCredentials(stateDir)
expect.that('refresh: a new access JWT was persisted', afterRefresh.prod?.kind === 'oidc' && /** @type {any} */ (afterRefresh.prod).accessJwt !== /** @type {any} */ (afterLogin.prod).accessJwt, (v) => v === true)

// ----- smoke_step: revoked_refresh -----
// Revoke the refresh row and force expiry again: the attach path must
// surface the re-login guidance, not a generic error.
server.state.refreshRevoked = true
await forceExpiry(stateDir, 'prod')
const third = runQuery(mcpUrl)
code = await cmd.run(['SELECT 1', '--remote', 'prod', '--format', 'json'], third.ctx)
expect.that('revoked: query exits nonzero', code, (v) => v !== 0)
expect.that('revoked: re-login guidance surfaced', third.err.join(''), (s) => /re-run 'hyp remote login prod'/.test(s))
} finally {
// Force-close keep-alive sockets so close() does not wait on idle timeouts,
// then flush telemetry even if an assertion above threw.
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
await new Promise((resolve) => server.close(() => resolve(undefined)))
await obs.shutdown()
}

// ----- telemetry: the remote-oidc path emitted its smoke_step markers -----
const logs = await expect.logs()
const oidcLogs = logs.filter((l) => l.attributes?.hyp_component === 'remote-oidc')
expect.that('telemetry: remote-oidc logs were emitted', oidcLogs, (rows) => rows.length > 0)
const steps = new Set(oidcLogs.map((l) => l.attributes?.smoke_step).filter(Boolean))
expect.that('telemetry: login_complete step present', steps.has('login_complete'), (v) => v === true)
expect.that('telemetry: loopback_bind step present', steps.has('loopback_bind'), (v) => v === true)
}

/**
* Build a ctx for the query verb against `mcpUrl`, with captured streams and a
* configured `prod` target. The credential resolves from HYP_HOME's state dir.
*
* @param {string} mcpUrl
*/
function runQuery(mcpUrl) {
/** @type {string[]} */ const out = []
/** @type {string[]} */ const err = []
const ctx = /** @type {any} */ ({
env: { HYP_HOME: process.env.HYP_HOME },
config: { version: 2, query: { remotes: { prod: { url: mcpUrl } } } },
query: {}, storage: {},
stdout: { write: (/** @type {string} */ s) => out.push(s) },
stderr: { write: (/** @type {string} */ s) => err.push(s) },
})
return { ctx, out, err }
}

/**
* Rewrite a target's stored OIDC record so its access JWT reads as expired,
* forcing the next resolve to refresh.
*
* @param {string} stateDir
* @param {string} target
*/
async function forceExpiry(stateDir, target) {
const creds = await readCredentials(stateDir)
const rec = /** @type {any} */ (creds[target])
// Write through writeSession (not a raw fs.writeFile) so the credential
// module's parse cache is invalidated. A raw same-size rewrite landing within
// one mtime tick would be hidden behind that cache, and the next resolve would
// read the pre-expiry record, skip the refresh, and flake this smoke.
await writeSession(stateDir, target, {
refreshToken: rec.refreshToken,
accessJwt: rec.accessJwt,
expiresAt: '2000-01-01T00:00:00Z',
org: rec.org,
})
}

/**
* Start the combined identity + MCP stub server. Signs a fresh access JWT on
* each grant; the MCP side accepts only the latest one.
*
* @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 mint = () => {
state.jwtSeq += 1
state.validJwt = `jwt-${state.jwtSeq}`
return state.validJwt
}
const FUTURE = '2999-01-01T00:00:00Z'

const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
const json = (/** @type {any} */ obj, status = 200) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(obj))
}

// Identity: browser start -> 302 to the loopback redirect with a code.
if (req.method === 'GET' && url.pathname === '/v1/identity/login/start') {
const redirectUri = url.searchParams.get('redirect_uri') ?? ''
const stateParam = url.searchParams.get('state') ?? ''
const loc = `${redirectUri}?code=auth-code&state=${encodeURIComponent(stateParam)}`
res.writeHead(302, { location: loc })
res.end()
return
}

// Identity: token endpoint (authorization_code + refresh_token grants).
if (req.method === 'POST' && url.pathname === '/v1/identity/token') {
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' })
}
if (grant === 'refresh_token') {
state.refreshCalls += 1
if (state.refreshRevoked) return json({ error: 'invalid_grant' }, 401)
return json({ access_jwt: mint(), expires_at: FUTURE, org: 'acme' })
}
return json({ error: 'unsupported_grant_type' }, 400)
})
return
}

// MCP: accept only the current access JWT.
if (req.method === 'POST' && url.pathname === '/mcp') {
readBody(req).then((rpc) => {
if (rpc.method === 'initialize') {
res.writeHead(200, { 'content-type': 'application/json', 'mcp-session-id': 'mcp-1' })
return res.end(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-06-18' } }))
}
if (rpc.method === 'notifications/initialized') {
res.writeHead(202)
return res.end()
}
if (rpc.method === 'tools/call') {
const auth = req.headers['authorization']
if (auth !== `Bearer ${state.validJwt}`) return json({ jsonrpc: '2.0', id: rpc.id }, 401)
return json({ jsonrpc: '2.0', id: rpc.id, result: { structuredContent: { columns: ['n'], rows: [{ n: 1 }] }, isError: false } })
}
return json({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'no' } })
})
return
}

json({ error: 'not_found' }, 404)
})

return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = /** @type {AddressInfo} */ (server.address())
resolve({
port: addr.port,
state,
close: (/** @type {() => void} */ cb) => server.close(cb),
closeAllConnections: () => server.closeAllConnections?.(),
})
})
})
}

/**
* @param {IncomingMessage} req
* @returns {Promise<any>}
*/
function readBody(req) {
return new Promise((resolve) => {
/** @type {Buffer[]} */ const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
try {
resolve(text ? JSON.parse(text) : {})
} catch {
resolve({})
}
})
})
}
18 changes: 15 additions & 3 deletions llp/0033-remote-query-attach.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,25 @@ a free invariant. The URL is non-secret and committable; the token is not config
(secrets-never-in-config, server LLP 0000). Resolution for the human-CLI path:

- **Storage:** `<state>/remote-credentials.json`, mode `0600`, atomic tmp+rename, a
single map `{ "<target>": { "token": "…" } }` (kernel-managed state,
single map `{ "<target>": { "kind": "…", … } }` (kernel-managed state,
[LLP 0004](./0004-activation-and-paths.spec.md); mirrors `central`'s
`identity.json` single-file precedent). One `hyp remote login` per server.
`identity.json` single-file precedent). One `hyp remote login` per server. Each
record is **discriminated by `kind`** ([LLP 0046 D4](./0046-oidc-login-client.decision.md#d4)):
a `static` record is the bare `{ token }` of this spec; an `oidc` record carries
a refresh token plus a cached short-lived access JWT from a browser login. A
legacy `token`-only record (no `kind`) reads as `static`, so existing files keep
working without a rewrite.
- **Resolution at query time:** per-target env `HYP_REMOTE_TOKEN_<NAME>`
(CI/ephemeral) → stored file → error (`no token for '<target>' — run 'hyp
remote login <target>'`). A *per-target* env var so a stored var can never
silently authenticate the wrong server.
silently authenticate the wrong server. For an `oidc` record the attach path is
**session-aware** ([LLP 0046 D5](./0046-oidc-login-client.decision.md#d5)): it
silently refreshes a near-expiry access JWT, and on a live `401`/`403` it
refreshes once and retries before surfacing; a refresh that fails `invalid_grant`
surfaces the same re-login guidance, now meaning re-run the browser flow. The
stdio proxy fallback ([LLP 0034 §proxy-fallback](./0034-mcp-host-intrinsic.decision.md#proxy-fallback))
shares this session-aware path, resolving a fresh JWT per forwarded message so a
long-lived proxy does not pin one short-lived access JWT.
- AI clients that install the endpoint directly hold the token in **their own** MCP
config — `hyp`'s store is only for the human-CLI client path.

Expand Down
Loading