agent-messenger 2.25.0 → 2.27.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/README.md +16 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -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/listener.d.ts +1 -4
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +33 -15
- package/dist/src/platforms/discord/listener.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/slack/qr-http-login.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js +105 -6
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/docs/content/docs/sdk/discord.mdx +23 -9
- package/e2e/README.md +3 -1
- 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 +43 -7
- package/skills/agent-discord/references/authentication.md +46 -8
- 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 +7 -4
- package/skills/agent-slack/references/authentication.md +8 -3
- 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/discord/commands/auth.ts +81 -1
- package/src/platforms/discord/index.ts +2 -0
- package/src/platforms/discord/listener.test.ts +12 -4
- package/src/platforms/discord/listener.ts +36 -16
- package/src/platforms/discord/remote-auth.test.ts +261 -0
- package/src/platforms/discord/remote-auth.ts +317 -0
- package/src/platforms/slack/qr-http-login.test.ts +47 -0
- package/src/platforms/slack/qr-http-login.ts +112 -7
|
@@ -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
|
+
}
|
|
@@ -132,6 +132,53 @@ describe('loginWithQr', () => {
|
|
|
132
132
|
await expect(promise).rejects.toThrow(/expired/)
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('reports SSO enforcement when the z-app hop redirects to an identity provider', async () => {
|
|
136
|
+
// Given a workspace whose z-app hop redirects off Slack to an SSO IdP (no d cookie issued)
|
|
137
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
138
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
139
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
140
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
141
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`)
|
|
142
|
+
}
|
|
143
|
+
return redirect('https://accounts.google.com/o/oauth2/auth?redirect_uri=https://slack.com/sso/google')
|
|
144
|
+
}) as typeof fetch
|
|
145
|
+
|
|
146
|
+
// When logging in, the error names SSO + the detected provider and points to a working auth method
|
|
147
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/enforces SSO \(via Google\)/)
|
|
148
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/auth extract/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('reports SSO enforcement for an unrecognized/vanity IdP host', async () => {
|
|
152
|
+
// Given a z-app hop that redirects off Slack to a custom SAML IdP not in the known-provider list
|
|
153
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
154
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
155
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
156
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
157
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`)
|
|
158
|
+
}
|
|
159
|
+
return redirect('https://login.acme.example/saml')
|
|
160
|
+
}) as typeof fetch
|
|
161
|
+
|
|
162
|
+
// When logging in, it is still classified as SSO (without naming a provider) and routes to auth extract
|
|
163
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/enforces SSO/)
|
|
164
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/auth extract/)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('reports an expired link when the final page is Slack\u2019s "Link Expired" page', async () => {
|
|
168
|
+
// Given a z-app chain that completes on a Slack host but returns the expired page with no d cookie
|
|
169
|
+
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
170
|
+
const fetchImpl = (async (input: string | URL) => {
|
|
171
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
172
|
+
if (url.startsWith('https://app.slack.com/t/')) {
|
|
173
|
+
return redirect(`https://${WORKSPACE}.slack.com/z-app-secret`)
|
|
174
|
+
}
|
|
175
|
+
return new Response('<title>Link Expired | Slack</title>', { status: 200 })
|
|
176
|
+
}) as typeof fetch
|
|
177
|
+
|
|
178
|
+
// When logging in, the error names the expiry explicitly
|
|
179
|
+
await expect(loginWithQr(dataUrl, { fetchImpl })).rejects.toThrow(/has expired or was already used/)
|
|
180
|
+
})
|
|
181
|
+
|
|
135
182
|
it('fails with qr_token_failed when the token cannot be retrieved', async () => {
|
|
136
183
|
const dataUrl = await qrDataUrl(ZAPP_URL)
|
|
137
184
|
|