agent-messenger 2.32.0 → 2.32.2
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.
- package/.claude-plugin/plugin.json +1 -1
- package/bun.lock +7 -0
- package/dist/package.json +2 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
- package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/channel.js +3 -2
- package/dist/src/platforms/discord/commands/channel.js.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.js +7 -6
- package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +17 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +36 -0
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +2 -0
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/slack/index.d.ts +1 -0
- package/dist/src/platforms/slack/index.d.ts.map +1 -1
- package/dist/src/platforms/slack/index.js.map +1 -1
- package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
- package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-confirmation.js +68 -0
- package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
- package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js +88 -53
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/docs/content/docs/cli/channeltalk.mdx +4 -4
- package/package.json +2 -1
- package/skills/agent-channeltalk/SKILL.md +5 -5
- package/skills/agent-channeltalk/references/authentication.md +9 -5
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +8 -4
- package/skills/agent-discord/references/common-patterns.md +7 -3
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +25 -2
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/channeltalk/token-extractor.test.ts +235 -0
- package/src/platforms/channeltalk/token-extractor.ts +81 -22
- package/src/platforms/discord/commands/channel.test.ts +33 -23
- package/src/platforms/discord/commands/channel.ts +3 -2
- package/src/platforms/discord/commands/snapshot.test.ts +80 -2
- package/src/platforms/discord/commands/snapshot.ts +7 -6
- package/src/platforms/discord/types.test.ts +45 -0
- package/src/platforms/discord/types.ts +41 -0
- package/src/platforms/kakaotalk/client.test.ts +29 -0
- package/src/platforms/kakaotalk/client.ts +1 -0
- package/src/platforms/slack/index.ts +1 -0
- package/src/platforms/slack/qr-confirmation.ts +86 -0
- package/src/platforms/slack/qr-cookie-jar.ts +54 -0
- package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
- package/src/platforms/slack/qr-http-login.test.ts +10 -18
- 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('"', '"')
|
|
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
|
|
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(
|
|
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(
|
|
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
|