agent-messenger 2.32.0 → 2.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bun.lock +7 -0
  3. package/dist/package.json +2 -1
  4. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  5. package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
  6. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  7. package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
  8. package/dist/src/platforms/discord/commands/channel.js +3 -2
  9. package/dist/src/platforms/discord/commands/channel.js.map +1 -1
  10. package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
  11. package/dist/src/platforms/discord/commands/snapshot.js +7 -6
  12. package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
  13. package/dist/src/platforms/discord/types.d.ts +17 -0
  14. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  15. package/dist/src/platforms/discord/types.js +36 -0
  16. package/dist/src/platforms/discord/types.js.map +1 -1
  17. package/dist/src/platforms/slack/index.d.ts +1 -0
  18. package/dist/src/platforms/slack/index.d.ts.map +1 -1
  19. package/dist/src/platforms/slack/index.js.map +1 -1
  20. package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
  21. package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
  22. package/dist/src/platforms/slack/qr-confirmation.js +68 -0
  23. package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
  24. package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
  25. package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
  26. package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
  27. package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
  28. package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
  29. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
  30. package/dist/src/platforms/slack/qr-http-login.js +88 -53
  31. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  32. package/docs/content/docs/cli/channeltalk.mdx +4 -4
  33. package/package.json +2 -1
  34. package/skills/agent-channeltalk/SKILL.md +5 -5
  35. package/skills/agent-channeltalk/references/authentication.md +9 -5
  36. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  37. package/skills/agent-discord/SKILL.md +8 -4
  38. package/skills/agent-discord/references/common-patterns.md +7 -3
  39. package/skills/agent-discordbot/SKILL.md +1 -1
  40. package/skills/agent-imessage/SKILL.md +1 -1
  41. package/skills/agent-instagram/SKILL.md +1 -1
  42. package/skills/agent-kakaotalk/SKILL.md +1 -1
  43. package/skills/agent-line/SKILL.md +1 -1
  44. package/skills/agent-slack/SKILL.md +25 -2
  45. package/skills/agent-slackbot/SKILL.md +1 -1
  46. package/skills/agent-teams/SKILL.md +1 -1
  47. package/skills/agent-telegram/SKILL.md +1 -1
  48. package/skills/agent-telegrambot/SKILL.md +1 -1
  49. package/skills/agent-webex/SKILL.md +1 -1
  50. package/skills/agent-webexbot/SKILL.md +1 -1
  51. package/skills/agent-wechatbot/SKILL.md +1 -1
  52. package/skills/agent-whatsapp/SKILL.md +1 -1
  53. package/skills/agent-whatsappbot/SKILL.md +1 -1
  54. package/src/platforms/channeltalk/token-extractor.test.ts +235 -0
  55. package/src/platforms/channeltalk/token-extractor.ts +81 -22
  56. package/src/platforms/discord/commands/channel.test.ts +33 -23
  57. package/src/platforms/discord/commands/channel.ts +3 -2
  58. package/src/platforms/discord/commands/snapshot.test.ts +80 -2
  59. package/src/platforms/discord/commands/snapshot.ts +7 -6
  60. package/src/platforms/discord/types.test.ts +45 -0
  61. package/src/platforms/discord/types.ts +41 -0
  62. package/src/platforms/slack/index.ts +1 -0
  63. package/src/platforms/slack/qr-confirmation.ts +86 -0
  64. package/src/platforms/slack/qr-cookie-jar.ts +54 -0
  65. package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
  66. package/src/platforms/slack/qr-http-login.test.ts +10 -18
  67. package/src/platforms/slack/qr-http-login.ts +109 -52
@@ -0,0 +1,398 @@
1
+ import QRCode from 'qrcode'
2
+
3
+ import { loginWithQr } from './qr-http-login'
4
+
5
+ const WORKSPACE = 'acme'
6
+ const QR_SECRET = 'z-app-1-2-abcdef'
7
+ const MAGIC_LOGIN = 'z-app-1-3-ghijkl'
8
+ const ZAPP_URL = `https://app.slack.com/t/${WORKSPACE}/login/${QR_SECRET}?src=qr_code&user_id=U1&team_id=T1`
9
+ const D_COOKIE = 'xoxd-abc%2Bdef123'
10
+ const TOKEN = `xoxc-1-2-3-${'a'.repeat(64)}`
11
+
12
+ async function qrDataUrl(): Promise<string> {
13
+ return QRCode.toDataURL(ZAPP_URL, { margin: 2, width: 256 })
14
+ }
15
+
16
+ function redirect(location: string, setCookies: readonly string[] = []): Response {
17
+ const headers = new Headers({ location })
18
+ for (const cookie of setCookies) headers.append('set-cookie', cookie)
19
+ return new Response(null, { status: 302, headers })
20
+ }
21
+
22
+ function confirmationPage(email = 'user@acme.com'): Response {
23
+ const props = JSON.stringify({
24
+ action: 'request_primary',
25
+ emailAddress: email,
26
+ magicLogin: MAGIC_LOGIN,
27
+ twoFactorType: 'sms',
28
+ }).replaceAll('"', '&quot;')
29
+ return new Response(`<div id="props_node" data-props="${props}"></div>`, { status: 200 })
30
+ }
31
+
32
+ describe('loginWithQr confirmation code', () => {
33
+ it('keeps ordinary QR login prompt-free when Slack issues the session cookie directly', async () => {
34
+ // Given an ordinary QR redirect chain and an optional confirmation callback
35
+ const dataUrl = await qrDataUrl()
36
+ let confirmationRequests = 0
37
+ const fetchImpl = (async (input: string | URL) => {
38
+ const url = typeof input === 'string' ? input : input.toString()
39
+ if (url.startsWith('https://app.slack.com/t/')) {
40
+ return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`, [`d=${D_COOKIE}; Domain=.slack.com; Path=/`])
41
+ }
42
+ if (url.includes('/z-app-secret')) return new Response(null, { status: 200 })
43
+ if (url.includes('/ssb/redirect')) {
44
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
45
+ }
46
+ throw new Error(`unexpected fetch: ${url}`)
47
+ }) as typeof fetch
48
+
49
+ // When logging in through the unified QR flow
50
+ const session = await loginWithQr(dataUrl, {
51
+ fetchImpl,
52
+ requestConfirmationCode: async () => {
53
+ confirmationRequests += 1
54
+ return '123456'
55
+ },
56
+ })
57
+
58
+ // Then ordinary QR login succeeds without asking for a confirmation code
59
+ expect(session.token).toBe(TOKEN)
60
+ expect(confirmationRequests).toBe(0)
61
+ })
62
+
63
+ it('submits a confirmation code from Slack data-props and returns the session', async () => {
64
+ // Given a QR chain that reaches Slack's confirmation-code page
65
+ const dataUrl = await qrDataUrl()
66
+ const requests: Array<{ url: string; method: string; body: string }> = []
67
+ const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
68
+ const url = typeof input === 'string' ? input : input.toString()
69
+ const method = init?.method ?? 'GET'
70
+ const body = typeof init?.body === 'string' ? init.body : ''
71
+ requests.push({ url, method, body })
72
+
73
+ if (method === 'GET' && url.startsWith('https://app.slack.com/t/')) {
74
+ return redirect(`https://${WORKSPACE}.slack.com/login/${QR_SECRET}?src=qr_code&user_id=U1&team_id=T1`)
75
+ }
76
+ if (method === 'GET' && url.includes(`/login/${QR_SECRET}`)) return confirmationPage()
77
+ if (method === 'POST' && url.includes(`/login/${QR_SECRET}`)) {
78
+ return redirect('https://slack.com/checkcookie?redir=x', [`d=${D_COOKIE}; Domain=.slack.com; Path=/`])
79
+ }
80
+ if (url.includes('/ssb/redirect')) {
81
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
82
+ }
83
+ throw new Error(`unexpected fetch: ${method} ${url}`)
84
+ }) as typeof fetch
85
+
86
+ // When the caller supplies the code Slack sent
87
+ const session = await loginWithQr(dataUrl, {
88
+ fetchImpl,
89
+ requestConfirmationCode: async () => '123456',
90
+ })
91
+
92
+ // Then the server-issued magic login and entered code are posted to the exact workspace z-app URL
93
+ const post = requests.find((request) => request.method === 'POST')
94
+ expect(post?.url).toBe(
95
+ `https://${WORKSPACE}.slack.com/login/${QR_SECRET}?domain=${WORKSPACE}&domainLogin=1&email=user%40acme.com`,
96
+ )
97
+ expect(post?.body).toContain(`2fa_magiclogin=${MAGIC_LOGIN}`)
98
+ expect(post?.body).toContain('2fa_code=123456')
99
+ expect(post?.body).toContain('2fa_action=submit_primary')
100
+ expect(session).toEqual({ token: TOKEN, cookie: D_COOKIE, workspace: WORKSPACE })
101
+ })
102
+
103
+ it('rejects confirmation pages on another Slack subdomain before requesting the code', async () => {
104
+ // Given a QR chain redirected to a different Slack workspace host
105
+ const dataUrl = await qrDataUrl()
106
+ let confirmationRequests = 0
107
+ let postRequests = 0
108
+ const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
109
+ const url = typeof input === 'string' ? input : input.toString()
110
+ if ((init?.method ?? 'GET') === 'POST') postRequests += 1
111
+ if (url.startsWith('https://app.slack.com/t/')) {
112
+ return redirect(`https://other.slack.com/login/${QR_SECRET}`)
113
+ }
114
+ if (url.startsWith('https://other.slack.com/')) return confirmationPage()
115
+ throw new Error(`unexpected fetch: ${url}`)
116
+ }) as typeof fetch
117
+
118
+ // When the unified login evaluates the confirmation page
119
+ const promise = loginWithQr(dataUrl, {
120
+ fetchImpl,
121
+ requestConfirmationCode: async () => {
122
+ confirmationRequests += 1
123
+ return '123456'
124
+ },
125
+ })
126
+
127
+ // Then no authentication material is requested or posted to the wrong workspace host
128
+ await expect(promise).rejects.toThrow(/workspace host|session/)
129
+ expect(confirmationRequests).toBe(0)
130
+ expect(postRequests).toBe(0)
131
+ })
132
+
133
+ it('rejects a same-workspace confirmation URL under an unexpected path prefix', async () => {
134
+ // Given a same-host redirect at an attacker-chosen path that still contains "/z-app-" and ends with the QR secret
135
+ const dataUrl = await qrDataUrl()
136
+ let confirmationRequests = 0
137
+ let postRequests = 0
138
+ const attackerPath = `/attacker-chosen/z-app-junk/${QR_SECRET}`
139
+ const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
140
+ const url = typeof input === 'string' ? input : input.toString()
141
+ if ((init?.method ?? 'GET') === 'POST') postRequests += 1
142
+ if (url.startsWith('https://app.slack.com/t/')) {
143
+ return redirect(`https://${WORKSPACE}.slack.com${attackerPath}`)
144
+ }
145
+ if (url === `https://${WORKSPACE}.slack.com${attackerPath}`) return confirmationPage()
146
+ throw new Error(`unexpected fetch: ${url}`)
147
+ }) as typeof fetch
148
+
149
+ // When the unified login evaluates the confirmation URL
150
+ const promise = loginWithQr(dataUrl, {
151
+ fetchImpl,
152
+ requestConfirmationCode: async () => {
153
+ confirmationRequests += 1
154
+ return '123456'
155
+ },
156
+ })
157
+
158
+ // Then no confirmation code is requested and no material is posted to the wrong path
159
+ await expect(promise).rejects.toThrow(/session|confirmation|origin/)
160
+ expect(confirmationRequests).toBe(0)
161
+ expect(postRequests).toBe(0)
162
+ })
163
+
164
+ it('rejects an empty confirmation code without issuing a POST', async () => {
165
+ // Given a valid confirmation page and a caller that returns only whitespace
166
+ const dataUrl = await qrDataUrl()
167
+ let postRequests = 0
168
+ const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
169
+ const url = typeof input === 'string' ? input : input.toString()
170
+ const method = init?.method ?? 'GET'
171
+ if (method === 'POST') postRequests += 1
172
+ if (url.startsWith('https://app.slack.com/t/')) {
173
+ return redirect(`https://${WORKSPACE}.slack.com/login/${QR_SECRET}`)
174
+ }
175
+ if (url.includes(`/login/${QR_SECRET}`)) return confirmationPage()
176
+ throw new Error(`unexpected fetch: ${method} ${url}`)
177
+ }) as typeof fetch
178
+
179
+ // When login receives the empty code
180
+ const promise = loginWithQr(dataUrl, {
181
+ fetchImpl,
182
+ requestConfirmationCode: async () => ' ',
183
+ })
184
+
185
+ // Then login fails before sending authentication material
186
+ await expect(promise).rejects.toThrow(/session|confirmation/)
187
+ expect(postRequests).toBe(0)
188
+ })
189
+
190
+ it('keeps debug output free of QR, email, code, and cookie secrets', async () => {
191
+ // Given a confirmation flow containing secret-bearing URLs and values
192
+ const dataUrl = await qrDataUrl()
193
+ const messages: string[] = []
194
+ const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
195
+ const url = typeof input === 'string' ? input : input.toString()
196
+ const method = init?.method ?? 'GET'
197
+ if (method === 'GET' && url.startsWith('https://app.slack.com/t/')) {
198
+ return redirect(`https://${WORKSPACE}.slack.com/login/${QR_SECRET}?email=user%40acme.com`)
199
+ }
200
+ if (method === 'GET' && url.includes(`/login/${QR_SECRET}`)) return confirmationPage()
201
+ if (method === 'POST') {
202
+ return redirect('https://slack.com/checkcookie', [`d=${D_COOKIE}; Domain=.slack.com; Path=/`])
203
+ }
204
+ if (url.includes('/ssb/redirect')) {
205
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
206
+ }
207
+ throw new Error(`unexpected fetch: ${method} ${url}`)
208
+ }) as typeof fetch
209
+
210
+ // When login emits debug messages
211
+ await loginWithQr(dataUrl, {
212
+ fetchImpl,
213
+ requestConfirmationCode: async () => '123456',
214
+ debug: (message) => messages.push(message),
215
+ })
216
+
217
+ // Then no authentication material appears in the debug channel
218
+ const output = messages.join('\n')
219
+ for (const secret of [QR_SECRET, MAGIC_LOGIN, 'user@acme.com', '123456', D_COOKIE, TOKEN]) {
220
+ expect(output).not.toContain(secret)
221
+ }
222
+ })
223
+
224
+ it('respects host, path, and deletion cookie semantics across redirects', async () => {
225
+ // Given scoped cookies with the same name plus an explicitly deleted cookie
226
+ const dataUrl = await qrDataUrl()
227
+ const observedCookies: Record<string, string | null> = {}
228
+ const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
229
+ const url = typeof input === 'string' ? input : input.toString()
230
+ observedCookies[url] = new Headers(init?.headers).get('cookie')
231
+ if (url.startsWith('https://app.slack.com/t/')) {
232
+ return redirect(`https://${WORKSPACE}.slack.com/login/${QR_SECRET}`, [
233
+ 'hostOnly=app; Path=/',
234
+ 'shared=root; Domain=.slack.com; Path=/',
235
+ 'shared=login; Domain=.slack.com; Path=/login',
236
+ 'deleted=gone; Domain=.slack.com; Path=/; Max-Age=0',
237
+ ])
238
+ }
239
+ if (url.includes(`/login/${QR_SECRET}`)) return new Response('<title>Link Expired</title>', { status: 200 })
240
+ throw new Error(`unexpected fetch: ${url}`)
241
+ }) as typeof fetch
242
+
243
+ // When redirects move from app.slack.com to the workspace login path
244
+ await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/expired/)
245
+
246
+ // Then only cookies valid for that destination host and path are sent
247
+ const destinationCookies = observedCookies[`https://${WORKSPACE}.slack.com/login/${QR_SECRET}`]
248
+ expect(destinationCookies).not.toContain('hostOnly=app')
249
+ expect(destinationCookies).toContain('shared=root')
250
+ expect(destinationCookies).toContain('shared=login')
251
+ expect(destinationCookies).not.toContain('deleted=gone')
252
+ })
253
+
254
+ it('discards a malformed Set-Cookie header without aborting a valid session cookie', async () => {
255
+ // Given a redirect that emits an unparseable cookie ahead of the valid d session cookie
256
+ const dataUrl = await qrDataUrl()
257
+ const fetchImpl = (async (input: string | URL) => {
258
+ const url = typeof input === 'string' ? input : input.toString()
259
+ if (url.startsWith('https://app.slack.com/t/')) {
260
+ return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`, [
261
+ 'bogus=1; Domain=.com; Path=/',
262
+ `d=${D_COOKIE}; Domain=.slack.com; Path=/`,
263
+ ])
264
+ }
265
+ if (url.includes('/z-app-secret')) return new Response(null, { status: 200 })
266
+ if (url.includes('/ssb/redirect')) {
267
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
268
+ }
269
+ throw new Error(`unexpected fetch: ${url}`)
270
+ }) as typeof fetch
271
+
272
+ // When login processes the response
273
+ const session = await loginWithQr(dataUrl, { fetchImpl })
274
+
275
+ // Then the malformed cookie is silently discarded and the valid session cookie is captured
276
+ expect(session.cookie).toBe(D_COOKIE)
277
+ expect(session.token).toBe(TOKEN)
278
+ })
279
+
280
+ it('does not retain a deleted Slack session cookie', async () => {
281
+ // Given a redirect that creates and then deletes the d session cookie
282
+ const dataUrl = await qrDataUrl()
283
+ const fetchImpl = (async (input: string | URL) => {
284
+ const url = typeof input === 'string' ? input : input.toString()
285
+ if (url.startsWith('https://app.slack.com/t/')) {
286
+ return redirect(`https://${WORKSPACE}.slack.com/end`, [
287
+ `d=${D_COOKIE}; Domain=.slack.com; Path=/`,
288
+ 'd=; Domain=.slack.com; Path=/; Max-Age=0',
289
+ ])
290
+ }
291
+ if (url === `https://${WORKSPACE}.slack.com/end`) return new Response(null, { status: 200 })
292
+ throw new Error(`unexpected fetch: ${url}`)
293
+ }) as typeof fetch
294
+
295
+ // When login processes the deleted session cookie
296
+ const promise = loginWithQr(dataUrl, { fetchImpl })
297
+
298
+ // Then the deleted session cannot authenticate the user
299
+ await expect(promise).rejects.toThrow(/session/)
300
+ })
301
+
302
+ it('does not capture a host-only d cookie set from app.slack.com when querying the workspace host', async () => {
303
+ // Given a d cookie set with no Domain attribute (host-only) on app.slack.com and a working token endpoint
304
+ const dataUrl = await qrDataUrl()
305
+ const fetchImpl = (async (input: string | URL) => {
306
+ const url = typeof input === 'string' ? input : input.toString()
307
+ if (url.startsWith('https://app.slack.com/t/')) {
308
+ return redirect(`https://${WORKSPACE}.slack.com/end`, [`d=${D_COOKIE}; Path=/; HttpOnly`])
309
+ }
310
+ if (url === `https://${WORKSPACE}.slack.com/end`) return new Response(null, { status: 200 })
311
+ if (url === `https://${WORKSPACE}.slack.com/ssb/redirect`) {
312
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
313
+ }
314
+ throw new Error(`unexpected fetch: ${url}`)
315
+ }) as typeof fetch
316
+
317
+ // When login queries the jar for the workspace-scoped session cookie
318
+ const promise = loginWithQr(dataUrl, { fetchImpl })
319
+
320
+ // Then the host-only cookie is not treated as a valid workspace session, so auth fails before minting a token
321
+ await expect(promise).rejects.toThrow(/Could not establish|expired/)
322
+ })
323
+
324
+ it('does not capture a d cookie scoped to a path outside the token URL', async () => {
325
+ // Given a d cookie scoped to a path the token URL does not fall under and a working token endpoint
326
+ const dataUrl = await qrDataUrl()
327
+ const fetchImpl = (async (input: string | URL) => {
328
+ const url = typeof input === 'string' ? input : input.toString()
329
+ if (url.startsWith('https://app.slack.com/t/')) {
330
+ return redirect(`https://${WORKSPACE}.slack.com/end`, [`d=${D_COOKIE}; Domain=.slack.com; Path=/nowhere`])
331
+ }
332
+ if (url === `https://${WORKSPACE}.slack.com/end`) return new Response(null, { status: 200 })
333
+ if (url === `https://${WORKSPACE}.slack.com/ssb/redirect`) {
334
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
335
+ }
336
+ throw new Error(`unexpected fetch: ${url}`)
337
+ }) as typeof fetch
338
+
339
+ // When login queries the jar for a cookie sendable to /ssb/redirect
340
+ const promise = loginWithQr(dataUrl, { fetchImpl })
341
+
342
+ // Then the path-restricted cookie is not returned for the token URL, so auth fails before minting a token
343
+ await expect(promise).rejects.toThrow(/Could not establish|expired/)
344
+ })
345
+
346
+ it('treats a d cookie with a past Expires date as deleted', async () => {
347
+ // Given a d cookie set and then invalidated via an Expires date in the past, plus a working token endpoint
348
+ const dataUrl = await qrDataUrl()
349
+ const fetchImpl = (async (input: string | URL) => {
350
+ const url = typeof input === 'string' ? input : input.toString()
351
+ if (url.startsWith('https://app.slack.com/t/')) {
352
+ return redirect(`https://${WORKSPACE}.slack.com/end`, [
353
+ `d=${D_COOKIE}; Domain=.slack.com; Path=/`,
354
+ 'd=; Domain=.slack.com; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
355
+ ])
356
+ }
357
+ if (url === `https://${WORKSPACE}.slack.com/end`) return new Response(null, { status: 200 })
358
+ if (url === `https://${WORKSPACE}.slack.com/ssb/redirect`) {
359
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
360
+ }
361
+ throw new Error(`unexpected fetch: ${url}`)
362
+ }) as typeof fetch
363
+
364
+ // When login processes the expired cookie
365
+ const promise = loginWithQr(dataUrl, { fetchImpl })
366
+
367
+ // Then the expired session is dropped and auth fails before minting a token
368
+ await expect(promise).rejects.toThrow(/Could not establish|expired/)
369
+ })
370
+
371
+ it('honors a d cookie deletion issued from slack.com root during a later hop', async () => {
372
+ // Given a workspace-issued d cookie followed by a slack.com-root deletion for the same .slack.com scope
373
+ const dataUrl = await qrDataUrl()
374
+ const fetchImpl = (async (input: string | URL) => {
375
+ const url = typeof input === 'string' ? input : input.toString()
376
+ if (url.startsWith('https://app.slack.com/t/')) {
377
+ return redirect(`https://${WORKSPACE}.slack.com/hop-2`, [`d=${D_COOKIE}; Domain=.slack.com; Path=/`])
378
+ }
379
+ if (url === `https://${WORKSPACE}.slack.com/hop-2`) {
380
+ return redirect('https://slack.com/checkcookie')
381
+ }
382
+ if (url === 'https://slack.com/checkcookie') {
383
+ return redirect(`https://${WORKSPACE}.slack.com/end`, ['d=; Domain=.slack.com; Path=/; Max-Age=0'])
384
+ }
385
+ if (url === `https://${WORKSPACE}.slack.com/end`) return new Response(null, { status: 200 })
386
+ if (url === `https://${WORKSPACE}.slack.com/ssb/redirect`) {
387
+ return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
388
+ }
389
+ throw new Error(`unexpected fetch: ${url}`)
390
+ }) as typeof fetch
391
+
392
+ // When login processes the deletion issued from the slack.com root
393
+ const promise = loginWithQr(dataUrl, { fetchImpl })
394
+
395
+ // Then the earlier workspace cookie is not resurrected and auth fails before minting a token
396
+ await expect(promise).rejects.toThrow(/Could not establish|expired/)
397
+ })
398
+ })
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'bun:test'
3
3
  import QRCode from 'qrcode'
4
4
 
5
5
  import { SlackError } from '@/platforms/slack/client'
6
- import { isSlackHost, loginWithQr, parseDCookie } from '@/platforms/slack/qr-http-login'
6
+ import { isSlackHost, loginWithQr } from '@/platforms/slack/qr-http-login'
7
7
 
8
8
  const WORKSPACE = 'acme'
9
9
  const ZAPP_URL = `https://app.slack.com/t/${WORKSPACE}/login/z-app-1-2-abcdef?src=qr_code&user_id=U1&team_id=T1`
@@ -26,20 +26,6 @@ afterEach(() => {
26
26
  globalThis.fetch = originalFetch
27
27
  })
28
28
 
29
- describe('parseDCookie', () => {
30
- it('extracts the d cookie value from a Set-Cookie header', () => {
31
- expect(parseDCookie(`d=${D_COOKIE}; Path=/; HttpOnly; Secure`)).toBe(D_COOKIE)
32
- })
33
-
34
- it('ignores a non-d cookie', () => {
35
- expect(parseDCookie('x=somevalue; Path=/')).toBeNull()
36
- })
37
-
38
- it('ignores a d cookie that is not an xoxd value', () => {
39
- expect(parseDCookie('d=plainvalue; Path=/')).toBeNull()
40
- })
41
- })
42
-
43
29
  describe('isSlackHost', () => {
44
30
  it('accepts slack.com and its subdomains over https', () => {
45
31
  expect(isSlackHost('https://slack.com/checkcookie')).toBe(true)
@@ -68,7 +54,10 @@ describe('loginWithQr', () => {
68
54
  return redirect(`https://${WORKSPACE}.slack.com/app-redir/login/x`)
69
55
  }
70
56
  if (url.includes('/app-redir/login/')) {
71
- return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`, `d=${D_COOKIE}; HttpOnly`)
57
+ return redirect(
58
+ `https://${WORKSPACE}.slack.com/z-app-secret`,
59
+ `d=${D_COOKIE}; Domain=.slack.com; Path=/; HttpOnly`,
60
+ )
72
61
  }
73
62
  if (url.includes('/z-app-secret')) {
74
63
  return redirect('https://slack.com/checkcookie?redir=x')
@@ -102,7 +91,10 @@ describe('loginWithQr', () => {
102
91
  requests.push({ url, cookie: headers.get('cookie') })
103
92
 
104
93
  if (url.startsWith('https://app.slack.com/t/')) {
105
- return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`, `d=${D_COOKIE}; HttpOnly`)
94
+ return redirect(
95
+ `https://${WORKSPACE}.slack.com/z-app-secret`,
96
+ `d=${D_COOKIE}; Domain=.slack.com; Path=/; HttpOnly`,
97
+ )
106
98
  }
107
99
  if (url.includes('/z-app-secret')) {
108
100
  return redirect('https://idp.example.com/saml/login')
@@ -186,7 +178,7 @@ describe('loginWithQr', () => {
186
178
  const stepAFetch = (async (input: string | URL) => {
187
179
  const url = typeof input === 'string' ? input : input.toString()
188
180
  if (url.startsWith('https://app.slack.com/t/')) {
189
- return redirect(`https://${WORKSPACE}.slack.com/end`, `d=${D_COOKIE}; HttpOnly`)
181
+ return redirect(`https://${WORKSPACE}.slack.com/end`, `d=${D_COOKIE}; Domain=.slack.com; Path=/; HttpOnly`)
190
182
  }
191
183
  return new Response(null, { status: 200 })
192
184
  }) as typeof fetch