agent-messenger 2.21.0 → 2.23.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 +21 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/webex/client.d.ts +25 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +115 -5
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +141 -25
- 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 +8 -4
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
- package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
- package/dist/src/platforms/webex/id-normalizer.js +60 -0
- package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +4 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +2 -0
- package/dist/src/platforms/webex/index.js.map +1 -1
- package/dist/src/platforms/webex/listener.d.ts +61 -0
- package/dist/src/platforms/webex/listener.d.ts.map +1 -0
- package/dist/src/platforms/webex/listener.js +222 -0
- package/dist/src/platforms/webex/listener.js.map +1 -0
- package/dist/src/platforms/webex/password-login.d.ts +18 -0
- package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
- package/dist/src/platforms/webex/password-login.js +259 -0
- package/dist/src/platforms/webex/password-login.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +2 -1
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.js +1 -1
- package/dist/src/platforms/webex/types.js.map +1 -1
- package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
- package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
- package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/cli.js +4 -1
- package/dist/src/platforms/webexbot/cli.js.map +1 -1
- package/dist/src/platforms/webexbot/client.d.ts +24 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +81 -5
- package/dist/src/platforms/webexbot/client.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
- package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/file.js +64 -0
- package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/commands/index.js +3 -0
- package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
- package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/commands/message.js +52 -1
- package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
- package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
- package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
- package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/user.js +66 -0
- package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
- package/dist/src/platforms/webexbot/index.d.ts +2 -0
- package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/index.js +1 -0
- package/dist/src/platforms/webexbot/index.js.map +1 -1
- package/dist/src/platforms/webexbot/listener.d.ts +3 -41
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/listener.js +13 -208
- package/dist/src/platforms/webexbot/listener.js.map +1 -1
- package/dist/src/platforms/webexbot/types.d.ts +1 -18
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/types.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +38 -12
- package/docs/content/docs/cli/webexbot.mdx +2 -0
- package/docs/content/docs/sdk/webexbot.mdx +18 -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-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +76 -22
- package/skills/agent-webex/references/authentication.md +55 -14
- package/skills/agent-webex/references/common-patterns.md +5 -2
- package/skills/agent-webexbot/SKILL.md +60 -5
- package/skills/agent-webexbot/references/common-patterns.md +118 -0
- 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/webex/cli.test.ts +31 -1
- package/src/platforms/webex/client.test.ts +67 -0
- package/src/platforms/webex/client.ts +136 -7
- package/src/platforms/webex/commands/auth.test.ts +189 -28
- package/src/platforms/webex/commands/auth.ts +194 -35
- package/src/platforms/webex/credential-manager.test.ts +40 -0
- package/src/platforms/webex/credential-manager.ts +7 -4
- package/src/platforms/webex/id-normalizer.test.ts +207 -0
- package/src/platforms/webex/id-normalizer.ts +76 -0
- package/src/platforms/webex/index.test.ts +6 -0
- package/src/platforms/webex/index.ts +4 -0
- package/src/platforms/webex/listener.test.ts +243 -0
- package/src/platforms/webex/listener.ts +285 -0
- package/src/platforms/webex/password-login.test.ts +193 -0
- package/src/platforms/webex/password-login.ts +332 -0
- package/src/platforms/webex/types.test.ts +16 -0
- package/src/platforms/webex/types.ts +2 -2
- package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
- package/src/platforms/webexbot/cli.ts +6 -0
- package/src/platforms/webexbot/client.test.ts +322 -0
- package/src/platforms/webexbot/client.ts +104 -7
- package/src/platforms/webexbot/commands/file.ts +104 -0
- package/src/platforms/webexbot/commands/index.ts +3 -0
- package/src/platforms/webexbot/commands/message.ts +68 -2
- package/src/platforms/webexbot/commands/snapshot.ts +60 -0
- package/src/platforms/webexbot/commands/user.test.ts +77 -0
- package/src/platforms/webexbot/commands/user.ts +98 -0
- package/src/platforms/webexbot/index.ts +2 -0
- package/src/platforms/webexbot/listener.test.ts +37 -224
- package/src/platforms/webexbot/listener.ts +18 -250
- package/src/platforms/webexbot/types.ts +2 -23
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
- package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
- /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
- /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { WebexError } from './types'
|
|
4
|
+
|
|
5
|
+
export const WEB_CLIENT_ID = 'C64ab04639eefee4798f58e7bc3fe01d47161be0d97ff0d31e040a6ffe66d7f0a'
|
|
6
|
+
export const WEB_CLIENT_SECRET = 'f4261a01a4111b3b3b1710583073cae9cd7104517e7f78800c43d01eea133782'
|
|
7
|
+
|
|
8
|
+
const REDIRECT_URI = 'https://web.webex.com'
|
|
9
|
+
const DEFAULT_IDBROKER_HOST = 'https://idbroker.webex.com'
|
|
10
|
+
const SCOPE =
|
|
11
|
+
'webexsquare:get_conversation Identity:SCIM identity:things_read spark:kms spark:people_read spark:people_write spark:organizations_read spark:rooms_read spark:rooms_write spark:memberships_read spark:calls_read spark:calls_write webexsquare:admin'
|
|
12
|
+
const U2C_URL = 'https://u2c.svc.webex.com/u2c/api/v1/limited/catalog'
|
|
13
|
+
const MAX_REDIRECTS = 12
|
|
14
|
+
|
|
15
|
+
export interface PasswordLoginOptions {
|
|
16
|
+
idbrokerHost?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PasswordLoginResult {
|
|
20
|
+
accessToken: string
|
|
21
|
+
refreshToken: string
|
|
22
|
+
expiresAt: number
|
|
23
|
+
deviceUrl: string
|
|
24
|
+
userId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TokenResponse {
|
|
28
|
+
access_token: string
|
|
29
|
+
refresh_token: string
|
|
30
|
+
expires_in: number
|
|
31
|
+
refresh_token_expires_in?: number
|
|
32
|
+
scope?: string
|
|
33
|
+
token_type?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface U2CDiscovery {
|
|
37
|
+
idbrokerHost: string
|
|
38
|
+
wdmHost?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RedirectResult {
|
|
42
|
+
response: Response
|
|
43
|
+
url: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function loginWithPassword(
|
|
47
|
+
email: string,
|
|
48
|
+
password: string,
|
|
49
|
+
options?: PasswordLoginOptions,
|
|
50
|
+
): Promise<PasswordLoginResult> {
|
|
51
|
+
const normalizedEmail = email.toLowerCase()
|
|
52
|
+
const emailHash = sha256Hex(normalizedEmail)
|
|
53
|
+
const discovery = await discoverU2C(emailHash)
|
|
54
|
+
const idbrokerHost = normalizeOrigin(options?.idbrokerHost ?? discovery.idbrokerHost)
|
|
55
|
+
const pkce = createPkcePair()
|
|
56
|
+
const state = base64url(Buffer.from(JSON.stringify({ csrf_token: crypto.randomUUID(), emailhash: emailHash })))
|
|
57
|
+
const cookieJar = new CookieJar()
|
|
58
|
+
|
|
59
|
+
const authorizeUrl = `${idbrokerHost}/idb/oauth2/v1/authorize?${new URLSearchParams({
|
|
60
|
+
response_type: 'code',
|
|
61
|
+
cisKeepMeSignedInOption: '1',
|
|
62
|
+
state,
|
|
63
|
+
cisService: 'webex',
|
|
64
|
+
emailHash,
|
|
65
|
+
code_challenge: pkce.challenge,
|
|
66
|
+
code_challenge_method: 'S256',
|
|
67
|
+
client_id: WEB_CLIENT_ID,
|
|
68
|
+
redirect_uri: REDIRECT_URI,
|
|
69
|
+
scope: SCOPE,
|
|
70
|
+
}).toString()}`
|
|
71
|
+
|
|
72
|
+
const authorizeResult = await followRedirects(authorizeUrl, { method: 'GET' }, cookieJar)
|
|
73
|
+
const clusterHost = new URL(authorizeResult.url).origin
|
|
74
|
+
// Reject before touching credentials: if authorize redirected off Webex (an SSO
|
|
75
|
+
// IdP), posting IDToken1/IDToken2 here would leak the password to that host.
|
|
76
|
+
if (!isWebexHost(new URL(clusterHost).host)) {
|
|
77
|
+
throw new WebexError('SSO/IdP login is not supported for headless password login', 'sso_required')
|
|
78
|
+
}
|
|
79
|
+
const loginPageHtml = await authorizeResult.response.text()
|
|
80
|
+
const fields = parseInputFields(loginPageHtml)
|
|
81
|
+
fields.set('IDToken0', '')
|
|
82
|
+
fields.set('IDToken1', email)
|
|
83
|
+
fields.set('IDToken2', password)
|
|
84
|
+
fields.set('IDButton', 'Sign In')
|
|
85
|
+
fields.set('loginid', email)
|
|
86
|
+
|
|
87
|
+
const loginBody = new URLSearchParams()
|
|
88
|
+
for (const [key, value] of fields) loginBody.set(key, value)
|
|
89
|
+
|
|
90
|
+
const loginResult = await followRedirects(
|
|
91
|
+
`${clusterHost}/idb/UI/Login`,
|
|
92
|
+
{
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
95
|
+
body: loginBody.toString(),
|
|
96
|
+
},
|
|
97
|
+
cookieJar,
|
|
98
|
+
)
|
|
99
|
+
const code = getAuthorizationCode(loginResult.url)
|
|
100
|
+
|
|
101
|
+
if (code === null) {
|
|
102
|
+
await throwLoginFailure(loginResult)
|
|
103
|
+
throw new WebexError('Login failed: invalid credentials or unexpected response', 'login_failed')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const token = await exchangeCode(clusterHost, code, pkce.verifier)
|
|
107
|
+
const device = await registerDevice(discovery.wdmHost, token.access_token)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
accessToken: token.access_token,
|
|
111
|
+
refreshToken: token.refresh_token,
|
|
112
|
+
expiresAt: Date.now() + token.expires_in * 1000,
|
|
113
|
+
deviceUrl: device.deviceUrl,
|
|
114
|
+
userId: device.userId,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createPkcePair(verifier?: string): { verifier: string; challenge: string } {
|
|
119
|
+
const resolvedVerifier = verifier ?? base64url(randomBytes(64))
|
|
120
|
+
return {
|
|
121
|
+
verifier: resolvedVerifier,
|
|
122
|
+
challenge: base64url(createHash('sha256').update(resolvedVerifier).digest()),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function discoverU2C(emailHash: string): Promise<U2CDiscovery> {
|
|
127
|
+
try {
|
|
128
|
+
const url = `${U2C_URL}?${new URLSearchParams({ format: 'hostmap', emailhash: emailHash }).toString()}`
|
|
129
|
+
const response = await fetch(url)
|
|
130
|
+
if (!response.ok) return { idbrokerHost: DEFAULT_IDBROKER_HOST }
|
|
131
|
+
const catalog = (await response.json()) as { serviceLinks?: Record<string, string> }
|
|
132
|
+
// Match the exact `idbroker` serviceLink key: a substring scan would wrongly
|
|
133
|
+
// pick `idbroker-guest`, routing login to a cluster the user has no account on.
|
|
134
|
+
const links = catalog.serviceLinks ?? {}
|
|
135
|
+
return {
|
|
136
|
+
idbrokerHost: typeof links.idbroker === 'string' ? normalizeOrigin(links.idbroker) : DEFAULT_IDBROKER_HOST,
|
|
137
|
+
wdmHost: typeof links.wdm === 'string' ? normalizeOrigin(links.wdm) : undefined,
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
return { idbrokerHost: DEFAULT_IDBROKER_HOST }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function exchangeCode(clusterHost: string, code: string, verifier: string): Promise<TokenResponse> {
|
|
145
|
+
const response = await fetch(`${clusterHost}/idb/oauth2/v1/access_token`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: {
|
|
148
|
+
Authorization: `Basic ${Buffer.from(`${WEB_CLIENT_ID}:${WEB_CLIENT_SECRET}`).toString('base64')}`,
|
|
149
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
150
|
+
},
|
|
151
|
+
body: new URLSearchParams({
|
|
152
|
+
grant_type: 'authorization_code',
|
|
153
|
+
redirect_uri: REDIRECT_URI,
|
|
154
|
+
code,
|
|
155
|
+
code_verifier: verifier,
|
|
156
|
+
self_contained_token: 'true',
|
|
157
|
+
}).toString(),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new WebexError(`Token exchange failed: HTTP ${response.status}`, 'token_exchange_failed')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const token = (await response.json()) as Partial<TokenResponse>
|
|
165
|
+
if (!token.access_token || !token.refresh_token || typeof token.expires_in !== 'number') {
|
|
166
|
+
throw new WebexError('Token exchange failed: incomplete response', 'token_exchange_failed')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return token as TokenResponse
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function registerDevice(
|
|
173
|
+
wdmHost: string | undefined,
|
|
174
|
+
accessToken: string,
|
|
175
|
+
): Promise<{ deviceUrl: string; userId: string }> {
|
|
176
|
+
// deviceUrl is required: without it WebexClient falls back to the plaintext
|
|
177
|
+
// public API instead of the internal KMS-encrypted path password tokens need.
|
|
178
|
+
if (!wdmHost) {
|
|
179
|
+
throw new WebexError('Webex device registration service was not found in the catalog', 'device_registration_failed')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const response = await fetch(`${normalizeOrigin(wdmHost)}/wdm/api/v1/devices`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
deviceName: 'agent-messenger',
|
|
187
|
+
name: 'agent-messenger',
|
|
188
|
+
deviceType: 'UNKNOWN',
|
|
189
|
+
model: 'agent-messenger',
|
|
190
|
+
localizedModel: 'agent-messenger',
|
|
191
|
+
systemName: 'agent-messenger',
|
|
192
|
+
systemVersion: '1.0',
|
|
193
|
+
}),
|
|
194
|
+
})
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
throw new WebexError(`Webex device registration failed: HTTP ${response.status}`, 'device_registration_failed')
|
|
197
|
+
}
|
|
198
|
+
const data = (await response.json()) as { url?: string; userId?: string }
|
|
199
|
+
if (!data.url || !data.userId) {
|
|
200
|
+
throw new WebexError('Webex device registration returned an incomplete device', 'device_registration_failed')
|
|
201
|
+
}
|
|
202
|
+
return { deviceUrl: data.url, userId: data.userId }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function followRedirects(url: string, init: RequestInit, cookieJar: CookieJar): Promise<RedirectResult> {
|
|
206
|
+
let currentUrl = url
|
|
207
|
+
let response = await fetchWithCookies(currentUrl, init, cookieJar)
|
|
208
|
+
let redirects = 0
|
|
209
|
+
|
|
210
|
+
while (isRedirect(response) && redirects++ < MAX_REDIRECTS) {
|
|
211
|
+
const location = response.headers.get('location')
|
|
212
|
+
if (!location) break
|
|
213
|
+
currentUrl = new URL(location, currentUrl).toString()
|
|
214
|
+
if (currentUrl.startsWith(REDIRECT_URI) && /[?&]code=/.test(currentUrl)) {
|
|
215
|
+
return { response, url: currentUrl }
|
|
216
|
+
}
|
|
217
|
+
response = await fetchWithCookies(currentUrl, { method: 'GET' }, cookieJar)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { response, url: currentUrl }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function fetchWithCookies(url: string, init: RequestInit, cookieJar: CookieJar): Promise<Response> {
|
|
224
|
+
const headers = new Headers(init.headers)
|
|
225
|
+
// Only attach/store session cookies on Webex hosts so a redirect to an external
|
|
226
|
+
// IdP (SSO) never receives the Webex session cookies.
|
|
227
|
+
const isWebex = isWebexHost(new URL(url).host)
|
|
228
|
+
const cookie = cookieJar.header()
|
|
229
|
+
if (cookie && isWebex) headers.set('Cookie', cookie)
|
|
230
|
+
const response = await fetch(url, { ...init, redirect: 'manual', headers })
|
|
231
|
+
if (isWebex) cookieJar.apply(response)
|
|
232
|
+
return response
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseInputFields(html: string): Map<string, string> {
|
|
236
|
+
const fields = new Map<string, string>()
|
|
237
|
+
for (const input of html.match(/<input[^>]*>/gi) ?? []) {
|
|
238
|
+
const attrs = parseAttributes(input)
|
|
239
|
+
const name = attrs.get('name')
|
|
240
|
+
if (name && !fields.has(name)) {
|
|
241
|
+
fields.set(name, decodeEntities(attrs.get('value') ?? ''))
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return fields
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseAttributes(tag: string): Map<string, string> {
|
|
248
|
+
const attrs = new Map<string, string>()
|
|
249
|
+
const attrPattern = /([:\w-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g
|
|
250
|
+
let match = attrPattern.exec(tag)
|
|
251
|
+
while (match) {
|
|
252
|
+
const rawValue = match[2]
|
|
253
|
+
const value = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue.slice(1, -1) : rawValue
|
|
254
|
+
attrs.set(match[1], decodeEntities(value))
|
|
255
|
+
match = attrPattern.exec(tag)
|
|
256
|
+
}
|
|
257
|
+
return attrs
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function decodeEntities(value: string): string {
|
|
261
|
+
return value
|
|
262
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
|
|
263
|
+
.replace(/&#(\d+);/g, (_, decimal: string) => String.fromCodePoint(parseInt(decimal, 10)))
|
|
264
|
+
.replace(/&/g, '&')
|
|
265
|
+
.replace(/"/g, '"')
|
|
266
|
+
.replace(/'/g, "'")
|
|
267
|
+
.replace(/</g, '<')
|
|
268
|
+
.replace(/>/g, '>')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function throwLoginFailure(loginResult: RedirectResult): Promise<never> {
|
|
272
|
+
const finalHost = new URL(loginResult.url).host
|
|
273
|
+
if (!isWebexHost(finalHost)) {
|
|
274
|
+
throw new WebexError('SSO/IdP login is not supported for headless password login', 'sso_required')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const html = await loginResult.response.text()
|
|
278
|
+
if (/verification code|passcode|one-time|security code|\bOTP\b|\bMFA\b/i.test(html)) {
|
|
279
|
+
throw new WebexError('Account requires MFA; headless password login is not supported', 'mfa_required')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new WebexError('Login failed: invalid credentials or unexpected response', 'login_failed')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getAuthorizationCode(url: string): string | null {
|
|
286
|
+
const code = new URL(url).searchParams.get('code')
|
|
287
|
+
return code ? decodeURIComponent(code) : null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isRedirect(response: Response): boolean {
|
|
291
|
+
return response.status >= 300 && response.status < 400
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isWebexHost(host: string): boolean {
|
|
295
|
+
return host === 'webex.com' || host.endsWith('.webex.com') || host === 'wbx2.com' || host.endsWith('.wbx2.com')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeOrigin(value: string): string {
|
|
299
|
+
return new URL(value).origin
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sha256Hex(value: string): string {
|
|
303
|
+
return createHash('sha256').update(value).digest('hex')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function base64url(buffer: Buffer): string {
|
|
307
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
class CookieJar {
|
|
311
|
+
private cookies = new Map<string, string>()
|
|
312
|
+
|
|
313
|
+
apply(response: Response): void {
|
|
314
|
+
for (const cookie of getSetCookies(response.headers)) {
|
|
315
|
+
const [pair] = cookie.split(';')
|
|
316
|
+
const index = pair.indexOf('=')
|
|
317
|
+
if (index > 0) this.cookies.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim())
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
header(): string {
|
|
322
|
+
return [...this.cookies.entries()].map(([key, value]) => `${key}=${value}`).join('; ')
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getSetCookies(headers: Headers): string[] {
|
|
327
|
+
const headersWithSetCookie = headers as Headers & { getSetCookie?: () => string[] }
|
|
328
|
+
const cookies = headersWithSetCookie.getSetCookie?.()
|
|
329
|
+
if (cookies?.length) return cookies
|
|
330
|
+
const single = headers.get('set-cookie')
|
|
331
|
+
return single ? [single] : []
|
|
332
|
+
}
|
|
@@ -266,6 +266,22 @@ it('WebexConfigSchema validates config with tokenType manual', () => {
|
|
|
266
266
|
}
|
|
267
267
|
})
|
|
268
268
|
|
|
269
|
+
it('WebexConfigSchema validates config with tokenType password', () => {
|
|
270
|
+
const result = WebexConfigSchema.safeParse({
|
|
271
|
+
accessToken: 'test',
|
|
272
|
+
refreshToken: 'test',
|
|
273
|
+
expiresAt: 1234567890,
|
|
274
|
+
tokenType: 'password',
|
|
275
|
+
deviceUrl: 'https://wdm-test.wbx2.com/wdm/api/v1/devices/device-1',
|
|
276
|
+
userId: 'user-1',
|
|
277
|
+
encryptionKeys: {},
|
|
278
|
+
})
|
|
279
|
+
expect(result.success).toBe(true)
|
|
280
|
+
if (result.success) {
|
|
281
|
+
expect(result.data.tokenType).toBe('password')
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
269
285
|
it('WebexConfigSchema rejects invalid tokenType', () => {
|
|
270
286
|
const result = WebexConfigSchema.safeParse({
|
|
271
287
|
accessToken: 'test',
|
|
@@ -55,7 +55,7 @@ export interface WebexConfig {
|
|
|
55
55
|
expiresAt: number
|
|
56
56
|
clientId?: string
|
|
57
57
|
clientSecret?: string
|
|
58
|
-
tokenType?: 'oauth' | 'manual' | 'extracted'
|
|
58
|
+
tokenType?: 'oauth' | 'manual' | 'extracted' | 'password'
|
|
59
59
|
deviceUrl?: string
|
|
60
60
|
userId?: string
|
|
61
61
|
encryptionKeys?: Record<string, string>
|
|
@@ -126,7 +126,7 @@ export const WebexConfigSchema = z.object({
|
|
|
126
126
|
expiresAt: z.number(),
|
|
127
127
|
clientId: z.string().optional(),
|
|
128
128
|
clientSecret: z.string().optional(),
|
|
129
|
-
tokenType: z.enum(['oauth', 'manual', 'extracted']).optional(),
|
|
129
|
+
tokenType: z.enum(['oauth', 'manual', 'extracted', 'password']).optional(),
|
|
130
130
|
deviceUrl: z.string().optional(),
|
|
131
131
|
userId: z.string().optional(),
|
|
132
132
|
encryptionKeys: z.record(z.string(), z.string()).optional(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FetchFunction, FetchRequest, FetchResponse } from 'webex-message-handler'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { WebexError } from './types'
|
|
4
4
|
|
|
5
5
|
const U2C_CATALOG_URL = 'https://u2c.wbx2.com/u2c/api/v1/catalog?format=hostmap'
|
|
6
6
|
const HARDCODED_WDM_DEVICES_URL = 'https://wdm-a.wbx2.com/wdm/api/v1/devices'
|
|
@@ -14,13 +14,13 @@ const HARDCODED_WDM_DEVICES_URL = 'https://wdm-a.wbx2.com/wdm/api/v1/devices'
|
|
|
14
14
|
export async function discoverWdmDevicesUrl(token: string): Promise<string> {
|
|
15
15
|
const response = await fetch(U2C_CATALOG_URL, { headers: { Authorization: `Bearer ${token}` } })
|
|
16
16
|
if (!response.ok) {
|
|
17
|
-
throw new
|
|
17
|
+
throw new WebexError(`Failed to discover Webex WDM cluster: HTTP ${response.status}`, 'wdm_discovery_failed')
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const catalog = (await response.json()) as { serviceLinks?: { wdm?: string } }
|
|
21
21
|
const wdm = catalog.serviceLinks?.wdm
|
|
22
22
|
if (!wdm) {
|
|
23
|
-
throw new
|
|
23
|
+
throw new WebexError('Webex U2C catalog did not include serviceLinks.wdm', 'wdm_discovery_failed')
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
return `${wdm.replace(/\/$/, '')}/devices`
|
|
@@ -5,10 +5,13 @@ import { Command } from 'commander'
|
|
|
5
5
|
import pkg from '../../../package.json' with { type: 'json' }
|
|
6
6
|
import {
|
|
7
7
|
authCommand,
|
|
8
|
+
fileCommand,
|
|
8
9
|
listenCommand,
|
|
9
10
|
memberCommand,
|
|
10
11
|
messageCommand,
|
|
12
|
+
snapshotCommand,
|
|
11
13
|
spaceCommand,
|
|
14
|
+
userCommand,
|
|
12
15
|
whoamiCommand,
|
|
13
16
|
} from './commands/index'
|
|
14
17
|
|
|
@@ -35,6 +38,9 @@ program.addCommand(whoamiCommand)
|
|
|
35
38
|
program.addCommand(messageCommand)
|
|
36
39
|
program.addCommand(spaceCommand)
|
|
37
40
|
program.addCommand(memberCommand)
|
|
41
|
+
program.addCommand(userCommand)
|
|
42
|
+
program.addCommand(fileCommand)
|
|
43
|
+
program.addCommand(snapshotCommand)
|
|
38
44
|
program.addCommand(listenCommand)
|
|
39
45
|
|
|
40
46
|
program.parseAsync(process.argv)
|