web-auth.e2e.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. /** Real `dsh web` authentication against a temporary Harness home. */
  2. import type { ChildProcess } from 'node:child_process'
  3. import { spawn } from 'node:child_process'
  4. import { stat } from 'node:fs/promises'
  5. import { request as httpRequest } from 'node:http'
  6. import { createRequire } from 'node:module'
  7. import { createServer } from 'node:net'
  8. import type { AddressInfo } from 'node:net'
  9. import { mkdtemp, rm } from 'node:fs/promises'
  10. import { tmpdir } from 'node:os'
  11. import { join } from 'node:path'
  12. import { fileURLToPath, pathToFileURL } from 'node:url'
  13. import { describe, expect, it } from 'vitest'
  14. const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
  15. const DSH_SOURCE_BIN = join(REPO_ROOT, 'apps/cli/src/bin.ts')
  16. const TSX_LOADER = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
  17. interface RunningWeb {
  18. readonly child: ChildProcess
  19. readonly launchUrl: string
  20. readonly output: () => string
  21. }
  22. interface HttpResult {
  23. readonly status: number
  24. readonly body: string
  25. }
  26. function redact(output: string): string {
  27. return output.replace(/([?&]token=)[^\s)]+/gu, '$1<redacted>')
  28. }
  29. /** Reserve one concrete loopback port, then release it for the CLI process. */
  30. async function freePort(): Promise<number> {
  31. const server = createServer()
  32. await new Promise<void>((resolve, reject) => {
  33. server.once('error', reject)
  34. server.listen(0, '127.0.0.1', resolve)
  35. })
  36. const port = (server.address() as AddressInfo).port
  37. await new Promise<void>((resolve, reject) => {
  38. server.close((error) => {
  39. if (error === undefined) resolve()
  40. else reject(error)
  41. })
  42. })
  43. return port
  44. }
  45. function cleanEnvironment(root: string, dshHome: string): NodeJS.ProcessEnv {
  46. const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
  47. !/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)))
  48. return {
  49. ...env,
  50. DSH_AGENTS_HOME: join(root, '.agents'),
  51. DSH_HOME: dshHome,
  52. DSH_TELEMETRY_DISABLED: '1',
  53. NODE_NO_WARNINGS: '1',
  54. SSH_CONNECTION: '',
  55. SSH_TTY: '',
  56. TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
  57. }
  58. }
  59. /** Start the public source CLI and wait for its authenticated readiness URL. */
  60. async function startWeb(root: string, dshHome: string, port: number): Promise<RunningWeb> {
  61. const child = spawn(process.execPath, [
  62. '--import', TSX_LOADER,
  63. DSH_SOURCE_BIN,
  64. 'web',
  65. '--no-open',
  66. '--port', String(port),
  67. ], {
  68. cwd: root,
  69. env: cleanEnvironment(root, dshHome),
  70. stdio: ['ignore', 'pipe', 'pipe'],
  71. })
  72. let output = ''
  73. const launchUrl = await new Promise<string>((resolve, reject) => {
  74. let settled = false
  75. const fail = (error: Error): void => {
  76. if (settled) return
  77. settled = true
  78. clearTimeout(timer)
  79. reject(error)
  80. }
  81. const timer = setTimeout(() => {
  82. fail(new Error(`dsh web did not become ready:\n${redact(output)}`))
  83. }, 90_000)
  84. const append = (chunk: Buffer | string): void => {
  85. output = `${output}${String(chunk)}`.slice(-100_000)
  86. const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output)
  87. if (settled || match?.[1] === undefined) return
  88. settled = true
  89. clearTimeout(timer)
  90. resolve(match[1])
  91. }
  92. child.stdout?.on('data', append)
  93. child.stderr?.on('data', append)
  94. child.once('error', (error) => {
  95. fail(error)
  96. })
  97. child.once('exit', (code) => {
  98. fail(new Error(`dsh web exited before readiness (${String(code)}):\n${redact(output)}`))
  99. })
  100. })
  101. return { child, launchUrl, output: () => output }
  102. }
  103. async function stopWeb(running: RunningWeb): Promise<void> {
  104. if (running.child.exitCode !== null) return
  105. const exited = new Promise<void>((resolve) => { running.child.once('exit', () => { resolve() }) })
  106. running.child.kill('SIGTERM')
  107. const forced = setTimeout(() => { running.child.kill('SIGKILL') }, 10_000)
  108. forced.unref()
  109. await exited
  110. clearTimeout(forced)
  111. }
  112. /** POST one real Remote envelope while controlling the wire Host header. */
  113. function describeSettings(port: number, host: string, cookie?: string): Promise<HttpResult> {
  114. const body = JSON.stringify({
  115. type: 'client-request',
  116. rpcId: 'web-auth-real-cli',
  117. method: 'settings/describe',
  118. payload: { args: {} },
  119. })
  120. return new Promise((resolve, reject) => {
  121. const req = httpRequest({
  122. hostname: '127.0.0.1',
  123. port,
  124. path: '/api/settings/describe',
  125. method: 'POST',
  126. headers: {
  127. host,
  128. 'content-type': 'application/json',
  129. 'content-length': Buffer.byteLength(body),
  130. ...cookie === undefined ? {} : { cookie },
  131. },
  132. }, (res) => {
  133. const chunks: Uint8Array[] = []
  134. res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  135. res.on('end', () => {
  136. resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })
  137. })
  138. })
  139. req.once('error', reject)
  140. req.end(body)
  141. })
  142. }
  143. describe('dsh web authentication through the real CLI', () => {
  144. it('rejects a forged loopback Host and preserves the browser cookie across restart', { timeout: 180_000 }, async () => {
  145. const root = await mkdtemp(join(tmpdir(), 'dsh-web-auth-real-cli-'))
  146. const dshHome = join(root, '.dsh')
  147. const port = await freePort()
  148. let first: RunningWeb | undefined
  149. let second: RunningWeb | undefined
  150. try {
  151. first = await startWeb(root, dshHome, port)
  152. const firstUrl = new URL(first.launchUrl)
  153. expect(firstUrl.origin).toBe(`http://127.0.0.1:${String(port)}`)
  154. expect(firstUrl.pathname).toBe('/')
  155. expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u)
  156. expect(await describeSettings(port, `localhost:${String(port)}`)).toEqual({
  157. status: 401,
  158. body: 'unauthorized',
  159. })
  160. const exchange = await fetch(first.launchUrl, { redirect: 'manual' })
  161. expect(exchange.status).toBe(303)
  162. expect(exchange.headers.get('location')).toBe('/')
  163. const setCookie = exchange.headers.get('set-cookie')
  164. if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie')
  165. expect(setCookie).toContain('HttpOnly')
  166. expect(setCookie).toContain('SameSite=Strict')
  167. expect(setCookie).not.toContain('Secure')
  168. const cookie = setCookie.split(';', 1)[0]!
  169. const authenticated = await describeSettings(port, firstUrl.host, cookie)
  170. expect(authenticated.status).toBe(200)
  171. const authenticatedBody = JSON.parse(authenticated.body) as unknown
  172. expect(authenticatedBody).toMatchObject({
  173. type: 'server-response',
  174. rpcId: 'web-auth-real-cli',
  175. result: { ok: true, value: { namespaces: expect.any(Array) as unknown } },
  176. })
  177. await stopWeb(first)
  178. first = undefined
  179. second = await startWeb(root, dshHome, port)
  180. const secondUrl = new URL(second.launchUrl)
  181. expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token'))
  182. expect((await describeSettings(port, secondUrl.host, cookie)).status).toBe(200)
  183. const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777
  184. expect(credentialMode).toBe(0o600)
  185. } catch (error) {
  186. const evidence = [first?.output(), second?.output()].filter(value => value !== undefined).join('\n')
  187. throw new Error(`${error instanceof Error ? error.message : String(error)}\n${redact(evidence)}`, { cause: error })
  188. } finally {
  189. if (second !== undefined) await stopWeb(second)
  190. if (first !== undefined) await stopWeb(first)
  191. await rm(root, { recursive: true, force: true })
  192. }
  193. })
  194. })