agent-messenger 2.24.1 → 2.26.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 +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/commands/auth.d.ts +4 -0
- package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/auth.js +61 -1
- package/dist/src/platforms/discord/commands/auth.js.map +1 -1
- package/dist/src/platforms/discord/index.d.ts +2 -0
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js +1 -0
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/remote-auth.d.ts +42 -0
- package/dist/src/platforms/discord/remote-auth.d.ts.map +1 -0
- package/dist/src/platforms/discord/remote-auth.js +208 -0
- package/dist/src/platforms/discord/remote-auth.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 +17 -1
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/message.d.ts +4 -0
- package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/message.js +16 -1
- package/dist/src/platforms/webex/commands/message.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +7 -0
- package/docs/content/docs/sdk/discord.mdx +22 -0
- package/docs/content/docs/sdk/webex.mdx +7 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +9 -2
- 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 +1 -1
- 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 +6 -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/discord/commands/auth.ts +81 -1
- package/src/platforms/discord/index.ts +2 -0
- package/src/platforms/discord/remote-auth.test.ts +261 -0
- package/src/platforms/discord/remote-auth.ts +317 -0
- package/src/platforms/webex/client.test.ts +74 -0
- package/src/platforms/webex/client.ts +20 -1
- package/src/platforms/webex/commands/message.test.ts +29 -1
- package/src/platforms/webex/commands/message.ts +18 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { DiscordClient, DiscordError } from './client'
|
|
2
2
|
export { DiscordCredentialManager } from './credential-manager'
|
|
3
3
|
export { DiscordListener } from './listener'
|
|
4
|
+
export { loginWithRemoteAuth } from './remote-auth'
|
|
5
|
+
export type { RemoteAuthOptions, RemoteAuthSession } from './remote-auth'
|
|
4
6
|
export type {
|
|
5
7
|
DiscordChannel,
|
|
6
8
|
DiscordConfig,
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { constants as cryptoConstants, createHash, generateKeyPairSync, publicEncrypt } from 'node:crypto'
|
|
3
|
+
import { EventEmitter } from 'node:events'
|
|
4
|
+
|
|
5
|
+
import { DiscordError } from '@/platforms/discord/client'
|
|
6
|
+
import { __test, loginWithRemoteAuth } from '@/platforms/discord/remote-auth'
|
|
7
|
+
|
|
8
|
+
const USER = { id: '852892297661906993', username: 'alice', discriminator: '0', avatar: 'abc' }
|
|
9
|
+
const USER_PAYLOAD = `${USER.id}:${USER.discriminator}:${USER.avatar}:${USER.username}`
|
|
10
|
+
const TOKEN = 'mfa.AbCdEfGhIjKlMnOpQrStUvWxYz'
|
|
11
|
+
|
|
12
|
+
class FakeWebSocket extends EventEmitter {
|
|
13
|
+
sent: Record<string, unknown>[] = []
|
|
14
|
+
closed = false
|
|
15
|
+
|
|
16
|
+
send(raw: string): void {
|
|
17
|
+
this.sent.push(JSON.parse(raw))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
close(): void {
|
|
21
|
+
this.closed = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
emitMessage(payload: Record<string, unknown>): void {
|
|
25
|
+
this.emit('message', Buffer.from(JSON.stringify(payload)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
lastOp(op: string): Record<string, unknown> | undefined {
|
|
29
|
+
return [...this.sent].reverse().find((m) => m.op === op)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function encryptForClient(encodedPublicKey: string, data: Buffer | string): string {
|
|
34
|
+
const der = Buffer.from(encodedPublicKey, 'base64')
|
|
35
|
+
const publicKey = __test.createPublicKey({ key: der, format: 'der', type: 'spki' })
|
|
36
|
+
const encrypted = publicEncrypt(
|
|
37
|
+
{ key: publicKey, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
|
38
|
+
typeof data === 'string' ? Buffer.from(data) : data,
|
|
39
|
+
)
|
|
40
|
+
return encrypted.toString('base64')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const originalFetch = globalThis.fetch
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
globalThis.fetch = originalFetch
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('crypto helpers', () => {
|
|
49
|
+
it('computes nonce proof as base64url(sha256(decrypted_nonce))', () => {
|
|
50
|
+
// Given a keypair and a nonce encrypted to its public key
|
|
51
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
52
|
+
const nonce = Buffer.from('the-nonce-bytes')
|
|
53
|
+
const encoded = publicKey.export({ type: 'spki', format: 'der' }).toString('base64')
|
|
54
|
+
const encryptedNonce = encryptForClient(encoded, nonce)
|
|
55
|
+
|
|
56
|
+
// When computing the proof
|
|
57
|
+
const proof = __test.computeNonceProof(privateKey, encryptedNonce)
|
|
58
|
+
|
|
59
|
+
// Then it matches the spec: hash first, then base64url without padding
|
|
60
|
+
const expected = createHash('sha256').update(nonce).digest('base64url')
|
|
61
|
+
expect(proof).toBe(expected)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('parses the colon-delimited user payload', () => {
|
|
65
|
+
expect(__test.parseUserPayload(USER_PAYLOAD)).toEqual({
|
|
66
|
+
id: USER.id,
|
|
67
|
+
discriminator: USER.discriminator,
|
|
68
|
+
avatar: USER.avatar,
|
|
69
|
+
username: USER.username,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('loginWithRemoteAuth', () => {
|
|
75
|
+
it('completes the full handshake and returns the decrypted token', async () => {
|
|
76
|
+
// Given a fake gateway that drives the protocol
|
|
77
|
+
const ws = new FakeWebSocket()
|
|
78
|
+
const qrUrls: string[] = []
|
|
79
|
+
const pendingUsers: string[] = []
|
|
80
|
+
|
|
81
|
+
globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
|
|
82
|
+
const body = JSON.parse(init?.body ?? '{}') as { ticket?: string }
|
|
83
|
+
expect(body.ticket).toBe('the-ticket')
|
|
84
|
+
const initMsg = ws.lastOp('init') as { encoded_public_key: string }
|
|
85
|
+
return new Response(JSON.stringify({ encrypted_token: encryptForClient(initMsg.encoded_public_key, TOKEN) }), {
|
|
86
|
+
status: 200,
|
|
87
|
+
})
|
|
88
|
+
}) as typeof fetch
|
|
89
|
+
|
|
90
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
91
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
92
|
+
onQrUrl: (url) => {
|
|
93
|
+
qrUrls.push(url)
|
|
94
|
+
},
|
|
95
|
+
onPendingLogin: (user) => {
|
|
96
|
+
pendingUsers.push(user.username)
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// When the gateway walks through the protocol
|
|
101
|
+
ws.emit('open')
|
|
102
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250, timeout_ms: 142637 })
|
|
103
|
+
|
|
104
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
105
|
+
expect(init).toBeDefined()
|
|
106
|
+
|
|
107
|
+
ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'nonce') })
|
|
108
|
+
expect(ws.lastOp('nonce_proof')).toBeDefined()
|
|
109
|
+
|
|
110
|
+
ws.emitMessage({ op: 'pending_remote_init', fingerprint: 'FINGERPRINT123' })
|
|
111
|
+
ws.emitMessage({
|
|
112
|
+
op: 'pending_ticket',
|
|
113
|
+
encrypted_user_payload: encryptForClient(init.encoded_public_key, USER_PAYLOAD),
|
|
114
|
+
})
|
|
115
|
+
ws.emitMessage({ op: 'pending_login', ticket: 'the-ticket' })
|
|
116
|
+
|
|
117
|
+
// Then the session resolves with the decrypted token and scanned user
|
|
118
|
+
const session = await sessionPromise
|
|
119
|
+
expect(session.token).toBe(TOKEN)
|
|
120
|
+
expect(session.user.username).toBe('alice')
|
|
121
|
+
expect(qrUrls).toEqual(['https://discord.com/ra/FINGERPRINT123'])
|
|
122
|
+
expect(pendingUsers).toEqual(['alice'])
|
|
123
|
+
expect(ws.closed).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('rejects with a captcha error when the login endpoint demands one', async () => {
|
|
127
|
+
// Given a gateway that reaches ticket exchange but the API returns a captcha challenge
|
|
128
|
+
const ws = new FakeWebSocket()
|
|
129
|
+
globalThis.fetch = (async () =>
|
|
130
|
+
new Response(JSON.stringify({ captcha_sitekey: 'sitekey', captcha_key: ['captcha-required'] }), {
|
|
131
|
+
status: 400,
|
|
132
|
+
})) as typeof fetch
|
|
133
|
+
|
|
134
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
135
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
|
|
139
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
140
|
+
ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
|
|
141
|
+
ws.emitMessage({ op: 'pending_remote_init', fingerprint: 'FP' })
|
|
142
|
+
ws.emitMessage({ op: 'pending_login', ticket: 't' })
|
|
143
|
+
|
|
144
|
+
// Then the promise rejects with a captcha-specific error code
|
|
145
|
+
await expect(sessionPromise).rejects.toMatchObject({ code: 'remote_auth_captcha' })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('rejects when the gateway times out (close code 4003)', async () => {
|
|
149
|
+
// Given a gateway that closes with the timeout code before completion
|
|
150
|
+
const ws = new FakeWebSocket()
|
|
151
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
152
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
|
|
156
|
+
ws.emit('close', 4003)
|
|
157
|
+
|
|
158
|
+
// Then it surfaces a timeout error
|
|
159
|
+
await expect(sessionPromise).rejects.toBeInstanceOf(DiscordError)
|
|
160
|
+
await expect(sessionPromise).rejects.toMatchObject({ code: 'remote_auth_timeout' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('does not reject on normal close (1000) while the ticket exchange is in flight', async () => {
|
|
164
|
+
// Given a fetch that stays pending until we release it, so close(1000) lands mid-exchange
|
|
165
|
+
const ws = new FakeWebSocket()
|
|
166
|
+
let releaseFetch: (() => void) | undefined
|
|
167
|
+
globalThis.fetch = (async () => {
|
|
168
|
+
await new Promise<void>((resolve) => {
|
|
169
|
+
releaseFetch = resolve
|
|
170
|
+
})
|
|
171
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
172
|
+
return new Response(JSON.stringify({ encrypted_token: encryptForClient(init.encoded_public_key, TOKEN) }), {
|
|
173
|
+
status: 200,
|
|
174
|
+
})
|
|
175
|
+
}) as typeof fetch
|
|
176
|
+
|
|
177
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
178
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
|
|
182
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
183
|
+
ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
|
|
184
|
+
ws.emitMessage({ op: 'pending_login', ticket: 'the-ticket' })
|
|
185
|
+
|
|
186
|
+
// When the gateway closes normally before the exchange resolves
|
|
187
|
+
ws.emit('close', 1000)
|
|
188
|
+
releaseFetch?.()
|
|
189
|
+
|
|
190
|
+
// Then the login still succeeds with the token from the completed exchange
|
|
191
|
+
const session = await sessionPromise
|
|
192
|
+
expect(session.token).toBe(TOKEN)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('rejects (does not crash) when an encrypted payload is malformed', async () => {
|
|
196
|
+
// Given a nonce_proof whose ciphertext cannot be decrypted by the private key
|
|
197
|
+
const ws = new FakeWebSocket()
|
|
198
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
199
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
|
|
203
|
+
// When garbage arrives where ciphertext is expected
|
|
204
|
+
ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: 'not-valid-base64-ciphertext' })
|
|
205
|
+
|
|
206
|
+
// Then the decrypt error is surfaced as a rejection instead of throwing out of the handler
|
|
207
|
+
await expect(sessionPromise).rejects.toBeInstanceOf(Error)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('resolves with a null user when pending_login arrives without pending_ticket', async () => {
|
|
211
|
+
// Given the gateway skips pending_ticket and goes straight to pending_login
|
|
212
|
+
const ws = new FakeWebSocket()
|
|
213
|
+
globalThis.fetch = (async () => {
|
|
214
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
215
|
+
return new Response(JSON.stringify({ encrypted_token: encryptForClient(init.encoded_public_key, TOKEN) }), {
|
|
216
|
+
status: 200,
|
|
217
|
+
})
|
|
218
|
+
}) as typeof fetch
|
|
219
|
+
|
|
220
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
221
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
|
|
225
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
226
|
+
ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
|
|
227
|
+
ws.emitMessage({ op: 'pending_login', ticket: 'the-ticket' })
|
|
228
|
+
|
|
229
|
+
// Then the token is returned but user is null rather than a fabricated blank user
|
|
230
|
+
const session = await sessionPromise
|
|
231
|
+
expect(session.token).toBe(TOKEN)
|
|
232
|
+
expect(session.user).toBeNull()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('exchanges the ticket only once even if pending_login is delivered twice', async () => {
|
|
236
|
+
// Given a duplicate pending_login from the gateway
|
|
237
|
+
const ws = new FakeWebSocket()
|
|
238
|
+
let fetchCalls = 0
|
|
239
|
+
globalThis.fetch = (async () => {
|
|
240
|
+
fetchCalls++
|
|
241
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
242
|
+
return new Response(JSON.stringify({ encrypted_token: encryptForClient(init.encoded_public_key, TOKEN) }), {
|
|
243
|
+
status: 200,
|
|
244
|
+
})
|
|
245
|
+
}) as typeof fetch
|
|
246
|
+
|
|
247
|
+
const sessionPromise = loginWithRemoteAuth({
|
|
248
|
+
createWebSocket: () => ws as unknown as import('ws').WebSocket,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
|
|
252
|
+
const init = ws.lastOp('init') as { encoded_public_key: string }
|
|
253
|
+
ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
|
|
254
|
+
ws.emitMessage({ op: 'pending_login', ticket: 't' })
|
|
255
|
+
ws.emitMessage({ op: 'pending_login', ticket: 't' })
|
|
256
|
+
|
|
257
|
+
// Then only a single token exchange is performed
|
|
258
|
+
await sessionPromise
|
|
259
|
+
expect(fetchCalls).toBe(1)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants as cryptoConstants,
|
|
3
|
+
createHash,
|
|
4
|
+
createPrivateKey,
|
|
5
|
+
createPublicKey,
|
|
6
|
+
generateKeyPairSync,
|
|
7
|
+
privateDecrypt,
|
|
8
|
+
} from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
import WebSocket from 'ws'
|
|
11
|
+
|
|
12
|
+
import { DiscordError } from './client'
|
|
13
|
+
import { getDiscordHeaders } from './super-properties'
|
|
14
|
+
|
|
15
|
+
const REMOTE_AUTH_GATEWAY_URL = 'wss://remote-auth-gateway.discord.gg/?v=2'
|
|
16
|
+
const REMOTE_AUTH_ORIGIN = 'https://discord.com'
|
|
17
|
+
const REMOTE_AUTH_LOGIN_URL = 'https://discord.com/api/v9/users/@me/remote-auth/login'
|
|
18
|
+
const QR_URL_PREFIX = 'https://discord.com/ra/'
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 150_000
|
|
20
|
+
|
|
21
|
+
interface RemoteAuthUser {
|
|
22
|
+
id: string
|
|
23
|
+
username: string
|
|
24
|
+
discriminator: string
|
|
25
|
+
avatar: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RemoteAuthSession {
|
|
29
|
+
token: string
|
|
30
|
+
user: RemoteAuthUser | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RemoteAuthOptions {
|
|
34
|
+
onQrUrl?: (url: string) => void | Promise<void>
|
|
35
|
+
onPendingLogin?: (user: RemoteAuthUser) => void
|
|
36
|
+
debug?: (message: string) => void
|
|
37
|
+
fetchImpl?: typeof fetch
|
|
38
|
+
timeoutMs?: number
|
|
39
|
+
createWebSocket?: (url: string) => WebSocket
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RemoteAuthHello {
|
|
43
|
+
op: 'hello'
|
|
44
|
+
timeout_ms?: number
|
|
45
|
+
heartbeat_interval: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RemoteAuthNonceProof {
|
|
49
|
+
op: 'nonce_proof'
|
|
50
|
+
encrypted_nonce: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RemoteAuthPendingRemoteInit {
|
|
54
|
+
op: 'pending_remote_init'
|
|
55
|
+
fingerprint: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RemoteAuthPendingTicket {
|
|
59
|
+
op: 'pending_ticket'
|
|
60
|
+
encrypted_user_payload: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface RemoteAuthPendingLogin {
|
|
64
|
+
op: 'pending_login'
|
|
65
|
+
ticket: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type RemoteAuthMessage =
|
|
69
|
+
| RemoteAuthHello
|
|
70
|
+
| RemoteAuthNonceProof
|
|
71
|
+
| RemoteAuthPendingRemoteInit
|
|
72
|
+
| RemoteAuthPendingTicket
|
|
73
|
+
| RemoteAuthPendingLogin
|
|
74
|
+
| { op: 'heartbeat_ack' }
|
|
75
|
+
| { op: 'cancel' }
|
|
76
|
+
| { op: string; [key: string]: unknown }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Authenticate a Discord user account via the Remote Auth ("scan QR with the mobile app") flow.
|
|
80
|
+
*
|
|
81
|
+
* The desktop side (this function) opens a WebSocket to Discord's remote-auth gateway, performs an
|
|
82
|
+
* RSA-OAEP key exchange, and renders a QR code. Once the user scans it with an authenticated Discord
|
|
83
|
+
* mobile app and confirms, Discord returns an encrypted ticket which is exchanged for the user token.
|
|
84
|
+
*/
|
|
85
|
+
export function loginWithRemoteAuth(options: RemoteAuthOptions = {}): Promise<RemoteAuthSession> {
|
|
86
|
+
const debug = options.debug
|
|
87
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
88
|
+
|
|
89
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
90
|
+
modulusLength: 2048,
|
|
91
|
+
publicExponent: 0x10001,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const ws = options.createWebSocket
|
|
95
|
+
? options.createWebSocket(REMOTE_AUTH_GATEWAY_URL)
|
|
96
|
+
: new WebSocket(REMOTE_AUTH_GATEWAY_URL, { headers: { Origin: REMOTE_AUTH_ORIGIN } })
|
|
97
|
+
|
|
98
|
+
return new Promise<RemoteAuthSession>((resolve, reject) => {
|
|
99
|
+
let settled = false
|
|
100
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
101
|
+
let pendingUser: RemoteAuthUser | null = null
|
|
102
|
+
let exchangeStarted = false
|
|
103
|
+
|
|
104
|
+
const cleanup = (): void => {
|
|
105
|
+
if (heartbeatTimer) {
|
|
106
|
+
clearInterval(heartbeatTimer)
|
|
107
|
+
heartbeatTimer = null
|
|
108
|
+
}
|
|
109
|
+
clearTimeout(timeoutTimer)
|
|
110
|
+
try {
|
|
111
|
+
ws.close()
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fail = (error: Error): void => {
|
|
116
|
+
if (settled) return
|
|
117
|
+
settled = true
|
|
118
|
+
cleanup()
|
|
119
|
+
reject(error)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const succeed = (session: RemoteAuthSession): void => {
|
|
123
|
+
if (settled) return
|
|
124
|
+
settled = true
|
|
125
|
+
cleanup()
|
|
126
|
+
resolve(session)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const timeoutTimer = setTimeout(() => {
|
|
130
|
+
fail(new DiscordError('Remote auth timed out before the QR code was scanned.', 'remote_auth_timeout'))
|
|
131
|
+
}, timeoutMs)
|
|
132
|
+
timeoutTimer.unref?.()
|
|
133
|
+
|
|
134
|
+
const send = (payload: Record<string, unknown>): void => {
|
|
135
|
+
try {
|
|
136
|
+
ws.send(JSON.stringify(payload))
|
|
137
|
+
} catch (error) {
|
|
138
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ws.on('open', () => {
|
|
143
|
+
debug?.('Connected to remote auth gateway')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
ws.on('message', (raw: WebSocket.RawData) => {
|
|
147
|
+
let message: RemoteAuthMessage
|
|
148
|
+
try {
|
|
149
|
+
message = JSON.parse(raw.toString()) as RemoteAuthMessage
|
|
150
|
+
} catch {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
handleMessage(message)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const handleMessage = (message: RemoteAuthMessage): void => {
|
|
162
|
+
switch (message.op) {
|
|
163
|
+
case 'hello': {
|
|
164
|
+
const interval = (message as RemoteAuthHello).heartbeat_interval
|
|
165
|
+
heartbeatTimer = setInterval(() => send({ op: 'heartbeat' }), interval)
|
|
166
|
+
heartbeatTimer.unref?.()
|
|
167
|
+
send({ op: 'init', encoded_public_key: encodePublicKey(publicKey) })
|
|
168
|
+
debug?.('Sent init with public key')
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'nonce_proof': {
|
|
173
|
+
const encrypted = (message as RemoteAuthNonceProof).encrypted_nonce
|
|
174
|
+
const proof = computeNonceProof(privateKey, encrypted)
|
|
175
|
+
send({ op: 'nonce_proof', proof })
|
|
176
|
+
debug?.('Sent nonce proof')
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'pending_remote_init': {
|
|
181
|
+
const { fingerprint } = message as RemoteAuthPendingRemoteInit
|
|
182
|
+
const qrUrl = `${QR_URL_PREFIX}${fingerprint}`
|
|
183
|
+
debug?.('Received fingerprint; QR ready')
|
|
184
|
+
Promise.resolve(options.onQrUrl?.(qrUrl)).catch((error) => {
|
|
185
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
186
|
+
})
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'pending_ticket': {
|
|
191
|
+
const payload = decryptPayload(privateKey, (message as RemoteAuthPendingTicket).encrypted_user_payload)
|
|
192
|
+
pendingUser = parseUserPayload(payload.toString('utf8'))
|
|
193
|
+
options.onPendingLogin?.(pendingUser)
|
|
194
|
+
debug?.(`QR scanned by ${pendingUser.username}`)
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'pending_login': {
|
|
199
|
+
if (exchangeStarted) break
|
|
200
|
+
exchangeStarted = true
|
|
201
|
+
const { ticket } = message as RemoteAuthPendingLogin
|
|
202
|
+
debug?.('Exchanging ticket for token')
|
|
203
|
+
exchangeTicket(ticket, privateKey, options.fetchImpl ?? fetch)
|
|
204
|
+
.then((token) => succeed({ token, user: pendingUser }))
|
|
205
|
+
.catch(fail)
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'cancel': {
|
|
210
|
+
fail(new DiscordError('Remote auth was cancelled from the mobile device.', 'remote_auth_cancelled'))
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
ws.on('close', (code: number) => {
|
|
217
|
+
if (settled) return
|
|
218
|
+
// The gateway closes the socket right after pending_login; the in-flight
|
|
219
|
+
// ticket exchange settles the result, so this close must not preempt it.
|
|
220
|
+
if (exchangeStarted) return
|
|
221
|
+
// 4003 is the gateway's timeout close code.
|
|
222
|
+
if (code === 4003) {
|
|
223
|
+
fail(
|
|
224
|
+
new DiscordError('Remote auth session expired. Generate a new QR code and try again.', 'remote_auth_timeout'),
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
fail(new DiscordError(`Remote auth connection closed before completion (code ${code}).`, 'remote_auth_closed'))
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
ws.on('error', (error: Error) => {
|
|
232
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function encodePublicKey(publicKey: ReturnType<typeof generateKeyPairSync>['publicKey']): string {
|
|
238
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' })
|
|
239
|
+
return spki.toString('base64')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function decryptPayload(
|
|
243
|
+
privateKey: ReturnType<typeof generateKeyPairSync>['privateKey'],
|
|
244
|
+
encryptedBase64: string,
|
|
245
|
+
): Buffer {
|
|
246
|
+
return privateDecrypt(
|
|
247
|
+
{
|
|
248
|
+
key: privateKey,
|
|
249
|
+
padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING,
|
|
250
|
+
oaepHash: 'sha256',
|
|
251
|
+
},
|
|
252
|
+
Buffer.from(encryptedBase64, 'base64'),
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function computeNonceProof(
|
|
257
|
+
privateKey: ReturnType<typeof generateKeyPairSync>['privateKey'],
|
|
258
|
+
encryptedNonce: string,
|
|
259
|
+
): string {
|
|
260
|
+
const nonce = decryptPayload(privateKey, encryptedNonce)
|
|
261
|
+
return base64UrlNoPadding(createHash('sha256').update(nonce).digest())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseUserPayload(payload: string): RemoteAuthUser {
|
|
265
|
+
const [id = '', discriminator = '0', avatar = '', username = ''] = payload.split(':')
|
|
266
|
+
return { id, username, discriminator, avatar: avatar || null }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function exchangeTicket(
|
|
270
|
+
ticket: string,
|
|
271
|
+
privateKey: ReturnType<typeof generateKeyPairSync>['privateKey'],
|
|
272
|
+
fetchImpl: typeof fetch,
|
|
273
|
+
): Promise<string> {
|
|
274
|
+
const { Authorization: _omit, ...headers } = getDiscordHeaders('')
|
|
275
|
+
const response = await fetchImpl(REMOTE_AUTH_LOGIN_URL, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ ticket }),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (response.status === 400) {
|
|
282
|
+
const body = (await response.json().catch(() => ({}))) as { captcha_sitekey?: string }
|
|
283
|
+
if (body.captcha_sitekey) {
|
|
284
|
+
throw new DiscordError(
|
|
285
|
+
'Discord requires a captcha to complete this login. Captcha solving is not supported; use `auth extract` instead.',
|
|
286
|
+
'remote_auth_captcha',
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
throw new DiscordError(
|
|
293
|
+
`Failed to exchange remote auth ticket (HTTP ${response.status}).`,
|
|
294
|
+
'remote_auth_login_failed',
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const body = (await response.json()) as { encrypted_token?: string }
|
|
299
|
+
if (!body.encrypted_token) {
|
|
300
|
+
throw new DiscordError('Remote auth response did not include a token.', 'remote_auth_login_failed')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return decryptPayload(privateKey, body.encrypted_token).toString('utf8')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function base64UrlNoPadding(buffer: Buffer): string {
|
|
307
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export const __test = {
|
|
311
|
+
encodePublicKey,
|
|
312
|
+
computeNonceProof,
|
|
313
|
+
parseUserPayload,
|
|
314
|
+
base64UrlNoPadding,
|
|
315
|
+
createPublicKey,
|
|
316
|
+
createPrivateKey,
|
|
317
|
+
}
|
|
@@ -760,6 +760,54 @@ describe('WebexClient', () => {
|
|
|
760
760
|
})
|
|
761
761
|
})
|
|
762
762
|
|
|
763
|
+
describe('setTyping', () => {
|
|
764
|
+
it('sends start_typing via internal API for extracted token', async () => {
|
|
765
|
+
// given
|
|
766
|
+
mockResponse(null, 204)
|
|
767
|
+
const client = await createExtractedClient()
|
|
768
|
+
|
|
769
|
+
// when
|
|
770
|
+
await client.setTyping(TEST_ROOM_ID)
|
|
771
|
+
|
|
772
|
+
// then
|
|
773
|
+
expect(fetchCalls).toHaveLength(1)
|
|
774
|
+
expect(fetchCalls[0].url).toContain('conv-r.wbx2.com/conversation/api/v1/conversations/')
|
|
775
|
+
expect(fetchCalls[0].url).toContain('/status/typing')
|
|
776
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
777
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
778
|
+
expect(body).toEqual({
|
|
779
|
+
conversationId: TEST_CONV_UUID,
|
|
780
|
+
eventType: 'status.start_typing',
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it('sends stop_typing when typing is false', async () => {
|
|
785
|
+
// given
|
|
786
|
+
mockResponse(null, 204)
|
|
787
|
+
const client = await createExtractedClient()
|
|
788
|
+
|
|
789
|
+
// when
|
|
790
|
+
await client.setTyping(TEST_ROOM_ID, false)
|
|
791
|
+
|
|
792
|
+
// then
|
|
793
|
+
const body = JSON.parse(fetchCalls[0].options?.body as string)
|
|
794
|
+
expect(body.eventType).toBe('status.stop_typing')
|
|
795
|
+
expect(body.conversationId).toBe(TEST_CONV_UUID)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('throws for non-internal token', async () => {
|
|
799
|
+
// given
|
|
800
|
+
const client = await new WebexClient().login({ token: 'plain-token' })
|
|
801
|
+
|
|
802
|
+
// when / then
|
|
803
|
+
await expect(client.setTyping('roomId')).rejects.toThrow(WebexError)
|
|
804
|
+
await expect(client.setTyping('roomId')).rejects.toThrow(
|
|
805
|
+
'Typing indicator requires an extracted or password token with a device URL',
|
|
806
|
+
)
|
|
807
|
+
expect(fetchCalls).toHaveLength(0)
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
|
|
763
811
|
describe('listMessages', () => {
|
|
764
812
|
it('calls GET on conversations endpoint with activitiesLimit and participantsLimit', async () => {
|
|
765
813
|
mockResponse(mockConversation([mockActivity('Hello')]))
|
|
@@ -1209,6 +1257,32 @@ describe('WebexClient', () => {
|
|
|
1209
1257
|
|
|
1210
1258
|
await expect(client.uploadFile(TEST_ROOM_ID, file())).resolves.toBeDefined()
|
|
1211
1259
|
})
|
|
1260
|
+
|
|
1261
|
+
it('accepts webexcontent.com upload urls returned by the server', async () => {
|
|
1262
|
+
mockResponse({ id: TEST_CONV_UUID })
|
|
1263
|
+
mockResponse({ spaceUrl: 'https://files-prod-us-west-2.webexcontent.com/spaces/sp1' })
|
|
1264
|
+
mockResponse({
|
|
1265
|
+
uploadUrl: 'https://files-prod-us-west-2.webexcontent.com/upload/sess1',
|
|
1266
|
+
finishUploadUrl: 'https://files-prod-us-west-2.webexcontent.com/upload/sess1/finish',
|
|
1267
|
+
})
|
|
1268
|
+
mockResponse({}, 200)
|
|
1269
|
+
mockResponse({ downloadUrl: 'https://files-prod-us-west-2.webexcontent.com/files/f1' })
|
|
1270
|
+
mockResponse({ ...mockActivity(''), verb: 'share' })
|
|
1271
|
+
|
|
1272
|
+
const client = await createExtractedClient()
|
|
1273
|
+
|
|
1274
|
+
await expect(client.uploadFile(TEST_ROOM_ID, file())).resolves.toBeDefined()
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it('refuses non-webex upload hosts that merely contain a trusted host', async () => {
|
|
1278
|
+
mockResponse({ id: TEST_CONV_UUID })
|
|
1279
|
+
mockResponse({ spaceUrl: 'https://webexcontent.com.evil.example/spaces/sp1' })
|
|
1280
|
+
|
|
1281
|
+
const client = await createExtractedClient()
|
|
1282
|
+
|
|
1283
|
+
await expect(client.uploadFile(TEST_ROOM_ID, file())).rejects.toThrow('untrusted host')
|
|
1284
|
+
expect(fetchCalls.every((c) => !c.url.includes('evil.example'))).toBe(true)
|
|
1285
|
+
})
|
|
1212
1286
|
})
|
|
1213
1287
|
|
|
1214
1288
|
describe('error handling', () => {
|