authorization.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { credentialKey } from '@deepseek-ai/dsh-credentials'
  4. import AuthorizationService, {
  5. AuthorizationDeclinedError,
  6. type AuthorizationFlow,
  7. type AuthorizationInteraction,
  8. type AuthorizationSession,
  9. } from '@deepseek-ai/dsh-authorization'
  10. import { MemoryCredentials } from './memory.ts'
  11. const KEY = credentialKey('llm-pi-ai', 'openai-codex')
  12. const OTHER = credentialKey('llm-pi-ai', 'anthropic')
  13. /** A context with the record store the seam confirms commits against. */
  14. async function harness(): Promise<Context> {
  15. const ctx = new Context()
  16. await ctx.plugin(MemoryCredentials)
  17. await ctx.plugin(AuthorizationService)
  18. return ctx
  19. }
  20. /** An interaction that answers every prompt with the same string. */
  21. function surface(answer = 'typed'): AuthorizationInteraction & {
  22. notices: unknown[]
  23. prompts: unknown[]
  24. } {
  25. const notices: unknown[] = []
  26. const prompts: unknown[] = []
  27. return {
  28. notices,
  29. prompts,
  30. notify: (notice) => { notices.push(notice) },
  31. prompt: (prompt) => {
  32. prompts.push(prompt)
  33. return Promise.resolve(answer)
  34. },
  35. }
  36. }
  37. /** A flow that commits `key` through the record store and then resolves. */
  38. function committingFlow(
  39. ctx: Context,
  40. key = KEY,
  41. run?: (session: AuthorizationSession) => Promise<void>,
  42. ): AuthorizationFlow {
  43. return {
  44. key,
  45. label: 'ChatGPT (Codex)',
  46. methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
  47. async run(session) {
  48. await run?.(session)
  49. await ctx.credentials.modifyRecord(key, () =>
  50. Promise.resolve({ kind: 'grant', payload: { token: 'granted' } }))
  51. },
  52. }
  53. }
  54. describe('AuthorizationService registry', () => {
  55. it('lists a registered flow and drops it when the registration is disposed', async () => {
  56. const ctx = await harness()
  57. const dispose = ctx.authorization.registerFlow(committingFlow(ctx))
  58. expect(ctx.authorization.list()).toEqual([{
  59. key: KEY,
  60. label: 'ChatGPT (Codex)',
  61. methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
  62. inFlight: false,
  63. }])
  64. expect(ctx.authorization.describe(KEY)?.label).toBe('ChatGPT (Codex)')
  65. expect(ctx.authorization.describe(OTHER)).toBeUndefined()
  66. dispose()
  67. expect(ctx.authorization.list()).toEqual([])
  68. expect(ctx.authorization.describe(KEY)).toBeUndefined()
  69. })
  70. it('refuses a second flow for the same key', async () => {
  71. const ctx = await harness()
  72. ctx.authorization.registerFlow(committingFlow(ctx))
  73. expect(() => ctx.authorization.registerFlow(committingFlow(ctx)))
  74. .toThrow(/already registered/)
  75. })
  76. it('withdraws an attempt still running when its flow leaves', async () => {
  77. const ctx = await harness()
  78. let started: (() => void) | undefined
  79. const running = new Promise<void>((resolve) => {
  80. started = resolve
  81. })
  82. const dispose = ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
  83. new Promise((_resolve, reject) => {
  84. started?.()
  85. session.signal.addEventListener('abort', () => { reject(new Error('withdrawn')) }, { once: true })
  86. })))
  87. const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
  88. await running
  89. dispose()
  90. await expect(attempt).resolves.toEqual({ status: 'cancelled' })
  91. })
  92. })
  93. describe('AuthorizationService.begin', () => {
  94. it('runs the flow, confirms the committed record, and reports the settlement', async () => {
  95. const ctx = await harness()
  96. ctx.authorization.registerFlow(committingFlow(ctx))
  97. const settled = vi.fn()
  98. ctx.on('authorization/settled', settled)
  99. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  100. .resolves.toEqual({ status: 'authorized' })
  101. expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'granted' } })
  102. expect(settled).toHaveBeenCalledWith(KEY, 'authorized')
  103. })
  104. it('runs the flow first method when the caller names none, and the named one when it does', async () => {
  105. const ctx = await harness()
  106. const seen: string[] = []
  107. ctx.authorization.registerFlow(committingFlow(ctx, KEY, (session) => {
  108. seen.push(session.method)
  109. return Promise.resolve()
  110. }))
  111. await ctx.authorization.begin({ key: KEY, interaction: surface() })
  112. await ctx.authorization.begin({ key: KEY, method: 'api-key', interaction: surface() })
  113. expect(seen).toEqual(['oauth', 'api-key'])
  114. })
  115. it('carries notices and prompts between the flow and the calling surface', async () => {
  116. const ctx = await harness()
  117. const answers: string[] = []
  118. ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
  119. session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
  120. answers.push(await session.prompt({ kind: 'text', message: 'Paste the code' }))
  121. }))
  122. const ui = surface('code-123')
  123. await ctx.authorization.begin({ key: KEY, interaction: ui })
  124. expect(ui.notices).toEqual([{ message: 'Continue in your browser', url: 'https://auth.example/start' }])
  125. expect(ui.prompts).toEqual([{ kind: 'text', message: 'Paste the code' }])
  126. expect(answers).toEqual(['code-123'])
  127. })
  128. it('refuses a key no flow claims', async () => {
  129. const ctx = await harness()
  130. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  131. .rejects.toThrow(/no authorization flow is registered/)
  132. })
  133. it('refuses a method the flow does not offer', async () => {
  134. const ctx = await harness()
  135. ctx.authorization.registerFlow(committingFlow(ctx))
  136. await expect(ctx.authorization.begin({ key: KEY, method: 'device', interaction: surface() }))
  137. .rejects.toThrow(/offers no method "device"/)
  138. })
  139. it('refuses a second attempt while one is running, and admits one after it settles', async () => {
  140. const ctx = await harness()
  141. // Only the first attempt blocks; the later ones must be free to complete,
  142. // which is what shows the key was released rather than merely idle-looking.
  143. const held = Promise.withResolvers<undefined>()
  144. const started = Promise.withResolvers<undefined>()
  145. let first = true
  146. ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
  147. if (!first) return Promise.resolve()
  148. first = false
  149. started.resolve(undefined)
  150. return held.promise
  151. }))
  152. const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
  153. await started.promise
  154. expect(ctx.authorization.describe(KEY)?.inFlight).toBe(true)
  155. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  156. .rejects.toThrow(/already running/)
  157. held.resolve(undefined)
  158. await expect(attempt).resolves.toEqual({ status: 'authorized' })
  159. expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
  160. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  161. .resolves.toEqual({ status: 'authorized' })
  162. })
  163. it('never starts a flow whose caller withdrew before begin', async () => {
  164. const ctx = await harness()
  165. const ran = vi.fn()
  166. const settled = vi.fn()
  167. ctx.on('authorization/settled', settled)
  168. ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
  169. ran()
  170. return new Promise(() => {})
  171. }))
  172. await expect(ctx.authorization.begin({
  173. key: KEY,
  174. interaction: surface(),
  175. signal: AbortSignal.abort(),
  176. })).resolves.toEqual({ status: 'cancelled' })
  177. expect(ran).not.toHaveBeenCalled()
  178. // Nothing occupied the key, so nothing settled on it either.
  179. expect(settled).not.toHaveBeenCalled()
  180. expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
  181. })
  182. it('still reports an unknown method to a caller that already withdrew', async () => {
  183. const ctx = await harness()
  184. ctx.authorization.registerFlow(committingFlow(ctx))
  185. await expect(ctx.authorization.begin({
  186. key: KEY,
  187. method: 'device',
  188. interaction: surface(),
  189. signal: AbortSignal.abort(),
  190. })).rejects.toThrow(/offers no method "device"/)
  191. })
  192. it('reports a caller that withdraws mid-flight as cancelled', async () => {
  193. const ctx = await harness()
  194. const controller = new AbortController()
  195. ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
  196. new Promise((_resolve, reject) => {
  197. session.signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  198. controller.abort()
  199. })))
  200. await expect(ctx.authorization.begin({ key: KEY, interaction: surface(), signal: controller.signal }))
  201. .resolves.toEqual({ status: 'cancelled' })
  202. })
  203. it('withdraws a running attempt through cancel(), and ignores cancel() for an idle key', async () => {
  204. const ctx = await harness()
  205. const started = Promise.withResolvers<undefined>()
  206. ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
  207. new Promise((_resolve, reject) => {
  208. session.signal.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true })
  209. started.resolve(undefined)
  210. })))
  211. ctx.authorization.cancel(OTHER)
  212. const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
  213. await started.promise
  214. ctx.authorization.cancel(KEY)
  215. await expect(attempt).resolves.toEqual({ status: 'cancelled' })
  216. })
  217. it('settles a withdrawn attempt even when its flow never reacts to the signal', async () => {
  218. const ctx = await harness()
  219. const orphan = Promise.withResolvers<undefined>()
  220. const started = Promise.withResolvers<undefined>()
  221. ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
  222. started.resolve(undefined)
  223. return orphan.promise
  224. }))
  225. const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
  226. await started.promise
  227. ctx.authorization.cancel(KEY)
  228. await expect(attempt).resolves.toEqual({ status: 'cancelled' })
  229. // The key is free again immediately, rather than at the mercy of a flow
  230. // that may never settle.
  231. expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
  232. // The orphan's own failure is nobody's to await, and must not surface as an
  233. // unhandled rejection.
  234. orphan.reject(new Error('gave up long after the human left'))
  235. await expect(orphan.promise).rejects.toThrow('gave up long after the human left')
  236. })
  237. it('propagates a flow failure to its caller and settles the key as failed', async () => {
  238. const ctx = await harness()
  239. ctx.authorization.registerFlow(committingFlow(ctx, KEY, () =>
  240. Promise.reject(new Error('the token endpoint said no'))))
  241. const settled = vi.fn()
  242. ctx.on('authorization/settled', settled)
  243. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  244. .rejects.toThrow('the token endpoint said no')
  245. expect(settled).toHaveBeenCalledWith(KEY, 'failed')
  246. expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
  247. })
  248. it('refuses a flow that resolves without committing its record', async () => {
  249. const ctx = await harness()
  250. ctx.authorization.registerFlow({
  251. key: KEY,
  252. label: 'Forgetful',
  253. methods: [{ id: 'oauth', label: 'Sign in' }],
  254. run: () => Promise.resolve(),
  255. })
  256. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  257. .rejects.toThrow(/resolved without committing a credential record/)
  258. })
  259. })
  260. describe('commit confirmation', () => {
  261. it('refuses a re-auth that left only the record of an earlier attempt', async () => {
  262. const ctx = await harness()
  263. await ctx.credentials.modifyRecord(KEY, () =>
  264. Promise.resolve({ kind: 'grant', payload: { token: 'stale' } }))
  265. ctx.authorization.registerFlow({
  266. key: KEY,
  267. label: 'Forgetful',
  268. methods: [{ id: 'oauth', label: 'Sign in' }],
  269. // A commit for another key is not this flow's commit either.
  270. async run() {
  271. await ctx.credentials.modifyRecord(OTHER, () =>
  272. Promise.resolve({ kind: 'grant', payload: { token: 'other' } }))
  273. },
  274. })
  275. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  276. .rejects.toThrow(/without committing a credential record in this attempt/)
  277. // Refused, not cleaned up: the stale record still belongs to its owner.
  278. expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'stale' } })
  279. })
  280. it('refuses a flow that deleted its record instead of committing one', async () => {
  281. const ctx = await harness()
  282. await ctx.credentials.modifyRecord(KEY, () =>
  283. Promise.resolve({ kind: 'grant', payload: { token: 'stale' } }))
  284. ctx.authorization.registerFlow({
  285. key: KEY,
  286. label: 'Destructive',
  287. methods: [{ id: 'oauth', label: 'Sign in' }],
  288. run: () => ctx.credentials.deleteRecord(KEY),
  289. })
  290. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  291. .rejects.toThrow(/deleted its credential record/)
  292. })
  293. })
  294. describe('declined prompts', () => {
  295. it('reports an attempt whose prompt the human declined as cancelled, not failed', async () => {
  296. const ctx = await harness()
  297. ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
  298. await session.prompt({ kind: 'text', message: 'Paste the code' })
  299. }))
  300. const settled = vi.fn()
  301. ctx.on('authorization/settled', settled)
  302. const declining: AuthorizationInteraction = {
  303. notify: () => undefined,
  304. prompt: () => Promise.reject(new AuthorizationDeclinedError()),
  305. }
  306. await expect(ctx.authorization.begin({ key: KEY, interaction: declining }))
  307. .resolves.toEqual({ status: 'cancelled' })
  308. expect(settled).toHaveBeenCalledWith(KEY, 'cancelled')
  309. })
  310. it('reads a decline through a flow that rewraps the rejection on its way out', async () => {
  311. const ctx = await harness()
  312. ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
  313. session.prompt({ kind: 'text', message: 'Paste the code' }).then(
  314. () => undefined,
  315. () => {
  316. throw new Error('sign-in aborted')
  317. })))
  318. const declining: AuthorizationInteraction = {
  319. notify: () => undefined,
  320. prompt: () => Promise.reject(new AuthorizationDeclinedError()),
  321. }
  322. await expect(ctx.authorization.begin({ key: KEY, interaction: declining }))
  323. .resolves.toEqual({ status: 'cancelled' })
  324. })
  325. it('keeps a prompt failure that is not a decline a flow failure', async () => {
  326. const ctx = await harness()
  327. ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
  328. await session.prompt({ kind: 'text', message: 'Paste the code' })
  329. }))
  330. const settled = vi.fn()
  331. ctx.on('authorization/settled', settled)
  332. const broken: AuthorizationInteraction = {
  333. notify: () => undefined,
  334. prompt: () => Promise.reject(new Error('the transport dropped')),
  335. }
  336. await expect(ctx.authorization.begin({ key: KEY, interaction: broken }))
  337. .rejects.toThrow('the transport dropped')
  338. expect(settled).toHaveBeenCalledWith(KEY, 'failed')
  339. })
  340. })
  341. describe('notice containment', () => {
  342. it('loses the notice, never the attempt, when the surface cannot render it', async () => {
  343. const ctx = await harness()
  344. ctx.authorization.registerFlow(committingFlow(ctx, KEY, (session) => {
  345. session.notify({ message: 'Continue in your browser' })
  346. return Promise.resolve()
  347. }))
  348. const broken: AuthorizationInteraction = {
  349. notify: () => {
  350. throw new Error('page connection closed')
  351. },
  352. prompt: () => Promise.resolve('unused'),
  353. }
  354. await expect(ctx.authorization.begin({ key: KEY, interaction: broken }))
  355. .resolves.toEqual({ status: 'authorized' })
  356. })
  357. })
  358. describe('the settled fan-out', () => {
  359. it('keeps a throwing listener from changing a finished attempt, and later listeners still run', async () => {
  360. const ctx = await harness()
  361. ctx.authorization.registerFlow(committingFlow(ctx))
  362. ctx.on('authorization/settled', () => {
  363. throw new Error('watcher boom')
  364. })
  365. const second = vi.fn()
  366. ctx.on('authorization/settled', second)
  367. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  368. .resolves.toEqual({ status: 'authorized' })
  369. expect(second).toHaveBeenCalledWith(KEY, 'authorized')
  370. })
  371. it('contains an async listener rejection', async () => {
  372. const ctx = await harness()
  373. ctx.authorization.registerFlow(committingFlow(ctx))
  374. // An unknown-returning function keeps the typed surface legal while the
  375. // runtime value is still the rejected promise the containment must handle.
  376. const boom = (): unknown => Promise.reject(new Error('async watcher boom'))
  377. ctx.on('authorization/settled', boom)
  378. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  379. .resolves.toEqual({ status: 'authorized' })
  380. await new Promise(resolve => setTimeout(resolve, 10))
  381. })
  382. it('rethrows an invariant-coded listener failure after the remaining listeners', async () => {
  383. const ctx = await harness()
  384. ctx.authorization.registerFlow(committingFlow(ctx))
  385. ctx.on('authorization/settled', () => {
  386. throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
  387. })
  388. const second = vi.fn()
  389. ctx.on('authorization/settled', second)
  390. await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
  391. .rejects.toThrow(/forged relation/)
  392. // Harness-fatal by design — but the record itself committed first.
  393. expect(second).toHaveBeenCalledWith(KEY, 'authorized')
  394. expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'granted' } })
  395. })
  396. })