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.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -1
  3. package/bun.lock +10 -1
  4. package/dist/package.json +4 -1
  5. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  6. package/dist/src/platforms/slack/commands/auth.js +56 -0
  7. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  8. package/dist/src/platforms/slack/ensure-auth.d.ts +1 -1
  9. package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
  10. package/dist/src/platforms/slack/ensure-auth.js +2 -2
  11. package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
  12. package/dist/src/platforms/slack/index.d.ts +4 -0
  13. package/dist/src/platforms/slack/index.d.ts.map +1 -1
  14. package/dist/src/platforms/slack/index.js +2 -0
  15. package/dist/src/platforms/slack/index.js.map +1 -1
  16. package/dist/src/platforms/slack/qr-http-login.d.ts +14 -0
  17. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -0
  18. package/dist/src/platforms/slack/qr-http-login.js +90 -0
  19. package/dist/src/platforms/slack/qr-http-login.js.map +1 -0
  20. package/dist/src/platforms/slack/qr-login.d.ts +10 -0
  21. package/dist/src/platforms/slack/qr-login.d.ts.map +1 -0
  22. package/dist/src/platforms/slack/qr-login.js +72 -0
  23. package/dist/src/platforms/slack/qr-login.js.map +1 -0
  24. package/dist/src/platforms/webex/client.d.ts +1 -0
  25. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  26. package/dist/src/platforms/webex/client.js +29 -16
  27. package/dist/src/platforms/webex/client.js.map +1 -1
  28. package/dist/src/vendor/linejs/base/request/mod.js +1 -1
  29. package/dist/src/vendor/linejs/base/request/mod.test.ts +54 -0
  30. package/docs/content/docs/cli/slack.mdx +22 -0
  31. package/docs/content/docs/sdk/slack.mdx +15 -0
  32. package/package.json +4 -1
  33. package/skills/agent-channeltalk/SKILL.md +1 -1
  34. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  35. package/skills/agent-discord/SKILL.md +1 -1
  36. package/skills/agent-discordbot/SKILL.md +1 -1
  37. package/skills/agent-instagram/SKILL.md +1 -1
  38. package/skills/agent-kakaotalk/SKILL.md +1 -1
  39. package/skills/agent-line/SKILL.md +1 -1
  40. package/skills/agent-slack/SKILL.md +45 -1
  41. package/skills/agent-slack/references/authentication.md +29 -0
  42. package/skills/agent-slackbot/SKILL.md +1 -1
  43. package/skills/agent-teams/SKILL.md +1 -1
  44. package/skills/agent-telegram/SKILL.md +1 -1
  45. package/skills/agent-telegrambot/SKILL.md +1 -1
  46. package/skills/agent-webex/SKILL.md +1 -1
  47. package/skills/agent-webexbot/SKILL.md +1 -1
  48. package/skills/agent-wechatbot/SKILL.md +1 -1
  49. package/skills/agent-whatsapp/SKILL.md +1 -1
  50. package/skills/agent-whatsappbot/SKILL.md +1 -1
  51. package/src/platforms/slack/commands/auth.ts +73 -0
  52. package/src/platforms/slack/ensure-auth.ts +6 -2
  53. package/src/platforms/slack/index.test.ts +10 -0
  54. package/src/platforms/slack/index.ts +4 -0
  55. package/src/platforms/slack/qr-http-login.test.ts +157 -0
  56. package/src/platforms/slack/qr-http-login.ts +120 -0
  57. package/src/platforms/slack/qr-login.test.ts +103 -0
  58. package/src/platforms/slack/qr-login.ts +90 -0
  59. package/src/platforms/webex/client.test.ts +63 -1
  60. package/src/platforms/webex/client.ts +35 -17
  61. package/src/vendor/linejs/base/request/mod.js +1 -1
  62. 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(domain: string, cookie: string): Promise<string | null> {
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 fetch(`https://${domain}.slack.com/ssb/redirect`, {
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
+ }