agent-messenger 2.25.0 → 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/docs/content/docs/sdk/discord.mdx +22 -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 +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/remote-auth.test.ts +261 -0
- package/src/platforms/discord/remote-auth.ts +317 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-webexbot
|
|
3
3
|
description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.26.0
|
|
5
5
|
allowed-tools: Bash(agent-webexbot:*)
|
|
6
6
|
metadata:
|
|
7
7
|
openclaw:
|
|
@@ -3,10 +3,12 @@ import { Command } from 'commander'
|
|
|
3
3
|
import { collectBrowserProfileOption } from '@/shared/chromium'
|
|
4
4
|
import { handleError } from '@/shared/utils/error-handler'
|
|
5
5
|
import { formatOutput } from '@/shared/utils/output'
|
|
6
|
-
import {
|
|
6
|
+
import { displayQR } from '@/shared/utils/qr'
|
|
7
|
+
import { debug, info } from '@/shared/utils/stderr'
|
|
7
8
|
|
|
8
9
|
import { DiscordClient } from '../client'
|
|
9
10
|
import { DiscordCredentialManager } from '../credential-manager'
|
|
11
|
+
import { loginWithRemoteAuth } from '../remote-auth'
|
|
10
12
|
import { DiscordTokenExtractor } from '../token-extractor'
|
|
11
13
|
|
|
12
14
|
export async function extractAction(options: {
|
|
@@ -137,6 +139,77 @@ export async function extractAction(options: {
|
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
async function saveTokenWithServers(
|
|
143
|
+
token: string,
|
|
144
|
+
credManager: DiscordCredentialManager,
|
|
145
|
+
): Promise<{ username: string; servers: { id: string; name: string }[]; current: string } | null> {
|
|
146
|
+
const client = await new DiscordClient().login({ token })
|
|
147
|
+
const authInfo = await client.testAuth()
|
|
148
|
+
const servers = await client.listServers()
|
|
149
|
+
if (servers.length === 0) return null
|
|
150
|
+
|
|
151
|
+
const serverMap: Record<string, { server_id: string; server_name: string }> = {}
|
|
152
|
+
for (const server of servers) {
|
|
153
|
+
serverMap[server.id] = { server_id: server.id, server_name: server.name }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await credManager.save({ token, current_server: servers[0].id, servers: serverMap })
|
|
157
|
+
return {
|
|
158
|
+
username: authInfo.username,
|
|
159
|
+
servers: servers.map((s) => ({ id: s.id, name: s.name })),
|
|
160
|
+
current: servers[0].id,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function qrAction(options: { pretty?: boolean; debug?: boolean }): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
const interactive = Boolean(process.stdout.isTTY)
|
|
167
|
+
const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
|
|
168
|
+
|
|
169
|
+
const session = await loginWithRemoteAuth({
|
|
170
|
+
debug: debugLog,
|
|
171
|
+
onQrUrl: async (url) => {
|
|
172
|
+
await displayQR(url, {
|
|
173
|
+
platform: 'Discord',
|
|
174
|
+
brandColor: '#5865F2',
|
|
175
|
+
scanInstruction: 'Scan with the Discord mobile app (Settings → Scan QR Code)',
|
|
176
|
+
interactive,
|
|
177
|
+
formatOutput,
|
|
178
|
+
pretty: options.pretty,
|
|
179
|
+
})
|
|
180
|
+
},
|
|
181
|
+
onPendingLogin: (user) => {
|
|
182
|
+
if (interactive) info(`\nQR scanned by ${user.username}. Confirm the login on your phone...\n`)
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const credManager = new DiscordCredentialManager()
|
|
187
|
+
const saved = await saveTokenWithServers(session.token, credManager)
|
|
188
|
+
|
|
189
|
+
if (!saved) {
|
|
190
|
+
console.log(
|
|
191
|
+
formatOutput(
|
|
192
|
+
{
|
|
193
|
+
error: 'Logged in, but this account is not a member of any server.',
|
|
194
|
+
hint: 'Join at least one server, then run this command again.',
|
|
195
|
+
},
|
|
196
|
+
options.pretty,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
process.exit(1)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(
|
|
203
|
+
formatOutput(
|
|
204
|
+
{ user: saved.username, servers: saved.servers.map((s) => `${s.id}/${s.name}`), current: saved.current },
|
|
205
|
+
options.pretty,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
handleError(error as Error)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
140
213
|
export async function logoutAction(options: { pretty?: boolean }): Promise<void> {
|
|
141
214
|
try {
|
|
142
215
|
const credManager = new DiscordCredentialManager()
|
|
@@ -208,6 +281,13 @@ export const authCommand = new Command('auth')
|
|
|
208
281
|
)
|
|
209
282
|
.action(extractAction),
|
|
210
283
|
)
|
|
284
|
+
.addCommand(
|
|
285
|
+
new Command('qr')
|
|
286
|
+
.description('Sign in by scanning a QR code with the Discord mobile app')
|
|
287
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
288
|
+
.option('--debug', 'Show debug output for troubleshooting')
|
|
289
|
+
.action(qrAction),
|
|
290
|
+
)
|
|
211
291
|
.addCommand(
|
|
212
292
|
new Command('logout')
|
|
213
293
|
.description('Logout from Discord')
|
|
@@ -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
|
+
})
|