1
0

browser-auth.host.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. /** Browser launch-token and persistent-cookie behavior. */
  2. import { createHmac } from 'node:crypto'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
  5. import { BrowserAuth } from '../src/browser-auth.ts'
  6. import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts'
  7. import { RecordCredentials } from './browser-credentials.ts'
  8. function signedCookie(store: RecordCredentials, name: string, payload: unknown): string {
  9. const body = typeof payload === 'string'
  10. ? Buffer.from(payload, 'utf8').toString('base64url')
  11. : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
  12. return signedBodyCookie(store, name, body)
  13. }
  14. function signedBodyCookie(store: RecordCredentials, name: string, body: string): string {
  15. const record = store.record
  16. if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) {
  17. throw new Error('test credential store has no signing secret')
  18. }
  19. const secret: unknown = Reflect.get(record.payload, 'secret')
  20. if (typeof secret !== 'string') throw new Error('test credential record has no string secret')
  21. const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url')
  22. return `${name}=v1.${body}.${signature}`
  23. }
  24. interface ResponseState {
  25. status?: number
  26. headers?: Readonly<Record<string, string>>
  27. body?: string
  28. }
  29. function response(): { value: ConnectionIndexResponse; state: ResponseState } {
  30. const state: ResponseState = {}
  31. return {
  32. value: {
  33. writeHead(status, headers) {
  34. state.status = status
  35. if (headers !== undefined) state.headers = headers
  36. },
  37. end(body) {
  38. if (body !== undefined) state.body = body
  39. },
  40. },
  41. state,
  42. }
  43. }
  44. function credentials(store: RecordCredentials): CredentialProvider {
  45. return store as unknown as CredentialProvider
  46. }
  47. function createAuth(
  48. store: RecordCredentials,
  49. maxAgeDays = 30,
  50. processOwner: object = {},
  51. ): Promise<BrowserAuth> {
  52. return BrowserAuth.create(processOwner, credentials(store), maxAgeDays)
  53. }
  54. function request(url: string, authority = '127.0.0.1:3080', init?: {
  55. cookie?: string
  56. method?: string
  57. }): ConnectionIndexRequest {
  58. return {
  59. method: init?.method ?? 'GET',
  60. url,
  61. headers: {
  62. host: authority,
  63. ...init?.cookie === undefined ? {} : { cookie: init.cookie },
  64. },
  65. }
  66. }
  67. function exchange(
  68. auth: BrowserAuth,
  69. authority = '127.0.0.1:3080',
  70. ): { cookie: string; launchUrl: string; state: ResponseState } {
  71. const launchUrl = auth.authenticatedUrl(`http://${authority}`)
  72. const target = new URL(launchUrl)
  73. const res = response()
  74. expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false)
  75. const setCookie = res.state.headers?.['set-cookie']
  76. if (setCookie === undefined) throw new Error('token exchange did not set a cookie')
  77. return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state }
  78. }
  79. afterEach(() => {
  80. vi.useRealTimers()
  81. })
  82. describe('BrowserAuth', () => {
  83. it('mints one process token and a persistent authority-bound cookie', async () => {
  84. const store = new RecordCredentials()
  85. const processOwner = {}
  86. const first = await createAuth(store, 30, processOwner)
  87. const login = exchange(first)
  88. expect(login.state).toMatchObject({
  89. status: 303,
  90. headers: {
  91. 'cache-control': 'no-store',
  92. 'location': '/',
  93. 'referrer-policy': 'no-referrer',
  94. },
  95. })
  96. expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u)
  97. expect(login.state.headers?.['set-cookie']).not.toContain('Secure')
  98. expect(first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
  99. expect(first.isAuthenticated({
  100. headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }),
  101. })).toBe(true)
  102. expect(first.isAuthenticated({ headers: new Headers() })).toBe(false)
  103. expect(first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false)
  104. expect(first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false)
  105. const reloaded = await createAuth(store, 30, processOwner)
  106. expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl)
  107. expect(reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
  108. const restarted = await createAuth(store)
  109. expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token'))
  110. .not.toBe(new URL(login.launchUrl).searchParams.get('token'))
  111. expect(restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
  112. const staleUrl = new URL(login.launchUrl)
  113. const redirected = response()
  114. expect(restarted.authorizeIndex(request(
  115. `${staleUrl.pathname}${staleUrl.search}`,
  116. '127.0.0.1:3080',
  117. { cookie: login.cookie },
  118. ), redirected.value)).toBe(false)
  119. expect(redirected.state).toEqual({
  120. status: 303,
  121. headers: {
  122. 'cache-control': 'no-store',
  123. 'location': '/',
  124. 'referrer-policy': 'no-referrer',
  125. },
  126. })
  127. })
  128. it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => {
  129. const auth = await createAuth(new RecordCredentials())
  130. const { cookie } = exchange(auth)
  131. const allowed = response()
  132. expect(auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true)
  133. expect(allowed.state).toEqual({})
  134. for (const candidate of [
  135. request('/'),
  136. request('/?token=wrong'),
  137. request('/?token=wrong&token=again'),
  138. request('/index.html?token=wrong'),
  139. request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }),
  140. ]) {
  141. const denied = response()
  142. expect(auth.authorizeIndex(candidate, denied.value)).toBe(false)
  143. expect(denied.state.status).toBe(401)
  144. expect(denied.state.headers).toEqual({
  145. 'cache-control': 'no-store',
  146. 'content-type': 'text/plain; charset=utf-8',
  147. })
  148. expect(denied.state.body).toBe(candidate.method === 'HEAD'
  149. ? undefined
  150. : 'dsh web authentication required; reopen the URL printed by dsh web.\n')
  151. }
  152. })
  153. it('rejects tampering, expiry, future issuance, and a longer lifetime than configured', async () => {
  154. vi.useFakeTimers()
  155. vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z'))
  156. const store = new RecordCredentials()
  157. const auth = await createAuth(store)
  158. const { cookie } = exchange(auth)
  159. const [name, value] = cookie.split('=') as [string, string]
  160. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false)
  161. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false)
  162. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false)
  163. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', {
  164. cookie: signedBodyCookie(store, name, 'a'),
  165. }))).toBe(false)
  166. expect(auth.isAuthenticated({ headers: {} })).toBe(false)
  167. expect(auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false)
  168. expect(auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false)
  169. const invalidPayloads: unknown[] = [
  170. 'not json',
  171. null,
  172. { version: 2, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: Date.now() + 1000 },
  173. { version: 1, authority: 42, issuedAt: Date.now(), expiresAt: Date.now() + 1000 },
  174. { version: 1, authority: '127.0.0.1:3080', issuedAt: 'now', expiresAt: Date.now() + 1000 },
  175. { version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' },
  176. ]
  177. for (const payload of invalidPayloads) {
  178. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', {
  179. cookie: signedCookie(store, name, payload),
  180. }))).toBe(false)
  181. }
  182. const shorter = await createAuth(store, 1)
  183. expect(shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
  184. vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z'))
  185. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
  186. vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z'))
  187. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
  188. })
  189. it('loads one secret per activation and replaces it after deletion on the next activation', async () => {
  190. const store = new RecordCredentials()
  191. const auth = await createAuth(store)
  192. const first = exchange(auth)
  193. expect(store).toMatchObject({ reads: 0, modifies: 1 })
  194. await store.deleteRecord()
  195. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(true)
  196. const sameActivation = exchange(auth)
  197. expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: sameActivation.cookie }))).toBe(true)
  198. expect(store).toMatchObject({ reads: 0, modifies: 1 })
  199. const reactivated = await createAuth(store)
  200. const second = exchange(reactivated)
  201. expect(second.cookie).not.toBe(first.cookie)
  202. expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
  203. expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true)
  204. expect(store).toMatchObject({ reads: 0, modifies: 2 })
  205. })
  206. it('fails loud on an invalid owner record instead of replacing it', async () => {
  207. const unsupported = new RecordCredentials()
  208. unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' }
  209. await expect(createAuth(unsupported)).rejects.toThrow(/unsupported format/u)
  210. const malformed = new RecordCredentials()
  211. malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } }
  212. await expect(createAuth(malformed)).rejects.toThrow(/invalid secret/u)
  213. const nonString = new RecordCredentials()
  214. nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } }
  215. await expect(createAuth(nonString)).rejects.toThrow(/invalid secret/u)
  216. const discarded = new RecordCredentials()
  217. discarded.discardWrites = true
  218. await expect(createAuth(discarded)).rejects.toThrow(/was not created/u)
  219. await expect(createAuth(new RecordCredentials(), Number.MAX_SAFE_INTEGER))
  220. .rejects.toThrow(/safe timestamp range/u)
  221. })
  222. })