@bodhiapp/bodhi-js 0.0.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/README.md +26 -0
- package/dist/bodhi-browser-ext/src/types/bodhiext.d.ts +202 -0
- package/dist/bodhi-browser-ext/src/types/common.d.ts +36 -0
- package/dist/bodhi-browser-ext/src/types/index.d.ts +6 -0
- package/dist/bodhi-browser-ext/src/types/protocol.d.ts +223 -0
- package/dist/bodhi-js-sdk/core/src/direct-client-base.d.ts +129 -0
- package/dist/bodhi-js-sdk/core/src/errors.d.ts +23 -0
- package/dist/bodhi-js-sdk/core/src/facade-client-base.d.ts +130 -0
- package/dist/bodhi-js-sdk/core/src/index.d.ts +19 -0
- package/dist/bodhi-js-sdk/core/src/interface.d.ts +228 -0
- package/dist/bodhi-js-sdk/core/src/logger.d.ts +13 -0
- package/dist/bodhi-js-sdk/core/src/oauth.d.ts +45 -0
- package/dist/bodhi-js-sdk/core/src/onboarding/config.d.ts +10 -0
- package/dist/bodhi-js-sdk/core/src/onboarding/index.d.ts +5 -0
- package/dist/bodhi-js-sdk/core/src/onboarding/modal.d.ts +80 -0
- package/dist/bodhi-js-sdk/core/src/onboarding/protocol-utils.d.ts +33 -0
- package/dist/bodhi-js-sdk/core/src/platform.d.ts +11 -0
- package/dist/bodhi-js-sdk/core/src/storage.d.ts +81 -0
- package/dist/bodhi-js-sdk/core/src/types/api.d.ts +34 -0
- package/dist/bodhi-js-sdk/core/src/types/callback.d.ts +23 -0
- package/dist/bodhi-js-sdk/core/src/types/client-state.d.ts +191 -0
- package/dist/bodhi-js-sdk/core/src/types/config.d.ts +26 -0
- package/dist/bodhi-js-sdk/core/src/types/html.d.ts +9 -0
- package/dist/bodhi-js-sdk/core/src/types/index.d.ts +15 -0
- package/dist/bodhi-js-sdk/core/src/types/platform.d.ts +16 -0
- package/dist/bodhi-js-sdk/core/src/types/user-info.d.ts +59 -0
- package/dist/bodhi-js-sdk/web/src/constants.d.ts +9 -0
- package/dist/bodhi-js-sdk/web/src/direct-client.d.ts +24 -0
- package/dist/bodhi-js-sdk/web/src/ext-client.d.ts +151 -0
- package/dist/bodhi-js-sdk/web/src/facade-client.d.ts +43 -0
- package/dist/bodhi-js-sdk/web/src/index.d.ts +5 -0
- package/dist/bodhi-js-sdk/web/src/interface.d.ts +4 -0
- package/dist/bodhi-web.cjs.js +749 -0
- package/dist/bodhi-web.cjs.js.map +1 -0
- package/dist/bodhi-web.esm.d.ts +1 -0
- package/dist/bodhi-web.esm.js +749 -0
- package/dist/bodhi-web.esm.js.map +1 -0
- package/dist/setup-modal/src/types/extension.d.ts +24 -0
- package/dist/setup-modal/src/types/index.d.ts +20 -0
- package/dist/setup-modal/src/types/lna.d.ts +56 -0
- package/dist/setup-modal/src/types/message-types.d.ts +169 -0
- package/dist/setup-modal/src/types/platform.d.ts +32 -0
- package/dist/setup-modal/src/types/protocol.d.ts +71 -0
- package/dist/setup-modal/src/types/server.d.ts +63 -0
- package/dist/setup-modal/src/types/state.d.ts +43 -0
- package/dist/setup-modal/src/types/type-guards.d.ts +27 -0
- package/package.json +54 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import { DirectClientBase, STORAGE_PREFIXES, generateCodeVerifier, generateCodeChallenge, isApiResultOperationError, isApiResultSuccess, createStorageKeys, EXTENSION_STATE_NOT_INITIALIZED, Logger, createOAuthEndpoints, NOOP_STATE_CALLBACK, EXTENSION_STATE_NOT_FOUND, PENDING_EXTENSION_READY, BACKEND_SERVER_NOT_REACHABLE, extractUserInfo, refreshAccessToken, createOperationError, backendServerNotReady, SERVER_ERROR_CODES, createApiError, BaseFacadeClient } from "@bodhiapp/bodhi-js-core";
|
|
2
|
+
class DirectWebClient extends DirectClientBase {
|
|
3
|
+
constructor(config, onStateChange) {
|
|
4
|
+
super({ ...config, storagePrefix: STORAGE_PREFIXES.DIRECT }, "DirectWebClient", onStateChange);
|
|
5
|
+
this.redirectUri = config.redirectUri;
|
|
6
|
+
}
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Authentication (Browser Redirect OAuth)
|
|
9
|
+
// ============================================================================
|
|
10
|
+
async login() {
|
|
11
|
+
const existingAuth = await this.getAuthState();
|
|
12
|
+
if (existingAuth.isLoggedIn) {
|
|
13
|
+
return existingAuth;
|
|
14
|
+
}
|
|
15
|
+
const resourceScope = await this.requestResourceAccess();
|
|
16
|
+
const fullScope = `openid profile email roles ${this.userScope} ${resourceScope}`;
|
|
17
|
+
const codeVerifier = generateCodeVerifier();
|
|
18
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
19
|
+
const state = generateCodeVerifier();
|
|
20
|
+
localStorage.setItem(this.storageKeys.CODE_VERIFIER, codeVerifier);
|
|
21
|
+
localStorage.setItem(this.storageKeys.STATE, state);
|
|
22
|
+
const authUrl = new URL(this.authEndpoints.authorize);
|
|
23
|
+
authUrl.searchParams.set("client_id", this.authClientId);
|
|
24
|
+
authUrl.searchParams.set("response_type", "code");
|
|
25
|
+
authUrl.searchParams.set("redirect_uri", this.redirectUri);
|
|
26
|
+
authUrl.searchParams.set("scope", fullScope);
|
|
27
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
28
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
29
|
+
authUrl.searchParams.set("state", state);
|
|
30
|
+
window.location.href = authUrl.toString();
|
|
31
|
+
throw new Error("Redirect initiated");
|
|
32
|
+
}
|
|
33
|
+
async handleOAuthCallback(code, state) {
|
|
34
|
+
const storedState = localStorage.getItem(this.storageKeys.STATE);
|
|
35
|
+
if (!storedState || storedState !== state) {
|
|
36
|
+
throw new Error("Invalid state parameter - possible CSRF attack");
|
|
37
|
+
}
|
|
38
|
+
await this.exchangeCodeForTokens(code);
|
|
39
|
+
localStorage.removeItem(this.storageKeys.CODE_VERIFIER);
|
|
40
|
+
localStorage.removeItem(this.storageKeys.STATE);
|
|
41
|
+
const authState = await this.getAuthState();
|
|
42
|
+
if (!authState.isLoggedIn) {
|
|
43
|
+
throw new Error("Login failed");
|
|
44
|
+
}
|
|
45
|
+
const result = authState;
|
|
46
|
+
this.setAuthState(result);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
async logout() {
|
|
50
|
+
const refreshToken = localStorage.getItem(this.storageKeys.REFRESH_TOKEN);
|
|
51
|
+
if (refreshToken) {
|
|
52
|
+
try {
|
|
53
|
+
const params = new URLSearchParams({
|
|
54
|
+
token: refreshToken,
|
|
55
|
+
client_id: this.authClientId,
|
|
56
|
+
token_type_hint: "refresh_token"
|
|
57
|
+
});
|
|
58
|
+
await fetch(this.authEndpoints.revoke, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
62
|
+
},
|
|
63
|
+
body: params
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.logger.warn("Token revocation failed:", error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
localStorage.removeItem(this.storageKeys.ACCESS_TOKEN);
|
|
70
|
+
localStorage.removeItem(this.storageKeys.REFRESH_TOKEN);
|
|
71
|
+
localStorage.removeItem(this.storageKeys.EXPIRES_AT);
|
|
72
|
+
localStorage.removeItem(this.storageKeys.RESOURCE_SCOPE);
|
|
73
|
+
const result = {
|
|
74
|
+
isLoggedIn: false
|
|
75
|
+
};
|
|
76
|
+
this.setAuthState(result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// OAuth Helper Methods
|
|
81
|
+
// ============================================================================
|
|
82
|
+
async requestResourceAccess() {
|
|
83
|
+
const response = await this.sendApiRequest(
|
|
84
|
+
"POST",
|
|
85
|
+
"/bodhi/v1/apps/request-access",
|
|
86
|
+
{ app_client_id: this.authClientId },
|
|
87
|
+
{},
|
|
88
|
+
false
|
|
89
|
+
);
|
|
90
|
+
if (isApiResultOperationError(response)) {
|
|
91
|
+
throw new Error("Failed to get resource access scope from server");
|
|
92
|
+
}
|
|
93
|
+
if (!isApiResultSuccess(response)) {
|
|
94
|
+
throw new Error("Failed to get resource access scope from server: API error");
|
|
95
|
+
}
|
|
96
|
+
const scope = response.body.scope;
|
|
97
|
+
localStorage.setItem(this.storageKeys.RESOURCE_SCOPE, scope);
|
|
98
|
+
return scope;
|
|
99
|
+
}
|
|
100
|
+
async exchangeCodeForTokens(code) {
|
|
101
|
+
const codeVerifier = localStorage.getItem(this.storageKeys.CODE_VERIFIER);
|
|
102
|
+
if (!codeVerifier) {
|
|
103
|
+
throw new Error("Code verifier not found");
|
|
104
|
+
}
|
|
105
|
+
const response = await fetch(this.authEndpoints.token, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
109
|
+
},
|
|
110
|
+
body: new URLSearchParams({
|
|
111
|
+
grant_type: "authorization_code",
|
|
112
|
+
code,
|
|
113
|
+
redirect_uri: this.redirectUri,
|
|
114
|
+
client_id: this.authClientId,
|
|
115
|
+
code_verifier: codeVerifier
|
|
116
|
+
})
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const errorText = await response.text();
|
|
120
|
+
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
|
121
|
+
}
|
|
122
|
+
const tokens = await response.json();
|
|
123
|
+
localStorage.setItem(this.storageKeys.ACCESS_TOKEN, tokens.access_token);
|
|
124
|
+
if (tokens.refresh_token) {
|
|
125
|
+
localStorage.setItem(this.storageKeys.REFRESH_TOKEN, tokens.refresh_token);
|
|
126
|
+
}
|
|
127
|
+
if (tokens.expires_in) {
|
|
128
|
+
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
|
129
|
+
localStorage.setItem(this.storageKeys.EXPIRES_AT, expiresAt.toString());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// Storage Implementation (localStorage)
|
|
134
|
+
// ============================================================================
|
|
135
|
+
async _storageGet(key) {
|
|
136
|
+
return localStorage.getItem(key);
|
|
137
|
+
}
|
|
138
|
+
async _storageSet(items) {
|
|
139
|
+
Object.entries(items).forEach(([key, value]) => {
|
|
140
|
+
localStorage.setItem(key, String(value));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async _storageRemove(keys) {
|
|
144
|
+
keys.forEach((key) => localStorage.removeItem(key));
|
|
145
|
+
}
|
|
146
|
+
_getRedirectUri() {
|
|
147
|
+
return this.redirectUri;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const POLL_INTERVAL = 500;
|
|
151
|
+
const POLL_TIMEOUT = 5e3;
|
|
152
|
+
const STORAGE_KEYS = createStorageKeys(STORAGE_PREFIXES.WEB);
|
|
153
|
+
class WindowBodhiextClient {
|
|
154
|
+
constructor(authClientId, config, onStateChange) {
|
|
155
|
+
this.state = EXTENSION_STATE_NOT_INITIALIZED;
|
|
156
|
+
this.bodhiext = null;
|
|
157
|
+
this.refreshPromise = null;
|
|
158
|
+
this.logger = new Logger("WindowBodhiextClient", config.logLevel);
|
|
159
|
+
this.authClientId = authClientId;
|
|
160
|
+
this.config = config;
|
|
161
|
+
this.authEndpoints = createOAuthEndpoints(this.config.authServerUrl);
|
|
162
|
+
this.onStateChange = onStateChange ?? NOOP_STATE_CALLBACK;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Set client state and notify callback
|
|
166
|
+
*/
|
|
167
|
+
setState(newState) {
|
|
168
|
+
this.state = newState;
|
|
169
|
+
this.logger.info(`{state: ${JSON.stringify(newState)}} - Setting client state`);
|
|
170
|
+
this.onStateChange({ type: "client-state", state: newState });
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Set auth state and notify callback
|
|
174
|
+
*/
|
|
175
|
+
setAuthState(authState) {
|
|
176
|
+
this.onStateChange({ type: "auth-state", state: authState });
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Set or update the state change callback
|
|
180
|
+
*/
|
|
181
|
+
setStateCallback(callback) {
|
|
182
|
+
this.onStateChange = callback;
|
|
183
|
+
}
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// Extension Communication
|
|
186
|
+
// ============================================================================
|
|
187
|
+
/**
|
|
188
|
+
* Ensure bodhiext is available, attempting to acquire it if not already set
|
|
189
|
+
* @throws Error if client not initialized
|
|
190
|
+
*/
|
|
191
|
+
ensureBodhiext() {
|
|
192
|
+
if (!this.bodhiext && window.bodhiext) {
|
|
193
|
+
this.logger.info("Acquiring window.bodhiext reference");
|
|
194
|
+
this.bodhiext = window.bodhiext;
|
|
195
|
+
}
|
|
196
|
+
if (!this.bodhiext) {
|
|
197
|
+
throw new Error("Client not initialized");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Send extension request via window.bodhiext.sendExtRequest
|
|
202
|
+
*/
|
|
203
|
+
async sendExtRequest(action, params) {
|
|
204
|
+
this.ensureBodhiext();
|
|
205
|
+
return this.bodhiext.sendExtRequest(action, params);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Send API message via window.bodhiext.sendApiRequest
|
|
209
|
+
* Converts ApiResponse to ApiResponseResult
|
|
210
|
+
*/
|
|
211
|
+
async sendApiRequest(method, endpoint, body, headers, authenticated) {
|
|
212
|
+
try {
|
|
213
|
+
this.ensureBodhiext();
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return {
|
|
216
|
+
error: {
|
|
217
|
+
message: err instanceof Error ? err.message : String(err),
|
|
218
|
+
type: "extension_error"
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
let requestHeaders = headers || {};
|
|
224
|
+
if (authenticated) {
|
|
225
|
+
const accessToken = await this._getAccessTokenRaw();
|
|
226
|
+
if (!accessToken) {
|
|
227
|
+
return {
|
|
228
|
+
error: {
|
|
229
|
+
message: "Not authenticated. Please log in first.",
|
|
230
|
+
type: "extension_error"
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
requestHeaders = {
|
|
235
|
+
...requestHeaders,
|
|
236
|
+
Authorization: `Bearer ${accessToken}`
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const response = await this.bodhiext.sendApiRequest(
|
|
240
|
+
method,
|
|
241
|
+
endpoint,
|
|
242
|
+
body,
|
|
243
|
+
requestHeaders
|
|
244
|
+
);
|
|
245
|
+
return response;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
const errorObj = e == null ? void 0 : e.error;
|
|
248
|
+
const message = (errorObj == null ? void 0 : errorObj.message) ?? (e instanceof Error ? e.message : String(e));
|
|
249
|
+
const errorType = (errorObj == null ? void 0 : errorObj.type) || "extension_error";
|
|
250
|
+
return {
|
|
251
|
+
error: {
|
|
252
|
+
message,
|
|
253
|
+
type: errorType
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Get current client state
|
|
260
|
+
*/
|
|
261
|
+
getState() {
|
|
262
|
+
return this.state;
|
|
263
|
+
}
|
|
264
|
+
isClientInitialized() {
|
|
265
|
+
return this.state.extension === "ready";
|
|
266
|
+
}
|
|
267
|
+
isServerReady() {
|
|
268
|
+
return this.isClientInitialized() && this.state.server.status === "ready";
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Initialize extension discovery with optional timeout
|
|
272
|
+
* Returns ExtensionState with extension and server status
|
|
273
|
+
*
|
|
274
|
+
* Note: Web mode uses stateless discovery (always polls for window.bodhiext)
|
|
275
|
+
* No extensionId storage/restoration needed - window.bodhiext handle is ephemeral
|
|
276
|
+
*/
|
|
277
|
+
async init(params = {}) {
|
|
278
|
+
var _a, _b, _c, _d;
|
|
279
|
+
if (!params.testConnection && !params.selectedConnection) {
|
|
280
|
+
this.logger.info("No testConnection or selectedConnection, returning not-initialized state");
|
|
281
|
+
return EXTENSION_STATE_NOT_INITIALIZED;
|
|
282
|
+
}
|
|
283
|
+
if (this.bodhiext && !params.testConnection) {
|
|
284
|
+
this.logger.debug("Already have bodhiext handle, skipping polling");
|
|
285
|
+
return this.state;
|
|
286
|
+
}
|
|
287
|
+
if (!this.bodhiext) {
|
|
288
|
+
const timeoutMs = params.timeoutMs ?? ((_b = (_a = this.config.initParams) == null ? void 0 : _a.extension) == null ? void 0 : _b.timeoutMs) ?? POLL_TIMEOUT;
|
|
289
|
+
const intervalMs = params.intervalMs ?? ((_d = (_c = this.config.initParams) == null ? void 0 : _c.extension) == null ? void 0 : _d.intervalMs) ?? POLL_INTERVAL;
|
|
290
|
+
const startTime = Date.now();
|
|
291
|
+
const found = await new Promise((resolve) => {
|
|
292
|
+
const check = () => {
|
|
293
|
+
if (window.bodhiext) {
|
|
294
|
+
this.bodhiext = window.bodhiext;
|
|
295
|
+
resolve(true);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
299
|
+
resolve(false);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
setTimeout(check, intervalMs);
|
|
303
|
+
};
|
|
304
|
+
check();
|
|
305
|
+
});
|
|
306
|
+
if (!found) {
|
|
307
|
+
this.logger.warn(`Extension discovery timed out`);
|
|
308
|
+
this.setState(EXTENSION_STATE_NOT_FOUND);
|
|
309
|
+
return this.state;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const extensionId = await this.bodhiext.getExtensionId();
|
|
313
|
+
this.logger.info(`Extension discovered: ${extensionId}`);
|
|
314
|
+
const state = {
|
|
315
|
+
type: "extension",
|
|
316
|
+
extension: "ready",
|
|
317
|
+
extensionId,
|
|
318
|
+
server: PENDING_EXTENSION_READY
|
|
319
|
+
};
|
|
320
|
+
if (params.testConnection) {
|
|
321
|
+
try {
|
|
322
|
+
const serverState = await this.getServerState();
|
|
323
|
+
this.setState({ ...state, server: serverState });
|
|
324
|
+
this.logger.info(`Server connectivity tested: ${serverState.status}`);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
this.logger.error(`Failed to get server state:`, error);
|
|
327
|
+
this.setState({ ...state, server: BACKEND_SERVER_NOT_REACHABLE });
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
this.setState(state);
|
|
331
|
+
}
|
|
332
|
+
return this.state;
|
|
333
|
+
}
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// OAuth Methods
|
|
336
|
+
// ============================================================================
|
|
337
|
+
/**
|
|
338
|
+
* Request resource access scope from backend
|
|
339
|
+
* Required for authenticated API access
|
|
340
|
+
*/
|
|
341
|
+
async requestResourceAccess() {
|
|
342
|
+
this.ensureBodhiext();
|
|
343
|
+
const response = await this.bodhiext.sendApiRequest("POST", "/bodhi/v1/apps/request-access", {
|
|
344
|
+
app_client_id: this.authClientId
|
|
345
|
+
});
|
|
346
|
+
if (!isApiResultSuccess(response)) {
|
|
347
|
+
throw new Error("Failed to get resource access scope: API error");
|
|
348
|
+
}
|
|
349
|
+
const scope = response.body.scope;
|
|
350
|
+
localStorage.setItem(STORAGE_KEYS.RESOURCE_SCOPE, scope);
|
|
351
|
+
return scope;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Login via browser redirect OAuth2 + PKCE flow
|
|
355
|
+
* @returns AuthLoggedIn (though in practice, this redirects and never returns)
|
|
356
|
+
*/
|
|
357
|
+
async login() {
|
|
358
|
+
const existingAuth = await this.getAuthState();
|
|
359
|
+
if (existingAuth.isLoggedIn) {
|
|
360
|
+
return existingAuth;
|
|
361
|
+
}
|
|
362
|
+
this.ensureBodhiext();
|
|
363
|
+
const resourceScope = await this.requestResourceAccess();
|
|
364
|
+
const codeVerifier = generateCodeVerifier();
|
|
365
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
366
|
+
const state = generateCodeVerifier();
|
|
367
|
+
localStorage.setItem(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);
|
|
368
|
+
localStorage.setItem(STORAGE_KEYS.STATE, state);
|
|
369
|
+
const scopes = ["openid", "profile", "email", "roles", this.config.userScope, resourceScope];
|
|
370
|
+
const params = new URLSearchParams({
|
|
371
|
+
response_type: "code",
|
|
372
|
+
client_id: this.authClientId,
|
|
373
|
+
redirect_uri: this.config.redirectUri,
|
|
374
|
+
scope: scopes.join(" "),
|
|
375
|
+
state,
|
|
376
|
+
code_challenge: codeChallenge,
|
|
377
|
+
code_challenge_method: "S256"
|
|
378
|
+
});
|
|
379
|
+
const authUrl = `${this.authEndpoints.authorize}?${params}`;
|
|
380
|
+
window.location.href = authUrl;
|
|
381
|
+
return new Promise(() => {
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Handle OAuth callback with authorization code
|
|
386
|
+
* Should be called from callback page with extracted URL params
|
|
387
|
+
* @returns AuthLoggedIn with login state and user info
|
|
388
|
+
*/
|
|
389
|
+
async handleOAuthCallback(code, state) {
|
|
390
|
+
const storedState = localStorage.getItem(STORAGE_KEYS.STATE);
|
|
391
|
+
if (!storedState || storedState !== state) {
|
|
392
|
+
throw new Error("Invalid state parameter - possible CSRF attack");
|
|
393
|
+
}
|
|
394
|
+
await this.exchangeCodeForTokens(code);
|
|
395
|
+
localStorage.removeItem(STORAGE_KEYS.CODE_VERIFIER);
|
|
396
|
+
localStorage.removeItem(STORAGE_KEYS.STATE);
|
|
397
|
+
const authState = await this.getAuthState();
|
|
398
|
+
if (!authState.isLoggedIn) {
|
|
399
|
+
throw new Error("Login failed");
|
|
400
|
+
}
|
|
401
|
+
this.setAuthState(authState);
|
|
402
|
+
return authState;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Exchange authorization code for tokens
|
|
406
|
+
*/
|
|
407
|
+
async exchangeCodeForTokens(code) {
|
|
408
|
+
const codeVerifier = localStorage.getItem(STORAGE_KEYS.CODE_VERIFIER);
|
|
409
|
+
if (!codeVerifier) {
|
|
410
|
+
throw new Error("Code verifier not found");
|
|
411
|
+
}
|
|
412
|
+
const params = new URLSearchParams({
|
|
413
|
+
grant_type: "authorization_code",
|
|
414
|
+
client_id: this.authClientId,
|
|
415
|
+
code,
|
|
416
|
+
redirect_uri: this.config.redirectUri,
|
|
417
|
+
code_verifier: codeVerifier
|
|
418
|
+
});
|
|
419
|
+
const response = await fetch(this.authEndpoints.token, {
|
|
420
|
+
method: "POST",
|
|
421
|
+
headers: {
|
|
422
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
423
|
+
},
|
|
424
|
+
body: params
|
|
425
|
+
});
|
|
426
|
+
if (!response.ok) {
|
|
427
|
+
const errorText = await response.text();
|
|
428
|
+
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
|
429
|
+
}
|
|
430
|
+
const tokenData = await response.json();
|
|
431
|
+
if (!tokenData.access_token) {
|
|
432
|
+
throw new Error("No access token received");
|
|
433
|
+
}
|
|
434
|
+
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, tokenData.access_token);
|
|
435
|
+
if (tokenData.refresh_token) {
|
|
436
|
+
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, tokenData.refresh_token);
|
|
437
|
+
}
|
|
438
|
+
if (tokenData.expires_in) {
|
|
439
|
+
const expiresAt = Date.now() + tokenData.expires_in * 1e3;
|
|
440
|
+
localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Logout user and revoke tokens
|
|
445
|
+
* @returns AuthLoggedOut with logged out state
|
|
446
|
+
*/
|
|
447
|
+
async logout() {
|
|
448
|
+
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
|
449
|
+
if (refreshToken) {
|
|
450
|
+
try {
|
|
451
|
+
const params = new URLSearchParams({
|
|
452
|
+
token: refreshToken,
|
|
453
|
+
client_id: this.authClientId,
|
|
454
|
+
token_type_hint: "refresh_token"
|
|
455
|
+
});
|
|
456
|
+
await fetch(this.authEndpoints.revoke, {
|
|
457
|
+
method: "POST",
|
|
458
|
+
headers: {
|
|
459
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
460
|
+
},
|
|
461
|
+
body: params
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.logger.warn("Token revocation failed:", error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
468
|
+
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
|
469
|
+
localStorage.removeItem(STORAGE_KEYS.EXPIRES_AT);
|
|
470
|
+
localStorage.removeItem(STORAGE_KEYS.CODE_VERIFIER);
|
|
471
|
+
localStorage.removeItem(STORAGE_KEYS.STATE);
|
|
472
|
+
localStorage.removeItem(STORAGE_KEYS.RESOURCE_SCOPE);
|
|
473
|
+
const result = {
|
|
474
|
+
isLoggedIn: false
|
|
475
|
+
};
|
|
476
|
+
this.setAuthState(result);
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Get current authentication state
|
|
481
|
+
*/
|
|
482
|
+
async getAuthState() {
|
|
483
|
+
const accessToken = await this._getAccessTokenRaw();
|
|
484
|
+
if (!accessToken) {
|
|
485
|
+
return { isLoggedIn: false };
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const userInfo = extractUserInfo(accessToken);
|
|
489
|
+
return { isLoggedIn: true, userInfo, accessToken };
|
|
490
|
+
} catch (error) {
|
|
491
|
+
this.logger.error("Failed to parse token:", error);
|
|
492
|
+
return { isLoggedIn: false };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Get current access token
|
|
497
|
+
* Returns null if not logged in or token expired
|
|
498
|
+
*/
|
|
499
|
+
async _getAccessTokenRaw() {
|
|
500
|
+
const accessToken = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
501
|
+
const expiresAt = localStorage.getItem(STORAGE_KEYS.EXPIRES_AT);
|
|
502
|
+
if (!accessToken) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
if (expiresAt) {
|
|
506
|
+
const expirationTime = parseInt(expiresAt, 10);
|
|
507
|
+
if (Date.now() >= expirationTime - 5 * 1e3) {
|
|
508
|
+
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
|
509
|
+
if (refreshToken) {
|
|
510
|
+
return this._tryRefreshToken(refreshToken);
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return accessToken;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Try to refresh access token using refresh token
|
|
519
|
+
* Race condition prevention: Returns existing promise if refresh already in progress
|
|
520
|
+
*/
|
|
521
|
+
async _tryRefreshToken(refreshToken) {
|
|
522
|
+
if (this.refreshPromise) {
|
|
523
|
+
this.logger.debug("Refresh already in progress, returning existing promise");
|
|
524
|
+
return this.refreshPromise;
|
|
525
|
+
}
|
|
526
|
+
this.refreshPromise = this._doRefreshToken(refreshToken);
|
|
527
|
+
try {
|
|
528
|
+
return await this.refreshPromise;
|
|
529
|
+
} finally {
|
|
530
|
+
this.refreshPromise = null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Perform the actual token refresh
|
|
535
|
+
*/
|
|
536
|
+
async _doRefreshToken(refreshToken) {
|
|
537
|
+
this.logger.debug("Refreshing access token");
|
|
538
|
+
try {
|
|
539
|
+
const tokens = await refreshAccessToken(
|
|
540
|
+
this.authEndpoints.token,
|
|
541
|
+
refreshToken,
|
|
542
|
+
this.authClientId
|
|
543
|
+
);
|
|
544
|
+
if (tokens) {
|
|
545
|
+
this._storeRefreshedTokens(tokens);
|
|
546
|
+
const userInfo = extractUserInfo(tokens.access_token);
|
|
547
|
+
this.setAuthState({
|
|
548
|
+
isLoggedIn: true,
|
|
549
|
+
userInfo,
|
|
550
|
+
accessToken: tokens.access_token
|
|
551
|
+
});
|
|
552
|
+
this.logger.info("Token refreshed successfully");
|
|
553
|
+
return tokens.access_token;
|
|
554
|
+
}
|
|
555
|
+
} catch (error) {
|
|
556
|
+
this.logger.warn("Token refresh failed:", error);
|
|
557
|
+
}
|
|
558
|
+
this.logger.warn("Token refresh failed, keeping tokens for manual retry");
|
|
559
|
+
throw createOperationError(
|
|
560
|
+
"Access token expired and unable to refresh. Try logging out and logging in again.",
|
|
561
|
+
"token_refresh_failed"
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Store refreshed tokens
|
|
566
|
+
*/
|
|
567
|
+
_storeRefreshedTokens(tokens) {
|
|
568
|
+
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
|
569
|
+
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);
|
|
570
|
+
localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, String(expiresAt));
|
|
571
|
+
if (tokens.refresh_token) {
|
|
572
|
+
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Ping API
|
|
577
|
+
*/
|
|
578
|
+
async pingApi() {
|
|
579
|
+
return this.sendApiRequest("GET", "/ping");
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Fetch models
|
|
583
|
+
*/
|
|
584
|
+
async fetchModels() {
|
|
585
|
+
return this.sendApiRequest(
|
|
586
|
+
"GET",
|
|
587
|
+
"/v1/models",
|
|
588
|
+
void 0,
|
|
589
|
+
void 0,
|
|
590
|
+
true
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Get backend server state
|
|
595
|
+
* Calls /bodhi/v1/info and returns structured server state
|
|
596
|
+
*/
|
|
597
|
+
async getServerState() {
|
|
598
|
+
const result = await this.sendApiRequest("GET", "/bodhi/v1/info");
|
|
599
|
+
if (isApiResultOperationError(result)) {
|
|
600
|
+
return BACKEND_SERVER_NOT_REACHABLE;
|
|
601
|
+
}
|
|
602
|
+
if (!isApiResultSuccess(result)) {
|
|
603
|
+
return BACKEND_SERVER_NOT_REACHABLE;
|
|
604
|
+
}
|
|
605
|
+
const body = result.body;
|
|
606
|
+
switch (body.status) {
|
|
607
|
+
case "ready":
|
|
608
|
+
return { status: "ready", version: body.version || "unknown" };
|
|
609
|
+
case "setup":
|
|
610
|
+
return backendServerNotReady("setup", body.version || "unknown");
|
|
611
|
+
case "resource-admin":
|
|
612
|
+
return backendServerNotReady("resource-admin", body.version || "unknown");
|
|
613
|
+
case "error":
|
|
614
|
+
return backendServerNotReady(
|
|
615
|
+
"error",
|
|
616
|
+
body.version || "unknown",
|
|
617
|
+
body.error ? { message: body.error.message, type: body.error.type } : SERVER_ERROR_CODES.SERVER_NOT_READY
|
|
618
|
+
);
|
|
619
|
+
default:
|
|
620
|
+
return BACKEND_SERVER_NOT_REACHABLE;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Generic streaming via window.bodhiext.sendStreamRequest
|
|
625
|
+
* Wraps ReadableStream as AsyncGenerator
|
|
626
|
+
*/
|
|
627
|
+
async *stream(method, endpoint, body, headers, authenticated = true) {
|
|
628
|
+
this.ensureBodhiext();
|
|
629
|
+
let requestHeaders = headers || {};
|
|
630
|
+
if (authenticated) {
|
|
631
|
+
const accessToken = await this._getAccessTokenRaw();
|
|
632
|
+
if (!accessToken) {
|
|
633
|
+
throw new Error("Not authenticated. Please log in first.");
|
|
634
|
+
}
|
|
635
|
+
requestHeaders = {
|
|
636
|
+
...requestHeaders,
|
|
637
|
+
Authorization: `Bearer ${accessToken}`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const stream = this.bodhiext.sendStreamRequest(method, endpoint, body, requestHeaders);
|
|
641
|
+
const reader = stream.getReader();
|
|
642
|
+
try {
|
|
643
|
+
while (true) {
|
|
644
|
+
const { value, done } = await reader.read();
|
|
645
|
+
if (done || (value == null ? void 0 : value.done)) {
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
yield value.body;
|
|
649
|
+
}
|
|
650
|
+
} catch (err) {
|
|
651
|
+
if (err instanceof Error) {
|
|
652
|
+
if ("response" in err) {
|
|
653
|
+
const apiErr = err;
|
|
654
|
+
throw createApiError(err.message, apiErr.response.status, apiErr.response.body);
|
|
655
|
+
}
|
|
656
|
+
if ("error" in err) {
|
|
657
|
+
throw createOperationError(err.message, "extension_error");
|
|
658
|
+
}
|
|
659
|
+
throw createOperationError(err.message, "extension_error");
|
|
660
|
+
}
|
|
661
|
+
throw err;
|
|
662
|
+
} finally {
|
|
663
|
+
reader.releaseLock();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Chat streaming
|
|
668
|
+
*/
|
|
669
|
+
async *streamChat(model, prompt, authenticated = true) {
|
|
670
|
+
yield* this.stream(
|
|
671
|
+
"POST",
|
|
672
|
+
"/v1/chat/completions",
|
|
673
|
+
{
|
|
674
|
+
model,
|
|
675
|
+
messages: [{ role: "user", content: prompt }],
|
|
676
|
+
stream: true
|
|
677
|
+
},
|
|
678
|
+
void 0,
|
|
679
|
+
authenticated
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Serialize web extension client state (all transient, nothing to persist)
|
|
684
|
+
*/
|
|
685
|
+
serialize() {
|
|
686
|
+
return {
|
|
687
|
+
extensionId: this.state.type === "extension" && this.state.extension === "ready" ? this.state.extensionId : void 0
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Debug dump of WindowBodhiextClient internal state
|
|
692
|
+
*/
|
|
693
|
+
async debug() {
|
|
694
|
+
return {
|
|
695
|
+
type: "WindowBodhiextClient",
|
|
696
|
+
state: this.state,
|
|
697
|
+
authState: await this.getAuthState(),
|
|
698
|
+
bodhiextAvailable: this.bodhiext !== null,
|
|
699
|
+
authClientId: this.authClientId,
|
|
700
|
+
authServerUrl: this.config.authServerUrl,
|
|
701
|
+
redirectUri: this.config.redirectUri,
|
|
702
|
+
userScope: this.config.userScope
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
class WebUIClient extends BaseFacadeClient {
|
|
707
|
+
constructor(authClientId, config, onStateChange, storagePrefix) {
|
|
708
|
+
const normalizedConfig = {
|
|
709
|
+
redirectUri: config.redirectUri,
|
|
710
|
+
authServerUrl: config.authServerUrl || "https://id.getbodhi.app/realms/bodhi",
|
|
711
|
+
userScope: config.userScope || "scope_user_user",
|
|
712
|
+
logLevel: config.logLevel || "warn",
|
|
713
|
+
initParams: config.initParams
|
|
714
|
+
};
|
|
715
|
+
super(authClientId, normalizedConfig, onStateChange, storagePrefix);
|
|
716
|
+
}
|
|
717
|
+
createLogger(config) {
|
|
718
|
+
return new Logger("WebUIClient", config.logLevel);
|
|
719
|
+
}
|
|
720
|
+
createExtClient(config, onStateChange) {
|
|
721
|
+
return new WindowBodhiextClient(this.authClientId, config, onStateChange);
|
|
722
|
+
}
|
|
723
|
+
createDirectClient(authClientId, config, onStateChange) {
|
|
724
|
+
return new DirectWebClient(
|
|
725
|
+
{
|
|
726
|
+
authClientId,
|
|
727
|
+
authServerUrl: config.authServerUrl,
|
|
728
|
+
redirectUri: config.redirectUri,
|
|
729
|
+
userScope: config.userScope,
|
|
730
|
+
logLevel: config.logLevel,
|
|
731
|
+
storagePrefix: STORAGE_PREFIXES.WEB
|
|
732
|
+
},
|
|
733
|
+
onStateChange
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
// ============================================================================
|
|
737
|
+
// Web-specific OAuth Callback
|
|
738
|
+
// ============================================================================
|
|
739
|
+
async handleOAuthCallback(code, state) {
|
|
740
|
+
if (this.connectionMode === "direct") {
|
|
741
|
+
return this.directClient.handleOAuthCallback(code, state);
|
|
742
|
+
}
|
|
743
|
+
return this.extClient.handleOAuthCallback(code, state);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
export {
|
|
747
|
+
WebUIClient
|
|
748
|
+
};
|
|
749
|
+
//# sourceMappingURL=bodhi-web.esm.js.map
|