@dotbots-boutique/auth-sdk 0.1.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.
@@ -0,0 +1,391 @@
1
+ 'use strict';
2
+
3
+ class DotBotsAuthError extends Error {
4
+ constructor(code, message, originalError) {
5
+ super(message);
6
+ this.code = code;
7
+ this.originalError = originalError;
8
+ this.name = 'DotBotsAuthError';
9
+ }
10
+ }
11
+
12
+ class TokenManager {
13
+ constructor(config, environment, onRefreshed, onSessionExpired) {
14
+ this.accessToken = null;
15
+ this.refreshToken = null;
16
+ this.expiresAt = 0;
17
+ this.refreshTimer = null;
18
+ this.config = config;
19
+ this.environment = environment;
20
+ this.onRefreshed = onRefreshed;
21
+ this.onSessionExpired = onSessionExpired;
22
+ }
23
+ setTokens(tokens) {
24
+ this.accessToken = tokens.accessToken;
25
+ this.refreshToken = tokens.refreshToken;
26
+ this.expiresAt = Date.now() + tokens.expiresIn * 1000;
27
+ this.scheduleRefresh(tokens.expiresIn * 1000);
28
+ }
29
+ getAccessToken() {
30
+ return this.accessToken;
31
+ }
32
+ isAuthenticated() {
33
+ return this.accessToken !== null && Date.now() < this.expiresAt;
34
+ }
35
+ async exchangeCode(code) {
36
+ const response = await this.apiRequest('/api/auth/token', {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ code, appId: this.config.appId }),
40
+ });
41
+ if (!response.ok) {
42
+ if (response.status === 401 || response.status === 400) {
43
+ throw new DotBotsAuthError('CODE_EXPIRED', 'Auth code is expired or invalid');
44
+ }
45
+ throw new DotBotsAuthError('NETWORK_ERROR', `Token exchange failed: ${response.status}`);
46
+ }
47
+ const tokens = await response.json();
48
+ this.setTokens(tokens);
49
+ }
50
+ async refresh() {
51
+ if (!this.refreshToken) {
52
+ throw new DotBotsAuthError('REFRESH_FAILED', 'No refresh token available');
53
+ }
54
+ let response;
55
+ try {
56
+ response = await this.apiRequest('/api/auth/refresh', {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({
60
+ refreshToken: this.refreshToken,
61
+ appId: this.config.appId,
62
+ }),
63
+ });
64
+ }
65
+ catch (err) {
66
+ throw new DotBotsAuthError('REFRESH_FAILED', 'Token refresh request failed', err instanceof Error ? err : undefined);
67
+ }
68
+ if (!response.ok) {
69
+ throw new DotBotsAuthError('REFRESH_FAILED', `Token refresh failed: ${response.status}`);
70
+ }
71
+ const tokens = await response.json();
72
+ this.setTokens(tokens);
73
+ }
74
+ async revoke() {
75
+ if (this.accessToken && this.refreshToken) {
76
+ try {
77
+ await this.apiRequest('/api/auth/revoke', {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ Authorization: `Bearer ${this.accessToken}`,
82
+ },
83
+ body: JSON.stringify({ refreshToken: this.refreshToken }),
84
+ });
85
+ }
86
+ catch {
87
+ // Best effort — continue with local cleanup regardless
88
+ }
89
+ }
90
+ this.clear();
91
+ }
92
+ clear() {
93
+ this.accessToken = null;
94
+ this.refreshToken = null;
95
+ this.expiresAt = 0;
96
+ if (this.refreshTimer !== null) {
97
+ clearTimeout(this.refreshTimer);
98
+ this.refreshTimer = null;
99
+ }
100
+ }
101
+ scheduleRefresh(expiresInMs) {
102
+ if (this.refreshTimer !== null) {
103
+ clearTimeout(this.refreshTimer);
104
+ }
105
+ const buffer = this.config.tokenRefreshBuffer ?? 60000;
106
+ const delay = Math.max(expiresInMs - buffer, 0);
107
+ this.refreshTimer = setTimeout(async () => {
108
+ try {
109
+ await this.refresh();
110
+ this.onRefreshed();
111
+ }
112
+ catch {
113
+ this.onSessionExpired();
114
+ this.config.onTokenRefreshFailed?.();
115
+ }
116
+ }, delay);
117
+ }
118
+ apiRequest(path, init) {
119
+ const headers = new Headers(init.headers);
120
+ headers.set('X-App-Id', this.config.appId);
121
+ headers.set('X-Environment', this.environment);
122
+ return fetch(`${this.config.apiUrl}${path}`, { ...init, headers });
123
+ }
124
+ }
125
+
126
+ class PostMessageHandler {
127
+ constructor(marketplaceOrigin) {
128
+ this.marketplaceOrigin = marketplaceOrigin;
129
+ }
130
+ /**
131
+ * Detect if the current window is running inside an iframe.
132
+ * Also performs a security check: if window.frameElement is accessible
133
+ * in a cross-origin context, something is wrong.
134
+ */
135
+ isInIframe() {
136
+ try {
137
+ if (window.self === window.top)
138
+ return false;
139
+ }
140
+ catch {
141
+ // Cross-origin restriction — we are in an iframe
142
+ return true;
143
+ }
144
+ // Security check: frameElement should NOT be accessible cross-origin
145
+ try {
146
+ if (window.frameElement) {
147
+ throw new DotBotsAuthError('UNAUTHORIZED', 'Security violation: window.frameElement is accessible in cross-origin iframe');
148
+ }
149
+ }
150
+ catch (err) {
151
+ if (err instanceof DotBotsAuthError)
152
+ throw err;
153
+ // SecurityError is expected — this is the normal case
154
+ }
155
+ return true;
156
+ }
157
+ /**
158
+ * Send a ready signal to the parent window and wait for the auth code.
159
+ */
160
+ requestAuthCode(appId, timeout) {
161
+ return new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => {
163
+ window.removeEventListener('message', handler);
164
+ reject(new DotBotsAuthError('IFRAME_TIMEOUT', `Parent did not respond within ${timeout}ms`));
165
+ }, timeout);
166
+ const handler = (event) => {
167
+ if (event.origin !== this.marketplaceOrigin)
168
+ return;
169
+ if (event.data?.type !== 'DOTBOTS_AUTH_CODE')
170
+ return;
171
+ clearTimeout(timer);
172
+ window.removeEventListener('message', handler);
173
+ resolve(event.data.code);
174
+ };
175
+ window.addEventListener('message', handler);
176
+ window.parent.postMessage({ type: 'DOTBOTS_READY', appId }, this.marketplaceOrigin);
177
+ });
178
+ }
179
+ /**
180
+ * Notify the parent that the user has logged out.
181
+ */
182
+ sendLogout() {
183
+ if (this.isInIframe()) {
184
+ window.parent.postMessage({ type: 'DOTBOTS_LOGOUT' }, this.marketplaceOrigin);
185
+ }
186
+ }
187
+ }
188
+
189
+ class ProxyConfigManager {
190
+ constructor(apiUrl, appId, environment) {
191
+ this.config = null;
192
+ this.apiUrl = apiUrl;
193
+ this.appId = appId;
194
+ this.environment = environment;
195
+ }
196
+ async fetchConfig() {
197
+ try {
198
+ const response = await fetch(`${this.apiUrl}/api/proxy/config`, {
199
+ headers: {
200
+ 'X-App-Id': this.appId,
201
+ 'X-Environment': this.environment,
202
+ },
203
+ });
204
+ if (!response.ok) {
205
+ throw new Error(`Proxy config request failed: ${response.status}`);
206
+ }
207
+ this.config = await response.json();
208
+ }
209
+ catch (err) {
210
+ // Non-fatal: log warning, SDK falls back to direct apiUrl
211
+ console.warn('[DotBotsAuth] Could not fetch proxy config, falling back to direct API:', err instanceof Error ? err.message : err);
212
+ this.config = null;
213
+ }
214
+ }
215
+ getProxyUrl() {
216
+ return this.config?.proxyUrl ?? null;
217
+ }
218
+ getConfig() {
219
+ return this.config;
220
+ }
221
+ /**
222
+ * Returns the base URL to use for API calls.
223
+ * Uses the proxy if available, otherwise falls back to apiUrl.
224
+ */
225
+ getBaseUrl() {
226
+ return this.config?.proxyUrl ?? this.apiUrl;
227
+ }
228
+ }
229
+
230
+ class DotBotsAuth {
231
+ constructor(config) {
232
+ this.listeners = new Map();
233
+ this.cachedUser = null;
234
+ this.initialized = false;
235
+ this.config = config;
236
+ this.environment = this.detectEnvironment();
237
+ const marketplaceOrigin = config.marketplaceOrigin ?? 'https://dotbots.boutique';
238
+ this.tokenManager = new TokenManager(config, this.environment, () => this.emit('tokenRefreshed'), () => this.emit('sessionExpired'));
239
+ this.postMessageHandler = new PostMessageHandler(marketplaceOrigin);
240
+ this.proxyConfigManager = new ProxyConfigManager(config.apiUrl, config.appId, this.environment);
241
+ }
242
+ async initialize() {
243
+ // Step 1 — Fetch proxy config
244
+ await this.proxyConfigManager.fetchConfig();
245
+ // Step 2 — Authenticate
246
+ if (this.postMessageHandler.isInIframe()) {
247
+ await this.initializeIframe();
248
+ }
249
+ else {
250
+ await this.initializeStandalone();
251
+ }
252
+ this.initialized = true;
253
+ }
254
+ async getUser() {
255
+ this.assertInitialized();
256
+ if (this.cachedUser)
257
+ return this.cachedUser;
258
+ const response = await this.buildRequest(`${this.config.apiUrl}/api/auth/me`);
259
+ if (!response.ok) {
260
+ if (response.status === 401) {
261
+ throw new DotBotsAuthError('UNAUTHORIZED', 'Not authorized to access this app');
262
+ }
263
+ throw new DotBotsAuthError('NETWORK_ERROR', `Failed to fetch user: ${response.status}`);
264
+ }
265
+ this.cachedUser = await response.json();
266
+ this.emit('userLoaded');
267
+ return this.cachedUser;
268
+ }
269
+ can(permission) {
270
+ if (!this.cachedUser) {
271
+ throw new DotBotsAuthError('NOT_INITIALIZED', 'User data not loaded. Call getUser() first.');
272
+ }
273
+ return this.cachedUser.permissions.includes(permission);
274
+ }
275
+ canAll(permissions) {
276
+ return permissions.every((p) => this.can(p));
277
+ }
278
+ canAny(permissions) {
279
+ return permissions.some((p) => this.can(p));
280
+ }
281
+ hasRole(role) {
282
+ if (!this.cachedUser) {
283
+ throw new DotBotsAuthError('NOT_INITIALIZED', 'User data not loaded. Call getUser() first.');
284
+ }
285
+ return this.cachedUser.roles.includes(role);
286
+ }
287
+ async fetch(url, options) {
288
+ this.assertInitialized();
289
+ const baseUrl = this.proxyConfigManager.getBaseUrl();
290
+ const fullUrl = `${baseUrl}${url.startsWith('/') ? url : `/${url}`}`;
291
+ let response = await this.buildRequest(fullUrl, options);
292
+ // On 401, try one refresh then retry
293
+ if (response.status === 401) {
294
+ try {
295
+ await this.tokenManager.refresh();
296
+ this.emit('tokenRefreshed');
297
+ response = await this.buildRequest(fullUrl, options);
298
+ }
299
+ catch {
300
+ throw new DotBotsAuthError('UNAUTHORIZED', 'Request unauthorized and token refresh failed');
301
+ }
302
+ }
303
+ return response;
304
+ }
305
+ async logout() {
306
+ this.assertInitialized();
307
+ await this.tokenManager.revoke();
308
+ this.cachedUser = null;
309
+ this.emit('loggedOut');
310
+ if (this.postMessageHandler.isInIframe()) {
311
+ this.postMessageHandler.sendLogout();
312
+ }
313
+ else {
314
+ const redirectUri = encodeURIComponent(window.location.origin);
315
+ window.location.href = `${this.config.apiUrl}/api/auth/logout?redirectUri=${redirectUri}`;
316
+ }
317
+ }
318
+ on(event, handler) {
319
+ if (!this.listeners.has(event)) {
320
+ this.listeners.set(event, new Set());
321
+ }
322
+ this.listeners.get(event).add(handler);
323
+ }
324
+ off(event, handler) {
325
+ this.listeners.get(event)?.delete(handler);
326
+ }
327
+ /**
328
+ * Returns the proxy config if available. Useful for advanced use cases.
329
+ */
330
+ getProxyConfig() {
331
+ return this.proxyConfigManager.getConfig();
332
+ }
333
+ // --- Private ---
334
+ detectEnvironment() {
335
+ const hostname = window.location.hostname;
336
+ if (hostname.includes('test-apps'))
337
+ return 'test';
338
+ return 'prod';
339
+ }
340
+ async initializeIframe() {
341
+ const timeout = this.config.iframeTimeout ?? 5000;
342
+ const code = await this.postMessageHandler.requestAuthCode(this.config.appId, timeout);
343
+ await this.tokenManager.exchangeCode(code);
344
+ }
345
+ async initializeStandalone() {
346
+ const url = new URL(window.location.href);
347
+ const code = url.searchParams.get('code');
348
+ if (code) {
349
+ // Remove code from URL immediately
350
+ url.searchParams.delete('code');
351
+ window.history.replaceState({}, '', url.toString());
352
+ await this.tokenManager.exchangeCode(code);
353
+ }
354
+ else if (!this.tokenManager.isAuthenticated()) {
355
+ // Redirect to auth
356
+ const redirectUri = encodeURIComponent(window.location.href);
357
+ window.location.href = `${this.config.apiUrl}/api/auth/authorize?appId=${this.config.appId}&redirectUri=${redirectUri}`;
358
+ }
359
+ }
360
+ async buildRequest(url, options) {
361
+ const headers = new Headers(options?.headers);
362
+ const accessToken = this.tokenManager.getAccessToken();
363
+ if (accessToken) {
364
+ headers.set('Authorization', `Bearer ${accessToken}`);
365
+ }
366
+ headers.set('X-App-Id', this.config.appId);
367
+ headers.set('X-Environment', this.environment);
368
+ return globalThis.fetch(url, { ...options, headers });
369
+ }
370
+ assertInitialized() {
371
+ if (!this.initialized) {
372
+ throw new DotBotsAuthError('NOT_INITIALIZED', 'Call initialize() before using the SDK');
373
+ }
374
+ }
375
+ emit(event) {
376
+ const handlers = this.listeners.get(event);
377
+ if (handlers) {
378
+ for (const handler of handlers) {
379
+ try {
380
+ handler();
381
+ }
382
+ catch {
383
+ // Don't let listener errors break the SDK
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ exports.DotBotsAuth = DotBotsAuth;
391
+ exports.DotBotsAuthError = DotBotsAuthError;
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+ var react = require('react');
5
+
6
+ const DotBotsAuthContext = react.createContext(null);
7
+ function DotBotsAuthProvider({ auth, children, loadingComponent, errorComponent, }) {
8
+ const [user, setUser] = react.useState(null);
9
+ const [isLoading, setIsLoading] = react.useState(true);
10
+ const [error, setError] = react.useState(null);
11
+ react.useEffect(() => {
12
+ let cancelled = false;
13
+ async function init() {
14
+ try {
15
+ await auth.initialize();
16
+ const userData = await auth.getUser();
17
+ if (!cancelled) {
18
+ setUser(userData);
19
+ setIsLoading(false);
20
+ }
21
+ }
22
+ catch (err) {
23
+ if (!cancelled) {
24
+ setError(err);
25
+ setIsLoading(false);
26
+ }
27
+ }
28
+ }
29
+ init();
30
+ return () => {
31
+ cancelled = true;
32
+ };
33
+ }, [auth]);
34
+ if (isLoading) {
35
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent ?? jsxRuntime.jsx("div", { children: "Loading..." }) });
36
+ }
37
+ if (error) {
38
+ if (errorComponent) {
39
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: errorComponent(error) });
40
+ }
41
+ return jsxRuntime.jsxs("div", { children: ["Authentication error: ", error.message] });
42
+ }
43
+ const value = {
44
+ user,
45
+ isLoading,
46
+ error,
47
+ can: (permission) => auth.can(permission),
48
+ canAll: (permissions) => auth.canAll(permissions),
49
+ canAny: (permissions) => auth.canAny(permissions),
50
+ hasRole: (role) => auth.hasRole(role),
51
+ fetch: (url, options) => auth.fetch(url, options),
52
+ logout: () => auth.logout(),
53
+ };
54
+ return (jsxRuntime.jsx(DotBotsAuthContext.Provider, { value: value, children: children }));
55
+ }
56
+
57
+ function useDotBotsAuth() {
58
+ const context = react.useContext(DotBotsAuthContext);
59
+ if (!context) {
60
+ throw new Error('useDotBotsAuth must be used within a DotBotsAuthProvider');
61
+ }
62
+ return context;
63
+ }
64
+
65
+ exports.DotBotsAuthProvider = DotBotsAuthProvider;
66
+ exports.useDotBotsAuth = useDotBotsAuth;