agent-messenger 2.23.5 → 2.24.0
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/README.md +12 -1
- package/bun.lock +10 -1
- package/dist/package.json +4 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +56 -0
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts +1 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.js +2 -2
- package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
- package/dist/src/platforms/slack/index.d.ts +4 -0
- package/dist/src/platforms/slack/index.d.ts.map +1 -1
- package/dist/src/platforms/slack/index.js +2 -0
- package/dist/src/platforms/slack/index.js.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts +14 -0
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-http-login.js +90 -0
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -0
- package/dist/src/platforms/slack/qr-login.d.ts +10 -0
- package/dist/src/platforms/slack/qr-login.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-login.js +72 -0
- package/dist/src/platforms/slack/qr-login.js.map +1 -0
- package/dist/src/platforms/webex/client.d.ts +1 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +29 -16
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/vendor/linejs/base/request/mod.js +1 -1
- package/dist/src/vendor/linejs/base/request/mod.test.ts +54 -0
- package/docs/content/docs/cli/slack.mdx +22 -0
- package/docs/content/docs/sdk/slack.mdx +15 -0
- package/package.json +4 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/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 +45 -1
- package/skills/agent-slack/references/authentication.md +29 -0
- 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/slack/commands/auth.ts +73 -0
- package/src/platforms/slack/ensure-auth.ts +6 -2
- package/src/platforms/slack/index.test.ts +10 -0
- package/src/platforms/slack/index.ts +4 -0
- package/src/platforms/slack/qr-http-login.test.ts +157 -0
- package/src/platforms/slack/qr-http-login.ts +120 -0
- package/src/platforms/slack/qr-login.test.ts +103 -0
- package/src/platforms/slack/qr-login.ts +90 -0
- package/src/platforms/webex/client.test.ts +63 -1
- package/src/platforms/webex/client.ts +35 -17
- package/src/vendor/linejs/base/request/mod.js +1 -1
- package/src/vendor/linejs/base/request/mod.test.ts +54 -0
|
@@ -9,6 +9,7 @@ import { debug } from '@/shared/utils/stderr'
|
|
|
9
9
|
import { SlackClient, SlackError } from '../client'
|
|
10
10
|
import { CredentialManager } from '../credential-manager'
|
|
11
11
|
import { refreshCookie, tryWebTokenRefresh } from '../ensure-auth'
|
|
12
|
+
import { loginWithQr } from '../qr-http-login'
|
|
12
13
|
import { type ExtractedWorkspace, TokenExtractor } from '../token-extractor'
|
|
13
14
|
|
|
14
15
|
export function formatCredentialDebug(ws: ExtractedWorkspace, showSecrets?: boolean): string {
|
|
@@ -230,6 +231,70 @@ async function statusAction(options: { pretty?: boolean }): Promise<void> {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
async function qrAction(imageArg: string | undefined, options: { pretty?: boolean; debug?: boolean }): Promise<void> {
|
|
235
|
+
try {
|
|
236
|
+
const dataUrl = imageArg ?? (await readStdin())
|
|
237
|
+
if (!dataUrl) {
|
|
238
|
+
console.log(
|
|
239
|
+
formatOutput(
|
|
240
|
+
{
|
|
241
|
+
error: 'No QR image provided. Pass the copied QR image data URL as an argument, or pipe it via stdin.',
|
|
242
|
+
hint: 'In Slack: your name (top-left) → "Sign in on mobile" → right-click the QR → Copy Image Address.',
|
|
243
|
+
},
|
|
244
|
+
options.pretty,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
|
|
251
|
+
const session = await loginWithQr(dataUrl, { debug: debugLog })
|
|
252
|
+
|
|
253
|
+
const client = await new SlackClient().login({ token: session.token, cookie: session.cookie })
|
|
254
|
+
const authInfo = await client.testAuth()
|
|
255
|
+
|
|
256
|
+
const credManager = new CredentialManager()
|
|
257
|
+
const workspace: ExtractedWorkspace = {
|
|
258
|
+
workspace_id: authInfo.team_id,
|
|
259
|
+
workspace_name: authInfo.team || session.workspace,
|
|
260
|
+
token: session.token,
|
|
261
|
+
cookie: session.cookie,
|
|
262
|
+
}
|
|
263
|
+
await credManager.setWorkspace(workspace)
|
|
264
|
+
|
|
265
|
+
const config = await credManager.load()
|
|
266
|
+
if (!config.current_workspace) {
|
|
267
|
+
await credManager.setCurrentWorkspace(workspace.workspace_id)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(
|
|
271
|
+
formatOutput(
|
|
272
|
+
{
|
|
273
|
+
workspace: `${workspace.workspace_id}/${workspace.workspace_name}`,
|
|
274
|
+
user: authInfo.user,
|
|
275
|
+
current: (await credManager.load()).current_workspace,
|
|
276
|
+
},
|
|
277
|
+
options.pretty,
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
} catch (error) {
|
|
281
|
+
handleError(error as Error)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readStdin(): Promise<string> {
|
|
286
|
+
if (process.stdin.isTTY) return Promise.resolve('')
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
let data = ''
|
|
289
|
+
process.stdin.setEncoding('utf8')
|
|
290
|
+
process.stdin.on('data', (chunk) => {
|
|
291
|
+
data += chunk
|
|
292
|
+
})
|
|
293
|
+
process.stdin.on('end', () => resolve(data))
|
|
294
|
+
process.stdin.on('error', () => resolve(data))
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
233
298
|
export function getExtractionErrorMessage(failureReasons: string[]): string {
|
|
234
299
|
if (failureReasons.includes('missing_cookie')) {
|
|
235
300
|
return 'Cookie extraction failed. Grant Keychain access when prompted, and make sure you are signed into Slack in the desktop app or a supported Chromium browser.'
|
|
@@ -260,6 +325,14 @@ export const authCommand = new Command('auth')
|
|
|
260
325
|
.option('--unsafely-show-secrets', 'Show full token and cookie values in debug output')
|
|
261
326
|
.action((options: BrowserProfileOption & Parameters<typeof extractAction>[0]) => extractAction(options)),
|
|
262
327
|
)
|
|
328
|
+
.addCommand(
|
|
329
|
+
new Command('qr')
|
|
330
|
+
.description('Sign in by pasting a QR code from Slack\u2019s "Sign in on mobile" screen')
|
|
331
|
+
.argument('[image]', 'QR image data URL (data:image/png;base64,...); read from stdin if omitted')
|
|
332
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
333
|
+
.option('--debug', 'Show debug output for troubleshooting')
|
|
334
|
+
.action(qrAction),
|
|
335
|
+
)
|
|
263
336
|
.addCommand(
|
|
264
337
|
new Command('logout')
|
|
265
338
|
.description('Logout from workspace')
|
|
@@ -145,9 +145,13 @@ async function refreshAndVerify(cookie: string, domain: string, fallbackName: st
|
|
|
145
145
|
|
|
146
146
|
const TOKEN_REGEX = /"api_token":"(xoxc-[a-zA-Z0-9-]+)"/
|
|
147
147
|
|
|
148
|
-
export async function refreshTokenFromWeb(
|
|
148
|
+
export async function refreshTokenFromWeb(
|
|
149
|
+
domain: string,
|
|
150
|
+
cookie: string,
|
|
151
|
+
fetchImpl: typeof fetch = fetch,
|
|
152
|
+
): Promise<string | null> {
|
|
149
153
|
try {
|
|
150
|
-
const response = await
|
|
154
|
+
const response = await fetchImpl(`https://${domain}.slack.com/ssb/redirect`, {
|
|
151
155
|
headers: { Cookie: `d=${cookie}` },
|
|
152
156
|
redirect: 'follow',
|
|
153
157
|
})
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
SlackUserSchema,
|
|
13
13
|
WorkspaceCredentialsSchema,
|
|
14
14
|
ConfigSchema,
|
|
15
|
+
decodeSlackQr,
|
|
16
|
+
loginWithQr,
|
|
15
17
|
} from '@/platforms/slack/index'
|
|
16
18
|
|
|
17
19
|
it('SlackClient is exported from barrel', () => {
|
|
@@ -57,3 +59,11 @@ it('WorkspaceCredentialsSchema is exported from barrel', () => {
|
|
|
57
59
|
it('ConfigSchema is exported from barrel', () => {
|
|
58
60
|
expect(typeof ConfigSchema.parse).toBe('function')
|
|
59
61
|
})
|
|
62
|
+
|
|
63
|
+
it('loginWithQr is exported from barrel', () => {
|
|
64
|
+
expect(typeof loginWithQr).toBe('function')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('decodeSlackQr is exported from barrel', () => {
|
|
68
|
+
expect(typeof decodeSlackQr).toBe('function')
|
|
69
|
+
})
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { SlackClient, SlackError } from './client'
|
|
2
2
|
export { SlackCredentialManager, CredentialManager } from './credential-manager'
|
|
3
3
|
export { SlackListener } from './listener'
|
|
4
|
+
export { loginWithQr } from './qr-http-login'
|
|
5
|
+
export type { QrLoginOptions, QrSession } from './qr-http-login'
|
|
6
|
+
export { decodeSlackQr } from './qr-login'
|
|
7
|
+
export type { SlackQrLogin } from './qr-login'
|
|
4
8
|
export type {
|
|
5
9
|
SlackBookmark,
|
|
6
10
|
SlackChannel,
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import QRCode from 'qrcode'
|
|
4
|
+
|
|
5
|
+
import { SlackError } from '@/platforms/slack/client'
|
|
6
|
+
import { isSlackHost, loginWithQr, parseDCookie } from '@/platforms/slack/qr-http-login'
|
|
7
|
+
|
|
8
|
+
const WORKSPACE = 'acme'
|
|
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`
|
|
10
|
+
const D_COOKIE = 'xoxd-abc%2Bdef123'
|
|
11
|
+
const TOKEN = `xoxc-1-2-3-${'a'.repeat(64)}`
|
|
12
|
+
|
|
13
|
+
async function qrDataUrl(text: string): Promise<string> {
|
|
14
|
+
return QRCode.toDataURL(text, { margin: 2, width: 256 })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function redirect(location: string, setCookie?: string): Response {
|
|
18
|
+
const headers = new Headers({ location })
|
|
19
|
+
if (setCookie) headers.append('set-cookie', setCookie)
|
|
20
|
+
return new Response(null, { status: 302, headers })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const originalFetch = globalThis.fetch
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch
|
|
27
|
+
})
|
|
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
|
+
describe('isSlackHost', () => {
|
|
44
|
+
it('accepts slack.com and its subdomains over https', () => {
|
|
45
|
+
expect(isSlackHost('https://slack.com/checkcookie')).toBe(true)
|
|
46
|
+
expect(isSlackHost('https://app.slack.com/t/acme/login/z-app-1')).toBe(true)
|
|
47
|
+
expect(isSlackHost('https://acme.slack.com/ssb/redirect')).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects non-Slack hosts, http, and lookalikes', () => {
|
|
51
|
+
expect(isSlackHost('https://idp.example.com/saml')).toBe(false)
|
|
52
|
+
expect(isSlackHost('http://app.slack.com/x')).toBe(false)
|
|
53
|
+
expect(isSlackHost('https://slack.com.evil.com/x')).toBe(false)
|
|
54
|
+
expect(isSlackHost('not a url')).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('loginWithQr', () => {
|
|
59
|
+
it('captures cookie and mints a token through a single injected fetch', async () => {
|
|
60
|
+
// Given a QR encoding the z-app login URL
|
|
61
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
62
|
+
|
|
63
|
+
// And a single fetchImpl serving both the redirect chain and the token page
|
|
64
|
+
// (no globalThis.fetch monkeypatching)
|
|
65
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
66
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
67
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
68
|
+
return redirect(`https://${WORKSPACE}.slack.com/app-redir/login/x`)
|
|
69
|
+
}
|
|
70
|
+
if (url.includes('/app-redir/login/')) {
|
|
71
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`, `d=${D_COOKIE}; HttpOnly`)
|
|
72
|
+
}
|
|
73
|
+
if (url.includes('/z-app-secret')) {
|
|
74
|
+
return redirect('https://slack.com/checkcookie?redir=x')
|
|
75
|
+
}
|
|
76
|
+
if (url.includes('/checkcookie')) {
|
|
77
|
+
return new Response(null, { status: 200 })
|
|
78
|
+
}
|
|
79
|
+
if (url.includes('/ssb/redirect')) {
|
|
80
|
+
return new Response(`<script>var boot_data={"api_token":"${TOKEN}"}</script>`, { status: 200 })
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`unexpected fetch: ${url}`)
|
|
83
|
+
}) as typeof fetch
|
|
84
|
+
|
|
85
|
+
// When logging in with only fetchImpl
|
|
86
|
+
const session = await loginWithQr(dataUrl, { fetchImpl })
|
|
87
|
+
|
|
88
|
+
// Then the cookie, token, and workspace are returned
|
|
89
|
+
expect(session.cookie).toBe(D_COOKIE)
|
|
90
|
+
expect(session.token).toBe(TOKEN)
|
|
91
|
+
expect(session.workspace).toBe(WORKSPACE)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('never sends the d cookie to a non-Slack redirect target', async () => {
|
|
95
|
+
// Given a chain that sets the d cookie, then redirects off-domain to an IdP
|
|
96
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
97
|
+
const requests: Array<{ url: string; cookie: string | null }> = []
|
|
98
|
+
|
|
99
|
+
const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
|
|
100
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
101
|
+
const headers = new Headers(init?.headers)
|
|
102
|
+
requests.push({ url, cookie: headers.get('cookie') })
|
|
103
|
+
|
|
104
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
105
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`, `d=${D_COOKIE}; HttpOnly`)
|
|
106
|
+
}
|
|
107
|
+
if (url.includes('/z-app-secret')) {
|
|
108
|
+
return redirect('https://idp.example.com/saml/login')
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`d cookie leaked to non-Slack host: ${url}`)
|
|
111
|
+
}) as typeof fetch
|
|
112
|
+
|
|
113
|
+
// When logging in, the off-domain redirect is refused (so no token is minted)
|
|
114
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/expired|client token/)
|
|
115
|
+
|
|
116
|
+
// Then the IdP host was never requested at all
|
|
117
|
+
expect(requests.some((r) => r.url.includes('idp.example.com'))).toBe(false)
|
|
118
|
+
// And no request ever carried the d session cookie to a non-Slack host
|
|
119
|
+
for (const r of requests) {
|
|
120
|
+
if (r.cookie?.includes('xoxd-')) {
|
|
121
|
+
expect(isSlackHost(r.url)).toBe(true)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('fails with qr_session_failed when no d cookie is set', async () => {
|
|
127
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
128
|
+
const stepAFetch = (async () => new Response(null, { status: 200 })) as typeof fetch
|
|
129
|
+
|
|
130
|
+
const promise = loginWithQr(dataUrl, { fetchImpl: stepAFetch })
|
|
131
|
+
await expect(promise).rejects.toThrow(SlackError)
|
|
132
|
+
await expect(promise).rejects.toThrow(/expired/)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('fails with qr_token_failed when the token cannot be retrieved', async () => {
|
|
136
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
137
|
+
|
|
138
|
+
globalThis.fetch = (async () => new Response('<html>no token here</html>', { status: 200 })) as typeof fetch
|
|
139
|
+
const stepAFetch = (async (input: string | URL) => {
|
|
140
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
141
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
142
|
+
return redirect(`https://${WORKSPACE}.slack.com/end`, `d=${D_COOKIE}; HttpOnly`)
|
|
143
|
+
}
|
|
144
|
+
return new Response(null, { status: 200 })
|
|
145
|
+
}) as typeof fetch
|
|
146
|
+
|
|
147
|
+
const promise = loginWithQr(dataUrl, { fetchImpl: stepAFetch })
|
|
148
|
+
await expect(promise).rejects.toThrow(/client token could not be retrieved/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rejects a QR that is not a Slack login link', async () => {
|
|
152
|
+
const dataUrl = await qrDataUrl('https://example.com/not-slack')
|
|
153
|
+
await expect(loginWithQr(dataUrl, { fetchImpl: (async () => new Response()) as typeof fetch })).rejects.toThrow(
|
|
154
|
+
SlackError,
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { SlackError } from './client'
|
|
2
|
+
import { refreshTokenFromWeb } from './ensure-auth'
|
|
3
|
+
import { decodeSlackQr } from './qr-login'
|
|
4
|
+
|
|
5
|
+
export interface QrSession {
|
|
6
|
+
token: string
|
|
7
|
+
cookie: string
|
|
8
|
+
workspace: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QrLoginOptions {
|
|
12
|
+
fetchImpl?: typeof fetch
|
|
13
|
+
maxRedirects?: number
|
|
14
|
+
debug?: (message: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BROWSER_USER_AGENT =
|
|
18
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
|
|
19
|
+
const DEFAULT_MAX_REDIRECTS = 10
|
|
20
|
+
|
|
21
|
+
export async function loginWithQr(dataUrl: string, options: QrLoginOptions = {}): Promise<QrSession> {
|
|
22
|
+
const login = decodeSlackQr(dataUrl.trim())
|
|
23
|
+
const debug = options.debug
|
|
24
|
+
debug?.(`Decoded QR for workspace ${login.workspace}`)
|
|
25
|
+
|
|
26
|
+
const cookie = await captureDCookie(login.url, options)
|
|
27
|
+
if (!cookie) {
|
|
28
|
+
throw new SlackError(
|
|
29
|
+
'Could not establish a Slack session from the QR code. The link may have expired — generate a new QR code and try again.',
|
|
30
|
+
'qr_session_failed',
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
debug?.('Captured session cookie')
|
|
34
|
+
|
|
35
|
+
const token = await refreshTokenFromWeb(login.workspace, cookie, options.fetchImpl ?? fetch)
|
|
36
|
+
if (!token) {
|
|
37
|
+
throw new SlackError(
|
|
38
|
+
'Slack session was established but the client token could not be retrieved.',
|
|
39
|
+
'qr_token_failed',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
debug?.('Retrieved client token')
|
|
43
|
+
|
|
44
|
+
return { token, cookie, workspace: login.workspace }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function captureDCookie(startUrl: string, options: QrLoginOptions): Promise<string | null> {
|
|
48
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
49
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
|
50
|
+
const debug = options.debug
|
|
51
|
+
|
|
52
|
+
let url = startUrl
|
|
53
|
+
let dCookie: string | null = null
|
|
54
|
+
const cookieJar: string[] = []
|
|
55
|
+
|
|
56
|
+
for (let hop = 0; hop < maxRedirects; hop++) {
|
|
57
|
+
// Only ever send captured cookies (including the d session cookie) to Slack
|
|
58
|
+
// hosts. A redirect to an off-domain host (e.g. an SSO IdP) must never
|
|
59
|
+
// receive the d=xoxd- session cookie.
|
|
60
|
+
if (!isSlackHost(url)) {
|
|
61
|
+
debug?.(`hop ${hop}: refusing non-Slack host`)
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const response = await doFetch(url, {
|
|
66
|
+
redirect: 'manual',
|
|
67
|
+
headers: {
|
|
68
|
+
'User-Agent': BROWSER_USER_AGENT,
|
|
69
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
70
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
71
|
+
...(cookieJar.length ? { Cookie: cookieJar.join('; ') } : {}),
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
for (const setCookie of getSetCookies(response)) {
|
|
76
|
+
const value = parseDCookie(setCookie)
|
|
77
|
+
if (value) dCookie = value
|
|
78
|
+
const pair = setCookie.split(';')[0]?.trim()
|
|
79
|
+
if (pair) cookieJar.push(pair)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
debug?.(`hop ${hop}: ${response.status}`)
|
|
83
|
+
|
|
84
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null
|
|
85
|
+
if (!location) break
|
|
86
|
+
|
|
87
|
+
const next = new URL(location, url).toString()
|
|
88
|
+
if (!isSlackHost(next)) {
|
|
89
|
+
debug?.(`hop ${hop}: redirect target is not a Slack host, stopping`)
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
url = next
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return dCookie
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isSlackHost(rawUrl: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
const { protocol, hostname } = new URL(rawUrl)
|
|
101
|
+
if (protocol !== 'https:') return false
|
|
102
|
+
return hostname === 'slack.com' || hostname.endsWith('.slack.com')
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseDCookie(setCookieHeader: string): string | null {
|
|
109
|
+
const match = setCookieHeader.match(/(?:^|,\s*)d=(xoxd-[^;]+)/)
|
|
110
|
+
return match ? match[1] : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getSetCookies(response: Response): string[] {
|
|
114
|
+
const withGetter = response.headers as Headers & { getSetCookie?: () => string[] }
|
|
115
|
+
if (typeof withGetter.getSetCookie === 'function') {
|
|
116
|
+
return withGetter.getSetCookie()
|
|
117
|
+
}
|
|
118
|
+
const single = response.headers.get('set-cookie')
|
|
119
|
+
return single ? [single] : []
|
|
120
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import QRCode from 'qrcode'
|
|
4
|
+
|
|
5
|
+
import { SlackError } from '@/platforms/slack/client'
|
|
6
|
+
import { decodeQrImage, decodeSlackQr, parseSlackQrUrl } from '@/platforms/slack/qr-login'
|
|
7
|
+
|
|
8
|
+
const VALID_QR_URL =
|
|
9
|
+
'https://app.slack.com/t/acme/login/z-app-1234567890-1234567890-abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd?src=qr_code&user_id=U0123456789&team_id=T0123456789'
|
|
10
|
+
|
|
11
|
+
async function qrDataUrl(text: string): Promise<string> {
|
|
12
|
+
return QRCode.toDataURL(text, { margin: 2, width: 256 })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('parseSlackQrUrl', () => {
|
|
16
|
+
it('extracts workspace, team_id, and user_id from a valid Slack QR URL', () => {
|
|
17
|
+
// Given a real-shaped Slack mobile sign-in URL
|
|
18
|
+
// When parsed
|
|
19
|
+
const result = parseSlackQrUrl(VALID_QR_URL)
|
|
20
|
+
|
|
21
|
+
// Then the identifying fields are extracted
|
|
22
|
+
expect(result.workspace).toBe('acme')
|
|
23
|
+
expect(result.teamId).toBe('T0123456789')
|
|
24
|
+
expect(result.userId).toBe('U0123456789')
|
|
25
|
+
expect(result.url).toBe(VALID_QR_URL)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('allows missing optional query params', () => {
|
|
29
|
+
const url = 'https://app.slack.com/t/acme/login/z-app-1-2-abc'
|
|
30
|
+
const result = parseSlackQrUrl(url)
|
|
31
|
+
|
|
32
|
+
expect(result.workspace).toBe('acme')
|
|
33
|
+
expect(result.teamId).toBeNull()
|
|
34
|
+
expect(result.userId).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects a non-URL string', () => {
|
|
38
|
+
expect(() => parseSlackQrUrl('not a url')).toThrow(SlackError)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects a non-Slack host', () => {
|
|
42
|
+
expect(() => parseSlackQrUrl('https://evil.example.com/t/x/login/z-app-1')).toThrow(/not a Slack sign-in link/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('rejects http (non-https) URLs', () => {
|
|
46
|
+
expect(() => parseSlackQrUrl('http://app.slack.com/t/x/login/z-app-1')).toThrow(SlackError)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rejects a Slack URL that is not a mobile sign-in link', () => {
|
|
50
|
+
expect(() => parseSlackQrUrl('https://app.slack.com/client/T0123456789/C123')).toThrow(
|
|
51
|
+
/not a Slack mobile sign-in link/,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects a sign-in link with an unparseable workspace', () => {
|
|
56
|
+
expect(() => parseSlackQrUrl('https://app.slack.com/login/z-app-1')).toThrow(/determine the workspace/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('decodeQrImage', () => {
|
|
61
|
+
it('reads the encoded URL back from a generated QR PNG', async () => {
|
|
62
|
+
// Given a QR PNG encoding the Slack URL
|
|
63
|
+
const dataUrl = await qrDataUrl(VALID_QR_URL)
|
|
64
|
+
|
|
65
|
+
// When decoded
|
|
66
|
+
const decoded = decodeQrImage(dataUrl)
|
|
67
|
+
|
|
68
|
+
// Then the original URL is recovered
|
|
69
|
+
expect(decoded).toBe(VALID_QR_URL)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects a data URL without the PNG header', () => {
|
|
73
|
+
expect(() => decodeQrImage('data:image/jpeg;base64,AAAA')).toThrow(/PNG data URL/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects a PNG header with no data', () => {
|
|
77
|
+
expect(() => decodeQrImage('data:image/png;base64,')).toThrow(/no image data/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('rejects a PNG header with non-PNG payload', () => {
|
|
81
|
+
expect(() => decodeQrImage('data:image/png;base64,Zm9vYmFy')).toThrow(/valid PNG/)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('decodeSlackQr', () => {
|
|
86
|
+
it('decodes a QR PNG and parses the Slack login URL end to end', async () => {
|
|
87
|
+
// Given a QR PNG encoding a valid Slack sign-in URL
|
|
88
|
+
const dataUrl = await qrDataUrl(VALID_QR_URL)
|
|
89
|
+
|
|
90
|
+
// When decoded end to end
|
|
91
|
+
const result = decodeSlackQr(dataUrl)
|
|
92
|
+
|
|
93
|
+
// Then both decode and parse succeed
|
|
94
|
+
expect(result.workspace).toBe('acme')
|
|
95
|
+
expect(result.teamId).toBe('T0123456789')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('decodes the QR but rejects a non-Slack URL', async () => {
|
|
99
|
+
const dataUrl = await qrDataUrl('https://example.com/whatever')
|
|
100
|
+
|
|
101
|
+
expect(() => decodeSlackQr(dataUrl)).toThrow(SlackError)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import jsQR from 'jsqr'
|
|
2
|
+
import { PNG } from 'pngjs'
|
|
3
|
+
|
|
4
|
+
import { SlackError } from './client'
|
|
5
|
+
|
|
6
|
+
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
|
|
7
|
+
|
|
8
|
+
// Slack's "Sign in on Mobile" QR encodes a login URL of the form:
|
|
9
|
+
// https://app.slack.com/t/<workspace>/login/z-app-<app_id>-<secret>?src=qr_code&user_id=...&team_id=...
|
|
10
|
+
// The `z-app-` segment is the single-use cross-device auth secret; `src=qr_code` marks the source.
|
|
11
|
+
const SLACK_QR_HOST = 'app.slack.com'
|
|
12
|
+
const SLACK_QR_PATH_SEGMENT = '/login/z-app-'
|
|
13
|
+
|
|
14
|
+
export interface SlackQrLogin {
|
|
15
|
+
url: string
|
|
16
|
+
workspace: string
|
|
17
|
+
teamId: string | null
|
|
18
|
+
userId: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function decodeQrImage(dataUrl: string): string {
|
|
22
|
+
const png = decodePngDataUrl(dataUrl)
|
|
23
|
+
const result = jsQR(new Uint8ClampedArray(png.data), png.width, png.height)
|
|
24
|
+
if (!result) {
|
|
25
|
+
throw new SlackError('Could not read a QR code from the image.', 'qr_unreadable')
|
|
26
|
+
}
|
|
27
|
+
return result.data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseSlackQrUrl(raw: string): SlackQrLogin {
|
|
31
|
+
let parsed: URL
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(raw)
|
|
34
|
+
} catch {
|
|
35
|
+
throw new SlackError('QR code does not contain a valid URL.', 'qr_invalid_url')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== SLACK_QR_HOST) {
|
|
39
|
+
throw new SlackError(`QR code is not a Slack sign-in link (expected https://${SLACK_QR_HOST}).`, 'qr_not_slack')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!parsed.pathname.includes(SLACK_QR_PATH_SEGMENT)) {
|
|
43
|
+
throw new SlackError('QR code is not a Slack mobile sign-in link.', 'qr_not_signin')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const workspace = extractWorkspace(parsed.pathname)
|
|
47
|
+
if (!workspace) {
|
|
48
|
+
throw new SlackError('Could not determine the workspace from the QR code.', 'qr_no_workspace')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
url: parsed.toString(),
|
|
53
|
+
workspace,
|
|
54
|
+
teamId: parsed.searchParams.get('team_id'),
|
|
55
|
+
userId: parsed.searchParams.get('user_id'),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function decodeSlackQr(dataUrl: string): SlackQrLogin {
|
|
60
|
+
return parseSlackQrUrl(decodeQrImage(dataUrl))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractWorkspace(pathname: string): string | null {
|
|
64
|
+
const match = pathname.match(/^\/t\/([^/]+)\/login\/z-app-/)
|
|
65
|
+
return match ? match[1] : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodePngDataUrl(dataUrl: string): PNG {
|
|
69
|
+
if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) {
|
|
70
|
+
throw new SlackError(`Expected a PNG data URL starting with "${PNG_DATA_URL_PREFIX}".`, 'qr_invalid_header')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const base64 = dataUrl.slice(PNG_DATA_URL_PREFIX.length)
|
|
74
|
+
if (!base64) {
|
|
75
|
+
throw new SlackError('QR data URL contains no image data.', 'qr_no_data')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let buffer: Buffer
|
|
79
|
+
try {
|
|
80
|
+
buffer = Buffer.from(base64, 'base64')
|
|
81
|
+
} catch {
|
|
82
|
+
throw new SlackError('QR data URL is not valid base64.', 'qr_invalid_base64')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return PNG.sync.read(buffer)
|
|
87
|
+
} catch {
|
|
88
|
+
throw new SlackError('QR data URL is not a valid PNG image.', 'qr_invalid_png')
|
|
89
|
+
}
|
|
90
|
+
}
|