agent-messenger 2.23.6 → 2.24.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +4 -0
- package/README.md +12 -1
- package/bun.lock +10 -72
- package/dist/package.json +7 -4
- package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/token-extractor.js +11 -38
- package/dist/src/platforms/kakaotalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +88 -5
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts +2 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.js +49 -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/slack/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/slack/token-extractor.js +5 -11
- package/dist/src/platforms/slack/token-extractor.js.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.js +4 -19
- package/dist/src/shared/chromium/cookie-reader.js.map +1 -1
- package/dist/src/shared/sqlite.d.ts +10 -0
- package/dist/src/shared/sqlite.d.ts.map +1 -0
- package/dist/src/shared/sqlite.js +46 -0
- package/dist/src/shared/sqlite.js.map +1 -0
- 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 +7 -4
- 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/channeltalk/token-extractor.test.ts +2 -2
- package/src/platforms/kakaotalk/token-extractor.ts +13 -36
- package/src/platforms/slack/commands/auth.ts +106 -5
- package/src/platforms/slack/ensure-auth.test.ts +130 -19
- package/src/platforms/slack/ensure-auth.ts +57 -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/slack/token-extractor-node-test.ts +5 -3
- package/src/platforms/slack/token-extractor.ts +4 -11
- package/src/shared/chromium/cookie-reader-node-test.ts +70 -0
- package/src/shared/chromium/cookie-reader-node.test.ts +10 -0
- package/src/shared/chromium/cookie-reader.ts +4 -21
- package/src/shared/sqlite.ts +61 -0
- package/src/vendor/linejs/base/request/mod.js +1 -1
- package/src/vendor/linejs/base/request/mod.test.ts +54 -0
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
2
3
|
import { tmpdir } from 'node:os'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
|
|
5
|
-
import Database from 'better-sqlite3'
|
|
6
|
-
|
|
7
6
|
import { TokenExtractor } from './token-extractor'
|
|
8
7
|
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
const { DatabaseSync } = require('node:sqlite')
|
|
10
|
+
|
|
9
11
|
const tempDir = mkdtempSync(join(tmpdir(), 'token-extractor-test-'))
|
|
10
12
|
const slackDir = join(tempDir, 'Slack')
|
|
11
13
|
mkdirSync(slackDir)
|
|
@@ -13,7 +15,7 @@ mkdirSync(slackDir)
|
|
|
13
15
|
const dbPath = join(slackDir, 'Cookies')
|
|
14
16
|
|
|
15
17
|
try {
|
|
16
|
-
const db = new
|
|
18
|
+
const db = new DatabaseSync(dbPath)
|
|
17
19
|
db.exec(`
|
|
18
20
|
CREATE TABLE cookies (
|
|
19
21
|
name TEXT,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process'
|
|
2
2
|
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
4
|
-
import { createRequire } from 'node:module'
|
|
5
4
|
import { homedir, tmpdir } from 'node:os'
|
|
6
5
|
import { join } from 'node:path'
|
|
7
6
|
|
|
@@ -16,11 +15,10 @@ import {
|
|
|
16
15
|
getBrowserBasePath,
|
|
17
16
|
getAgentBrowserProfileDirs,
|
|
18
17
|
} from '@/shared/chromium'
|
|
18
|
+
import { openReadonlyDatabase } from '@/shared/sqlite'
|
|
19
19
|
import { DerivedKeyCache } from '@/shared/utils/derived-key-cache'
|
|
20
20
|
import { lookupLinuxKeyringPassword } from '@/shared/utils/linux-keyring'
|
|
21
21
|
|
|
22
|
-
const require = createRequire(import.meta.url)
|
|
23
|
-
|
|
24
22
|
export interface ExtractedWorkspace {
|
|
25
23
|
workspace_id: string
|
|
26
24
|
workspace_name: string
|
|
@@ -872,16 +870,11 @@ export class TokenExtractor {
|
|
|
872
870
|
encrypted_value?: Uint8Array | Buffer
|
|
873
871
|
} | null
|
|
874
872
|
|
|
873
|
+
const db = openReadonlyDatabase(tempDbPath)
|
|
875
874
|
let row: CookieRow
|
|
876
|
-
|
|
877
|
-
const { Database } = require('bun:sqlite')
|
|
878
|
-
const db = new Database(tempDbPath, { readonly: true })
|
|
879
|
-
row = db.query(sql).get() as CookieRow
|
|
880
|
-
db.close()
|
|
881
|
-
} else {
|
|
882
|
-
const Database = require('better-sqlite3')
|
|
883
|
-
const db = new Database(tempDbPath, { readonly: true })
|
|
875
|
+
try {
|
|
884
876
|
row = db.prepare(sql).get() as CookieRow
|
|
877
|
+
} finally {
|
|
885
878
|
db.close()
|
|
886
879
|
}
|
|
887
880
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { ChromiumCookieReader } from '@/shared/chromium'
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
const { DatabaseSync } = require('node:sqlite')
|
|
10
|
+
|
|
11
|
+
function fail(message: string): never {
|
|
12
|
+
console.error(message)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'cookie-reader-node-test-'))
|
|
17
|
+
const dbPath = join(tempDir, 'Cookies')
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const db = new DatabaseSync(dbPath)
|
|
21
|
+
db.exec('CREATE TABLE cookies (name TEXT, value TEXT, encrypted_value BLOB, host_key TEXT, last_access_utc INTEGER)')
|
|
22
|
+
const insert = db.prepare(
|
|
23
|
+
'INSERT INTO cookies (name, value, encrypted_value, host_key, last_access_utc) VALUES (?, ?, ?, ?, ?)',
|
|
24
|
+
)
|
|
25
|
+
// Chromium *_utc columns are microseconds since 1601; real values exceed
|
|
26
|
+
// Number.MAX_SAFE_INTEGER, which node:sqlite throws on when SELECTed.
|
|
27
|
+
insert.run('d', 'xoxd-newer', null, '.slack.com', 13380000000000200n)
|
|
28
|
+
insert.run('d', 'xoxd-older', null, '.slack.com', 13380000000000100n)
|
|
29
|
+
insert.run('other', 'ignored', null, '.example.com', 13380000000000300n)
|
|
30
|
+
insert.run('enc', '', Buffer.from('v10-secret-bytes'), '.slack.com', 13380000000000100n)
|
|
31
|
+
db.close()
|
|
32
|
+
|
|
33
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
|
|
34
|
+
fail('Expected Node.js runtime (node:sqlite), but Bun was detected')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reader = new ChromiumCookieReader()
|
|
38
|
+
|
|
39
|
+
const first = await reader.queryFirst<{ value: string }>(
|
|
40
|
+
dbPath,
|
|
41
|
+
"SELECT value FROM cookies WHERE name = 'd' AND host_key LIKE ? ORDER BY last_access_utc DESC LIMIT 1",
|
|
42
|
+
['%slack.com%'],
|
|
43
|
+
)
|
|
44
|
+
if (first?.value !== 'xoxd-newer') fail(`queryFirst expected xoxd-newer, got ${String(first?.value)}`)
|
|
45
|
+
|
|
46
|
+
const all = await reader.queryAll<{ value: string }>(
|
|
47
|
+
dbPath,
|
|
48
|
+
"SELECT value FROM cookies WHERE name = 'd' ORDER BY last_access_utc DESC",
|
|
49
|
+
)
|
|
50
|
+
if (all.length !== 2) fail(`queryAll expected 2 rows, got ${all.length}`)
|
|
51
|
+
if (all[0]?.value !== 'xoxd-newer') fail('queryAll expected newest-first ordering')
|
|
52
|
+
|
|
53
|
+
const none = await reader.queryFirst(dbPath, "SELECT value FROM cookies WHERE host_key = 'no.match'")
|
|
54
|
+
if (none !== null) fail(`no-row queryFirst expected null, got ${String(none)}`)
|
|
55
|
+
|
|
56
|
+
// node:sqlite returns BLOBs as Uint8Array (not Buffer); Buffer.from must
|
|
57
|
+
// preserve the bytes so cookie decryption keeps working
|
|
58
|
+
const encrypted = await reader.queryFirst<{ encrypted_value?: Uint8Array }>(
|
|
59
|
+
dbPath,
|
|
60
|
+
"SELECT encrypted_value FROM cookies WHERE name = 'enc' LIMIT 1",
|
|
61
|
+
)
|
|
62
|
+
if (!(encrypted?.encrypted_value instanceof Uint8Array)) fail('encrypted_value expected Uint8Array')
|
|
63
|
+
if (Buffer.from(encrypted.encrypted_value).toString('utf8') !== 'v10-secret-bytes') {
|
|
64
|
+
fail('Buffer.from(BLOB) did not round-trip the original bytes')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('ok')
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(tempDir, { recursive: true })
|
|
70
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { expect, it } from 'bun:test'
|
|
2
|
+
import { execSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
it('ChromiumCookieReader works in Node.js (node:sqlite)', () => {
|
|
5
|
+
const result = execSync('bun tsx src/shared/chromium/cookie-reader-node-test.ts', {
|
|
6
|
+
cwd: process.cwd(),
|
|
7
|
+
encoding: 'utf-8',
|
|
8
|
+
})
|
|
9
|
+
expect(result.trim()).toBe('ok')
|
|
10
|
+
})
|