agent-messenger 2.9.0 → 2.10.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/dist/package.json +1 -1
- package/dist/src/platforms/teams/client.d.ts +9 -1
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +69 -18
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/auth.js +7 -2
- package/dist/src/platforms/teams/commands/auth.js.map +1 -1
- package/dist/src/platforms/teams/commands/channel.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/channel.js +18 -3
- package/dist/src/platforms/teams/commands/channel.js.map +1 -1
- package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/file.js +18 -3
- package/dist/src/platforms/teams/commands/file.js.map +1 -1
- package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/message.js +24 -4
- package/dist/src/platforms/teams/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/commands/reaction.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/reaction.js +12 -2
- package/dist/src/platforms/teams/commands/reaction.js.map +1 -1
- package/dist/src/platforms/teams/commands/snapshot.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/snapshot.js +6 -1
- package/dist/src/platforms/teams/commands/snapshot.js.map +1 -1
- package/dist/src/platforms/teams/commands/team.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/team.js +6 -1
- package/dist/src/platforms/teams/commands/team.js.map +1 -1
- package/dist/src/platforms/teams/commands/user.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/user.js +18 -3
- package/dist/src/platforms/teams/commands/user.js.map +1 -1
- package/dist/src/platforms/teams/commands/whoami.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/whoami.js +6 -1
- package/dist/src/platforms/teams/commands/whoami.js.map +1 -1
- package/dist/src/platforms/teams/credential-manager.d.ts +3 -1
- package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/teams/credential-manager.js +6 -1
- package/dist/src/platforms/teams/credential-manager.js.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.js +7 -2
- package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
- package/dist/src/platforms/teams/token-extractor.d.ts +3 -1
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js +67 -10
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +17 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +2 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/platforms/webex/client.d.ts +3 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +58 -13
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +61 -10
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +18 -6
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
- package/dist/src/platforms/webex/encryption.js +3 -1
- package/dist/src/platforms/webex/encryption.js.map +1 -1
- package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/ensure-auth.js +10 -2
- package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
- package/dist/src/platforms/webex/token-extractor.d.ts +1 -0
- package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/webex/token-extractor.js +21 -4
- package/dist/src/platforms/webex/token-extractor.js.map +1 -1
- package/e2e/webex.e2e.test.ts +57 -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 +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +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-webex/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/teams/client.test.ts +34 -30
- package/src/platforms/teams/client.ts +92 -20
- package/src/platforms/teams/commands/auth.test.ts +6 -2
- package/src/platforms/teams/commands/auth.ts +7 -2
- package/src/platforms/teams/commands/channel.test.ts +6 -6
- package/src/platforms/teams/commands/channel.ts +18 -3
- package/src/platforms/teams/commands/file.ts +18 -3
- package/src/platforms/teams/commands/message.ts +24 -4
- package/src/platforms/teams/commands/reaction.ts +12 -2
- package/src/platforms/teams/commands/snapshot.ts +6 -1
- package/src/platforms/teams/commands/team.test.ts +2 -2
- package/src/platforms/teams/commands/team.ts +6 -1
- package/src/platforms/teams/commands/user.ts +18 -3
- package/src/platforms/teams/commands/whoami.ts +6 -1
- package/src/platforms/teams/credential-manager.test.ts +25 -0
- package/src/platforms/teams/credential-manager.ts +13 -3
- package/src/platforms/teams/ensure-auth.test.ts +6 -1
- package/src/platforms/teams/ensure-auth.ts +7 -2
- package/src/platforms/teams/token-extractor.ts +77 -12
- package/src/platforms/teams/types.test.ts +17 -0
- package/src/platforms/teams/types.ts +6 -0
- package/src/platforms/webex/client.test.ts +157 -13
- package/src/platforms/webex/client.ts +64 -15
- package/src/platforms/webex/commands/auth.test.ts +122 -1
- package/src/platforms/webex/commands/auth.ts +72 -17
- package/src/platforms/webex/credential-manager.test.ts +63 -0
- package/src/platforms/webex/credential-manager.ts +22 -8
- package/src/platforms/webex/encryption.test.ts +54 -0
- package/src/platforms/webex/encryption.ts +3 -1
- package/src/platforms/webex/ensure-auth.ts +10 -2
- package/src/platforms/webex/token-extractor.test.ts +32 -3
- package/src/platforms/webex/token-extractor.ts +26 -5
|
@@ -21,12 +21,14 @@ export class WebexClient {
|
|
|
21
21
|
private globalRateLimitUntil: number = 0
|
|
22
22
|
private encryption: WebexEncryptionService | null = null
|
|
23
23
|
|
|
24
|
-
async login(credentials?: { token: string }): Promise<this> {
|
|
24
|
+
async login(credentials?: { token: string; deviceUrl?: string; tokenType?: string }): Promise<this> {
|
|
25
25
|
if (credentials) {
|
|
26
26
|
if (!credentials.token) {
|
|
27
27
|
throw new WebexError('Token is required', 'missing_token')
|
|
28
28
|
}
|
|
29
29
|
this.token = credentials.token
|
|
30
|
+
if (credentials.deviceUrl !== undefined) this.deviceUrl = credentials.deviceUrl
|
|
31
|
+
if (credentials.tokenType !== undefined) this.tokenType = credentials.tokenType
|
|
30
32
|
return this
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -161,9 +163,28 @@ export class WebexClient {
|
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
async testAuth(): Promise<WebexPerson> {
|
|
166
|
+
if (this.useInternalAPI) {
|
|
167
|
+
try {
|
|
168
|
+
return await this.request<WebexPerson>('GET', '/people/me')
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const isAuthError = err instanceof WebexError && (err.code === 'http_401' || err.code === 'http_403')
|
|
171
|
+
if (!isAuthError) throw err
|
|
172
|
+
await this.testAuthInternal()
|
|
173
|
+
return { id: '', emails: [], displayName: '', orgId: '', type: 'person', created: '' } as WebexPerson
|
|
174
|
+
}
|
|
175
|
+
}
|
|
164
176
|
return this.request<WebexPerson>('GET', '/people/me')
|
|
165
177
|
}
|
|
166
178
|
|
|
179
|
+
private async testAuthInternal(): Promise<void> {
|
|
180
|
+
if (!this.deviceUrl) {
|
|
181
|
+
throw new WebexError('No device URL available for internal API validation', 'no_device_url')
|
|
182
|
+
}
|
|
183
|
+
await this.internalRequest<InternalConversation>(
|
|
184
|
+
'/conversations?participantsLimit=0&activitiesLimit=0&conversationsLimit=1',
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
async listSpaces(options?: { type?: string; max?: number }): Promise<WebexSpace[]> {
|
|
168
189
|
const params = new URLSearchParams()
|
|
169
190
|
if (options?.type) params.set('type', options.type)
|
|
@@ -248,10 +269,15 @@ export class WebexClient {
|
|
|
248
269
|
private async buildEncryptedObject(
|
|
249
270
|
convUuid: string,
|
|
250
271
|
text: string,
|
|
251
|
-
options?: { markdown?: boolean },
|
|
272
|
+
options?: { markdown?: boolean; forEdit?: boolean },
|
|
252
273
|
): Promise<{ object: Record<string, string>; encryptionKeyUrl?: string }> {
|
|
253
274
|
const displayName = options?.markdown ? stripMarkdown(text) : text
|
|
254
|
-
|
|
275
|
+
let content: string | undefined
|
|
276
|
+
if (options?.markdown) {
|
|
277
|
+
content = markdownToHtml(text)
|
|
278
|
+
} else if (options?.forEdit) {
|
|
279
|
+
content = text
|
|
280
|
+
}
|
|
255
281
|
|
|
256
282
|
if (this.encryption) {
|
|
257
283
|
const conv = await this.internalRequest<InternalConversation>(
|
|
@@ -260,21 +286,25 @@ export class WebexClient {
|
|
|
260
286
|
const keyUri = conv.defaultActivityEncryptionKeyUrl
|
|
261
287
|
if (keyUri) {
|
|
262
288
|
const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
|
|
263
|
-
const encryptedContent = await this.encryption.encryptText(keyUri, content)
|
|
264
|
-
if (encryptedDisplayName
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
encryptionKeyUrl: keyUri,
|
|
289
|
+
const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
|
|
290
|
+
if (encryptedDisplayName) {
|
|
291
|
+
const object: Record<string, string> = {
|
|
292
|
+
objectType: 'comment',
|
|
293
|
+
displayName: encryptedDisplayName,
|
|
294
|
+
}
|
|
295
|
+
if (encryptedContent) {
|
|
296
|
+
object.content = encryptedContent
|
|
272
297
|
}
|
|
298
|
+
return { object, encryptionKeyUrl: keyUri }
|
|
273
299
|
}
|
|
274
300
|
}
|
|
275
301
|
}
|
|
276
302
|
|
|
277
|
-
|
|
303
|
+
const object: Record<string, string> = { objectType: 'comment', displayName }
|
|
304
|
+
if (content) {
|
|
305
|
+
object.content = content
|
|
306
|
+
}
|
|
307
|
+
return { object }
|
|
278
308
|
}
|
|
279
309
|
|
|
280
310
|
private async sendMessageInternal(
|
|
@@ -349,6 +379,11 @@ export class WebexClient {
|
|
|
349
379
|
if (this.useInternalAPI) {
|
|
350
380
|
const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
|
|
351
381
|
const convId = activity.target?.id ?? ''
|
|
382
|
+
// Internal API responses don't carry the cluster shard (e.g. `us-west-2_r`) the
|
|
383
|
+
// public roomId encoding requires. The `unknown` placeholder is a sentinel — it
|
|
384
|
+
// round-trips through other internal API calls because they decode only the
|
|
385
|
+
// conversation UUID suffix. Callers that need a public-API-safe roomId should
|
|
386
|
+
// obtain it from `listSpaces()` or pass it through from a prior `sendMessage`.
|
|
352
387
|
const roomId = convId ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64') : ''
|
|
353
388
|
return this.activityToMessage(activity, roomId)
|
|
354
389
|
}
|
|
@@ -381,14 +416,17 @@ export class WebexClient {
|
|
|
381
416
|
): Promise<WebexMessage> {
|
|
382
417
|
if (this.useInternalAPI) {
|
|
383
418
|
const convUuid = this.decodeConvUuid(roomId)
|
|
384
|
-
const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text,
|
|
419
|
+
const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, {
|
|
420
|
+
...options,
|
|
421
|
+
forEdit: true,
|
|
422
|
+
})
|
|
385
423
|
|
|
386
424
|
const activity: Record<string, unknown> = {
|
|
387
425
|
verb: 'post',
|
|
388
426
|
object,
|
|
389
427
|
target: { id: convUuid, objectType: 'conversation' },
|
|
390
428
|
parent: { id: messageId, type: 'edit' },
|
|
391
|
-
clientTempId: `tmp-${Date.now()}`,
|
|
429
|
+
clientTempId: `tmp-${Date.now()}-edit`,
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
if (encryptionKeyUrl) {
|
|
@@ -399,6 +437,16 @@ export class WebexClient {
|
|
|
399
437
|
method: 'POST',
|
|
400
438
|
body: JSON.stringify(activity),
|
|
401
439
|
})
|
|
440
|
+
|
|
441
|
+
// Tolerate responses that omit `parent` (server may return minimal shape) —
|
|
442
|
+
// only fail on an explicit mismatch between the echoed parent and the edited id.
|
|
443
|
+
if (result.parent && result.parent.id !== messageId) {
|
|
444
|
+
throw new WebexError(
|
|
445
|
+
`Edit rejected: server linked the new activity ${result.id} to ${result.parent.id} instead of ${messageId}.`,
|
|
446
|
+
'edit_failed',
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
402
450
|
return this.activityToMessage(result, roomId)
|
|
403
451
|
}
|
|
404
452
|
const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
|
|
@@ -443,6 +491,7 @@ interface InternalActivity {
|
|
|
443
491
|
encryptionKeyUrl?: string
|
|
444
492
|
}
|
|
445
493
|
target?: { id: string; encryptionKeyUrl?: string }
|
|
494
|
+
parent?: { id: string; type: string }
|
|
446
495
|
published: string
|
|
447
496
|
encryptionKeyUrl?: string
|
|
448
497
|
}
|
|
@@ -3,7 +3,9 @@ import * as childProcess from 'node:child_process'
|
|
|
3
3
|
|
|
4
4
|
import { WebexClient } from '../client'
|
|
5
5
|
import { WebexCredentialManager } from '../credential-manager'
|
|
6
|
-
import {
|
|
6
|
+
import { WebexTokenExtractor } from '../token-extractor'
|
|
7
|
+
import { WebexError } from '../types'
|
|
8
|
+
import { extractAction, loginAction, logoutAction, statusAction } from './auth'
|
|
7
9
|
|
|
8
10
|
describe('auth commands', () => {
|
|
9
11
|
let consoleSpy: ReturnType<typeof spyOn>
|
|
@@ -208,6 +210,125 @@ describe('auth commands', () => {
|
|
|
208
210
|
})
|
|
209
211
|
})
|
|
210
212
|
|
|
213
|
+
describe('extractAction', () => {
|
|
214
|
+
test('passes deviceUrl and tokenType to client.login', async () => {
|
|
215
|
+
protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
|
|
216
|
+
accessToken: 'extracted-token-at-least-twenty-chars',
|
|
217
|
+
refreshToken: 'refresh-token',
|
|
218
|
+
expiresAt: Date.now() + 3600000,
|
|
219
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id',
|
|
220
|
+
userId: 'user-1',
|
|
221
|
+
})
|
|
222
|
+
const loginSpy = protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
223
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
224
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
225
|
+
|
|
226
|
+
await extractAction({ pretty: false })
|
|
227
|
+
|
|
228
|
+
expect(loginSpy).toHaveBeenCalledWith({
|
|
229
|
+
token: 'extracted-token-at-least-twenty-chars',
|
|
230
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id',
|
|
231
|
+
tokenType: 'extracted',
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('attempts refresh when token is expired', async () => {
|
|
236
|
+
protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
|
|
237
|
+
accessToken: 'expired-token-at-least-twenty-chars-',
|
|
238
|
+
refreshToken: 'valid-refresh-token',
|
|
239
|
+
expiresAt: Date.now() - 7200000,
|
|
240
|
+
})
|
|
241
|
+
const refreshSpy = protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue({
|
|
242
|
+
accessToken: 'refreshed-token-at-least-twenty-ch',
|
|
243
|
+
refreshToken: 'new-refresh',
|
|
244
|
+
expiresAt: Date.now() + 3600000,
|
|
245
|
+
})
|
|
246
|
+
const loginSpy = protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
247
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
248
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
249
|
+
|
|
250
|
+
await extractAction({ pretty: false })
|
|
251
|
+
|
|
252
|
+
expect(refreshSpy).toHaveBeenCalled()
|
|
253
|
+
expect(loginSpy).toHaveBeenCalledWith(expect.objectContaining({ token: 'refreshed-token-at-least-twenty-ch' }))
|
|
254
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
255
|
+
const output = JSON.parse(lastCall)
|
|
256
|
+
expect(output.authenticated).toBe(true)
|
|
257
|
+
expect(output.refreshed).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('reports expired token with actionable hint when refresh fails', async () => {
|
|
261
|
+
protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
|
|
262
|
+
accessToken: 'expired-token-at-least-twenty-chars-',
|
|
263
|
+
refreshToken: 'bad-refresh-token',
|
|
264
|
+
expiresAt: Date.now() - 7200000,
|
|
265
|
+
})
|
|
266
|
+
protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
|
|
267
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
268
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new WebexError('Unauthorized', 'http_401'))
|
|
269
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
270
|
+
|
|
271
|
+
await extractAction({ pretty: false })
|
|
272
|
+
|
|
273
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
274
|
+
const output = JSON.parse(lastCall)
|
|
275
|
+
expect(output.error).toContain('expired')
|
|
276
|
+
expect(output.hint).toContain('web.webex.com')
|
|
277
|
+
expect(output.hint).toContain('not webex.com')
|
|
278
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('rethrows non-auth errors even when token is expired', async () => {
|
|
282
|
+
protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
|
|
283
|
+
accessToken: 'expired-token-at-least-twenty-chars-',
|
|
284
|
+
refreshToken: 'bad-refresh-token',
|
|
285
|
+
expiresAt: Date.now() - 7200000,
|
|
286
|
+
})
|
|
287
|
+
protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
|
|
288
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
289
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
|
|
290
|
+
protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
291
|
+
|
|
292
|
+
await extractAction({ pretty: false })
|
|
293
|
+
|
|
294
|
+
const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
|
|
295
|
+
if (lastCall) {
|
|
296
|
+
const output = JSON.parse(lastCall)
|
|
297
|
+
expect(output.error).toContain('Network error')
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('rethrows non-expiry auth errors', async () => {
|
|
302
|
+
protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
|
|
303
|
+
accessToken: 'valid-token-at-least-twenty-chars-xx',
|
|
304
|
+
expiresAt: Date.now() + 3600000,
|
|
305
|
+
})
|
|
306
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
307
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
|
|
308
|
+
protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
309
|
+
|
|
310
|
+
await extractAction({ pretty: false })
|
|
311
|
+
|
|
312
|
+
const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
|
|
313
|
+
if (lastCall) {
|
|
314
|
+
const output = JSON.parse(lastCall)
|
|
315
|
+
expect(output.error).toContain('Network error')
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('outputs no token found when extract returns null', async () => {
|
|
320
|
+
protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue(null)
|
|
321
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
322
|
+
|
|
323
|
+
await extractAction({ pretty: false })
|
|
324
|
+
|
|
325
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
326
|
+
const output = JSON.parse(lastCall)
|
|
327
|
+
expect(output.error).toContain('No Webex token found')
|
|
328
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
211
332
|
describe('logoutAction', () => {
|
|
212
333
|
test('clears credentials when authenticated', async () => {
|
|
213
334
|
protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue({
|
|
@@ -8,6 +8,7 @@ import { getWebexAppCredentials } from '../app-config'
|
|
|
8
8
|
import { WebexClient } from '../client'
|
|
9
9
|
import { WebexCredentialManager } from '../credential-manager'
|
|
10
10
|
import { WebexTokenExtractor } from '../token-extractor'
|
|
11
|
+
import { WebexError } from '../types'
|
|
11
12
|
|
|
12
13
|
interface ResolvedCredentials {
|
|
13
14
|
clientId: string
|
|
@@ -142,7 +143,8 @@ export async function statusAction(options: { pretty?: boolean }): Promise<void>
|
|
|
142
143
|
|
|
143
144
|
export async function extractAction(options: { pretty?: boolean; debug?: boolean }): Promise<void> {
|
|
144
145
|
try {
|
|
145
|
-
const
|
|
146
|
+
const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
|
|
147
|
+
const extractor = new WebexTokenExtractor(undefined, debugLog)
|
|
146
148
|
|
|
147
149
|
if (options.debug) {
|
|
148
150
|
debug('[debug] Searching browser profiles for Webex tokens...')
|
|
@@ -155,7 +157,7 @@ export async function extractAction(options: { pretty?: boolean; debug?: boolean
|
|
|
155
157
|
formatOutput(
|
|
156
158
|
{
|
|
157
159
|
error:
|
|
158
|
-
'No Webex token found in any browser. Make sure you are logged in
|
|
160
|
+
'No Webex token found in any browser. Make sure you are logged in at https://web.webex.com (not webex.com) in Chrome, Edge, Arc, or Brave.',
|
|
159
161
|
hint: 'Run "auth login" for OAuth Device Grant flow, or --debug for more info.',
|
|
160
162
|
},
|
|
161
163
|
options.pretty,
|
|
@@ -165,30 +167,83 @@ export async function extractAction(options: { pretty?: boolean; debug?: boolean
|
|
|
165
167
|
return
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
+
const isExpired = extracted.expiresAt != null && extracted.expiresAt > 0 && extracted.expiresAt < Date.now()
|
|
171
|
+
if (isExpired && options.debug) {
|
|
172
|
+
const agoMs = Date.now() - extracted.expiresAt!
|
|
173
|
+
const agoHours = Math.round(agoMs / 3_600_000)
|
|
174
|
+
debugLog?.(`Token expired ${agoHours > 0 ? `${agoHours}h ago` : 'recently'}.`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let activeToken = extracted.accessToken
|
|
178
|
+
let refreshedConfig: { accessToken: string; refreshToken: string; expiresAt: number } | null = null
|
|
179
|
+
|
|
180
|
+
if (isExpired && extracted.refreshToken) {
|
|
181
|
+
debugLog?.('Attempting token refresh...')
|
|
182
|
+
const credManager = new WebexCredentialManager()
|
|
183
|
+
const { clientId, clientSecret } = getWebexAppCredentials()
|
|
184
|
+
refreshedConfig = await credManager.refreshToken(extracted.refreshToken, clientId, clientSecret)
|
|
185
|
+
if (refreshedConfig) {
|
|
186
|
+
debugLog?.('Token refreshed successfully.')
|
|
187
|
+
activeToken = refreshedConfig.accessToken
|
|
188
|
+
} else {
|
|
189
|
+
debugLog?.('Token refresh failed. Will attempt validation with expired token.')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const client = await new WebexClient().login({
|
|
194
|
+
token: activeToken,
|
|
195
|
+
deviceUrl: extracted.deviceUrl,
|
|
196
|
+
tokenType: 'extracted',
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
let person: { id: string; displayName: string; emails: string[] } | null = null
|
|
200
|
+
try {
|
|
201
|
+
const result = await client.testAuth()
|
|
202
|
+
if (result.id) {
|
|
203
|
+
person = { id: result.id, displayName: result.displayName, emails: result.emails }
|
|
204
|
+
}
|
|
205
|
+
} catch (authError) {
|
|
206
|
+
const isAuthFailure =
|
|
207
|
+
authError instanceof WebexError && (authError.code === 'http_401' || authError.code === 'http_403')
|
|
208
|
+
if (isExpired && isAuthFailure) {
|
|
209
|
+
console.log(
|
|
210
|
+
formatOutput(
|
|
211
|
+
{
|
|
212
|
+
error: 'Extracted browser token is expired and could not be refreshed.',
|
|
213
|
+
hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth login" for OAuth Device Grant flow.',
|
|
214
|
+
},
|
|
215
|
+
options.pretty,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
process.exit(1)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
throw authError
|
|
222
|
+
}
|
|
170
223
|
|
|
171
224
|
const credManager = new WebexCredentialManager()
|
|
172
225
|
await credManager.saveConfig({
|
|
173
|
-
accessToken:
|
|
174
|
-
refreshToken: extracted.refreshToken ?? '',
|
|
175
|
-
expiresAt: extracted.expiresAt ?? 0,
|
|
226
|
+
accessToken: activeToken,
|
|
227
|
+
refreshToken: refreshedConfig?.refreshToken ?? extracted.refreshToken ?? '',
|
|
228
|
+
expiresAt: refreshedConfig?.expiresAt ?? extracted.expiresAt ?? 0,
|
|
176
229
|
tokenType: 'extracted',
|
|
177
230
|
deviceUrl: extracted.deviceUrl,
|
|
178
231
|
userId: extracted.userId,
|
|
179
232
|
encryptionKeys: extracted.encryptionKeys ? Object.fromEntries(extracted.encryptionKeys) : undefined,
|
|
180
233
|
})
|
|
181
234
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
235
|
+
const output: Record<string, unknown> = {
|
|
236
|
+
authenticated: true,
|
|
237
|
+
tokenType: 'extracted',
|
|
238
|
+
}
|
|
239
|
+
if (refreshedConfig) {
|
|
240
|
+
output['refreshed'] = true
|
|
241
|
+
}
|
|
242
|
+
if (person) {
|
|
243
|
+
output['user'] = person
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(formatOutput(output, options.pretty))
|
|
192
247
|
} catch (error) {
|
|
193
248
|
handleError(error as Error)
|
|
194
249
|
}
|
|
@@ -291,6 +291,69 @@ describe('WebexCredentialManager', () => {
|
|
|
291
291
|
expect(loaded?.clientSecret).toBe('my-client-secret')
|
|
292
292
|
})
|
|
293
293
|
|
|
294
|
+
test('getToken tries refresh for expired extracted tokens', async () => {
|
|
295
|
+
const originalFetch = globalThis.fetch
|
|
296
|
+
globalThis.fetch = mock(() =>
|
|
297
|
+
Promise.resolve(
|
|
298
|
+
new Response(
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
access_token: 'refreshed-extracted-token',
|
|
301
|
+
refresh_token: 'new-refresh',
|
|
302
|
+
expires_in: 3600,
|
|
303
|
+
}),
|
|
304
|
+
{ status: 200 },
|
|
305
|
+
),
|
|
306
|
+
),
|
|
307
|
+
) as typeof fetch
|
|
308
|
+
|
|
309
|
+
await credManager.saveConfig({
|
|
310
|
+
accessToken: 'expired-extracted-token',
|
|
311
|
+
refreshToken: 'extracted-refresh',
|
|
312
|
+
expiresAt: Date.now() - 1000,
|
|
313
|
+
tokenType: 'extracted',
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const token = await credManager.getToken()
|
|
317
|
+
expect(token).toBe('refreshed-extracted-token')
|
|
318
|
+
|
|
319
|
+
const config = await credManager.loadConfig()
|
|
320
|
+
expect(config?.tokenType).toBe('extracted')
|
|
321
|
+
expect(config?.accessToken).toBe('refreshed-extracted-token')
|
|
322
|
+
|
|
323
|
+
globalThis.fetch = originalFetch
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('getToken returns expired extracted token when refresh fails', async () => {
|
|
327
|
+
const originalFetch = globalThis.fetch
|
|
328
|
+
globalThis.fetch = mock(() =>
|
|
329
|
+
Promise.resolve(new Response('{"error":"invalid_grant"}', { status: 400 })),
|
|
330
|
+
) as typeof fetch
|
|
331
|
+
|
|
332
|
+
await credManager.saveConfig({
|
|
333
|
+
accessToken: 'expired-extracted-token',
|
|
334
|
+
refreshToken: 'bad-refresh',
|
|
335
|
+
expiresAt: Date.now() - 1000,
|
|
336
|
+
tokenType: 'extracted',
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const token = await credManager.getToken()
|
|
340
|
+
expect(token).toBe('expired-extracted-token')
|
|
341
|
+
|
|
342
|
+
globalThis.fetch = originalFetch
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('getToken returns non-expired extracted token without refresh', async () => {
|
|
346
|
+
await credManager.saveConfig({
|
|
347
|
+
accessToken: 'valid-extracted-token',
|
|
348
|
+
refreshToken: 'refresh',
|
|
349
|
+
expiresAt: Date.now() + 3600000,
|
|
350
|
+
tokenType: 'extracted',
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const token = await credManager.getToken()
|
|
354
|
+
expect(token).toBe('valid-extracted-token')
|
|
355
|
+
})
|
|
356
|
+
|
|
294
357
|
test('loadConfig backward compat — old config without clientId/clientSecret', async () => {
|
|
295
358
|
// Write raw JSON without clientId/clientSecret fields
|
|
296
359
|
const credPath = join(tempDir, 'webex-credentials.json')
|
|
@@ -45,16 +45,33 @@ export class WebexCredentialManager {
|
|
|
45
45
|
const config = await this.loadConfig()
|
|
46
46
|
if (!config) return null
|
|
47
47
|
|
|
48
|
-
if (config.tokenType === 'manual'
|
|
48
|
+
if (config.tokenType === 'manual') {
|
|
49
49
|
return config.accessToken
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
const isExpired = config.expiresAt > 0 && config.expiresAt < Date.now() + 5 * 60 * 1000
|
|
53
|
+
|
|
54
|
+
if (config.tokenType === 'extracted') {
|
|
55
|
+
if (isExpired && config.refreshToken) {
|
|
56
|
+
const builtinCreds = getWebexAppCredentials()
|
|
57
|
+
const refreshed = await this.refreshToken(config.refreshToken, builtinCreds.clientId, builtinCreds.clientSecret)
|
|
58
|
+
if (refreshed) {
|
|
59
|
+
await this.saveConfig({ ...config, ...refreshed, tokenType: 'extracted' })
|
|
60
|
+
return refreshed.accessToken
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return config.accessToken
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isExpired) {
|
|
53
67
|
const builtinCreds = getWebexAppCredentials()
|
|
54
68
|
const resolvedClientId = clientId ?? config.clientId ?? builtinCreds.clientId
|
|
55
69
|
const resolvedClientSecret = clientSecret ?? config.clientSecret ?? builtinCreds.clientSecret
|
|
56
70
|
const refreshed = await this.refreshToken(config.refreshToken, resolvedClientId, resolvedClientSecret)
|
|
57
|
-
if (refreshed)
|
|
71
|
+
if (refreshed) {
|
|
72
|
+
await this.saveConfig({ ...config, ...refreshed })
|
|
73
|
+
return refreshed.accessToken
|
|
74
|
+
}
|
|
58
75
|
return null
|
|
59
76
|
}
|
|
60
77
|
|
|
@@ -82,14 +99,11 @@ export class WebexCredentialManager {
|
|
|
82
99
|
expires_in: number
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
|
|
102
|
+
return {
|
|
86
103
|
accessToken: data.access_token,
|
|
87
104
|
refreshToken: data.refresh_token,
|
|
88
105
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
await this.saveConfig(config)
|
|
92
|
-
return config
|
|
106
|
+
} satisfies Pick<WebexConfig, 'accessToken' | 'refreshToken' | 'expiresAt'>
|
|
93
107
|
} catch {
|
|
94
108
|
return null
|
|
95
109
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import * as jose from 'node-jose'
|
|
4
|
+
|
|
5
|
+
import { WebexEncryptionService } from './encryption'
|
|
6
|
+
|
|
7
|
+
const decodeJweHeader = (jwe: string): Record<string, unknown> => {
|
|
8
|
+
const [header = ''] = jwe.split('.')
|
|
9
|
+
const padded = header + '='.repeat((4 - (header.length % 4)) % 4)
|
|
10
|
+
const json = Buffer.from(padded, 'base64url').toString('utf8')
|
|
11
|
+
return JSON.parse(json) as Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const createKeyring = async (keyUri: string) => {
|
|
15
|
+
const keystore = jose.JWK.createKeyStore()
|
|
16
|
+
const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
|
|
17
|
+
const jwk = key.toJSON(true)
|
|
18
|
+
const rawKeys = new Map<string, string>()
|
|
19
|
+
rawKeys.set(keyUri, JSON.stringify({ jwk }))
|
|
20
|
+
return new WebexEncryptionService(rawKeys)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('WebexEncryptionService', () => {
|
|
24
|
+
const keyUri = 'kms://kms-aore.wbx2.com/keys/7819829b-5e0d-4139-9cad-1b6fe7aee533'
|
|
25
|
+
|
|
26
|
+
test('encryptText emits JWE with alg, enc, and kid JOSE headers', async () => {
|
|
27
|
+
const service = await createKeyring(keyUri)
|
|
28
|
+
|
|
29
|
+
const jwe = await service.encryptText(keyUri, 'hello world')
|
|
30
|
+
|
|
31
|
+
expect(jwe).not.toBeNull()
|
|
32
|
+
const header = decodeJweHeader(jwe as string)
|
|
33
|
+
expect(header.alg).toBe('dir')
|
|
34
|
+
expect(header.enc).toBe('A256GCM')
|
|
35
|
+
expect(header.kid).toBe(keyUri)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('encryptText returns null when key is unknown', async () => {
|
|
39
|
+
const service = await createKeyring(keyUri)
|
|
40
|
+
|
|
41
|
+
const jwe = await service.encryptText('kms://other/keys/missing', 'hello')
|
|
42
|
+
|
|
43
|
+
expect(jwe).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('decryptText round-trips plaintext encrypted by encryptText', async () => {
|
|
47
|
+
const service = await createKeyring(keyUri)
|
|
48
|
+
|
|
49
|
+
const jwe = await service.encryptText(keyUri, 'round trip')
|
|
50
|
+
const plaintext = await service.decryptText(keyUri, jwe as string)
|
|
51
|
+
|
|
52
|
+
expect(plaintext).toBe('round trip')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -30,9 +30,11 @@ export class WebexEncryptionService {
|
|
|
30
30
|
if (!key) return null
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
|
+
// Webex desktop/web clients auto-tombstone edit activities whose JWE is missing
|
|
34
|
+
// `kid` — they can't resolve the KMS key and treat the activity as malformed.
|
|
33
35
|
return await jose.JWE.createEncrypt(
|
|
34
36
|
{ format: 'compact', contentAlg: 'A256GCM' },
|
|
35
|
-
{ key, header: { alg: 'dir' }, reference: null },
|
|
37
|
+
{ key, header: { alg: 'dir', kid: keyUri }, reference: null },
|
|
36
38
|
).final(plaintext, 'utf8')
|
|
37
39
|
} catch {
|
|
38
40
|
return null
|
|
@@ -11,7 +11,11 @@ export async function ensureWebexAuth(): Promise<void> {
|
|
|
11
11
|
const token = await credManager.getToken(config.clientId, config.clientSecret)
|
|
12
12
|
if (token) {
|
|
13
13
|
const client = new WebexClient()
|
|
14
|
-
await client.login({
|
|
14
|
+
await client.login({
|
|
15
|
+
token,
|
|
16
|
+
deviceUrl: config.deviceUrl,
|
|
17
|
+
tokenType: config.tokenType,
|
|
18
|
+
})
|
|
15
19
|
await client.testAuth()
|
|
16
20
|
return
|
|
17
21
|
}
|
|
@@ -22,7 +26,11 @@ export async function ensureWebexAuth(): Promise<void> {
|
|
|
22
26
|
if (!extracted) return
|
|
23
27
|
|
|
24
28
|
const client = new WebexClient()
|
|
25
|
-
await client.login({
|
|
29
|
+
await client.login({
|
|
30
|
+
token: extracted.accessToken,
|
|
31
|
+
deviceUrl: extracted.deviceUrl,
|
|
32
|
+
tokenType: 'extracted',
|
|
33
|
+
})
|
|
26
34
|
await client.testAuth()
|
|
27
35
|
|
|
28
36
|
await credManager.saveConfig({
|