agent-messenger 2.9.0 → 2.10.1
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 +73 -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.test.ts +112 -98
- package/src/platforms/teams/token-extractor.ts +83 -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
|
@@ -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({
|
|
@@ -216,12 +216,41 @@ describe('WebexTokenExtractor', () => {
|
|
|
216
216
|
expect(result).not.toBeNull()
|
|
217
217
|
})
|
|
218
218
|
|
|
219
|
-
test('
|
|
219
|
+
test('prefers token with latest expiry across profiles', async () => {
|
|
220
220
|
const dir1 = createLevelDBDir(tempDir, 'Default')
|
|
221
221
|
const dir2 = createLevelDBDir(tempDir, 'Profile 1')
|
|
222
222
|
|
|
223
|
-
const
|
|
224
|
-
|
|
223
|
+
const expiredToken = makeWebexStorageJson({
|
|
224
|
+
accessToken: 'expired-token-longer-than-twenty-chars-xx',
|
|
225
|
+
expires: Date.now() - 3600000,
|
|
226
|
+
})
|
|
227
|
+
const freshToken = makeWebexStorageJson({
|
|
228
|
+
accessToken: 'fresh-token-longer-than-twenty-chars-xxx',
|
|
229
|
+
expires: Date.now() + 3600000,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
writeFileSync(join(dir1, '000003.log'), expiredToken)
|
|
233
|
+
writeFileSync(join(dir2, '000003.log'), freshToken)
|
|
234
|
+
|
|
235
|
+
const extractor = new WebexTokenExtractor('darwin', undefined, tempDir)
|
|
236
|
+
const result = await extractor.extract()
|
|
237
|
+
|
|
238
|
+
expect(result!.accessToken).toBe('fresh-token-longer-than-twenty-chars-xxx')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('returns first token when all have same expiry', async () => {
|
|
242
|
+
const dir1 = createLevelDBDir(tempDir, 'Default')
|
|
243
|
+
const dir2 = createLevelDBDir(tempDir, 'Profile 1')
|
|
244
|
+
|
|
245
|
+
const expires = Date.now() + 3600000
|
|
246
|
+
const token1 = makeWebexStorageJson({
|
|
247
|
+
accessToken: 'first-valid-token-longer-than-twenty-chars',
|
|
248
|
+
expires,
|
|
249
|
+
})
|
|
250
|
+
const token2 = makeWebexStorageJson({
|
|
251
|
+
accessToken: 'second-valid-token-longer-than-twenty-chars',
|
|
252
|
+
expires,
|
|
253
|
+
})
|
|
225
254
|
|
|
226
255
|
writeFileSync(join(dir1, '000003.log'), token1)
|
|
227
256
|
writeFileSync(join(dir2, '000003.log'), token2)
|
|
@@ -160,25 +160,46 @@ export class WebexTokenExtractor {
|
|
|
160
160
|
return null
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
let best: { token: ExtractedWebexToken; source: string } | null = null
|
|
164
|
+
|
|
163
165
|
for (const leveldbDir of profileDirs) {
|
|
164
166
|
this.debug(`Scanning: ${leveldbDir}`)
|
|
165
167
|
|
|
166
168
|
const result = (await this.scanViaClassicLevelCopy(leveldbDir)) ?? this.scanRawFiles(leveldbDir)
|
|
167
169
|
|
|
168
170
|
if (result?.token) {
|
|
169
|
-
this.debug(`Found token in: ${leveldbDir}`)
|
|
170
|
-
|
|
171
171
|
const token = result.token
|
|
172
172
|
if (result.encryptionKeys.size > 0) {
|
|
173
173
|
token.encryptionKeys = result.encryptionKeys
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
this.debug(
|
|
177
|
+
`Found token in: ${leveldbDir} (expires: ${token.expiresAt ? new Date(token.expiresAt).toISOString() : 'unknown'}, length: ${token.accessToken.length})`,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if (!best || this.isTokenFresher(token, best.token)) {
|
|
181
|
+
best = { token, source: leveldbDir }
|
|
182
|
+
}
|
|
177
183
|
}
|
|
178
184
|
}
|
|
179
185
|
|
|
180
|
-
|
|
181
|
-
|
|
186
|
+
if (!best) {
|
|
187
|
+
this.debug('No Webex tokens found in any browser profile')
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.debug(`Selected token from: ${best.source}`)
|
|
192
|
+
return best.token
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private isTokenFresher(candidate: ExtractedWebexToken, current: ExtractedWebexToken): boolean {
|
|
196
|
+
const candidateExpiry = candidate.expiresAt ?? 0
|
|
197
|
+
const currentExpiry = current.expiresAt ?? 0
|
|
198
|
+
if (candidateExpiry > 0 && currentExpiry > 0) {
|
|
199
|
+
return candidateExpiry > currentExpiry
|
|
200
|
+
}
|
|
201
|
+
if (candidateExpiry > 0 && currentExpiry === 0) return true
|
|
202
|
+
return false
|
|
182
203
|
}
|
|
183
204
|
|
|
184
205
|
private async scanViaClassicLevelCopy(dbPath: string): Promise<ScanResult | null> {
|