@oxyhq/core 1.2.2 → 1.2.4

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.
@@ -26,122 +26,215 @@ export function OxyServicesUtilityMixin(Base) {
26
26
  }
27
27
  }
28
28
  /**
29
- * Simple Express.js authentication middleware
29
+ * Express.js authentication middleware
30
30
  *
31
- * Built-in authentication middleware that validates JWT tokens and adds user data to requests.
31
+ * Validates JWT tokens against the Oxy API and attaches user data to requests.
32
+ * Uses server-side session validation for security (not just JWT decode).
32
33
  *
33
34
  * @example
34
35
  * ```typescript
35
- * // Basic usage - just add it to your routes
36
- * app.use('/api/protected', oxyServices.auth());
36
+ * import { OxyServices } from '@oxyhq/core';
37
37
  *
38
- * // With debug logging
39
- * app.use('/api/protected', oxyServices.auth({ debug: true }));
38
+ * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
40
39
  *
41
- * // With custom error handling
42
- * app.use('/api/protected', oxyServices.auth({
43
- * onError: (error) => console.error('Auth failed:', error)
44
- * }));
40
+ * // Protect all routes under /api/protected
41
+ * app.use('/api/protected', oxy.auth());
45
42
  *
46
- * // Load full user data
47
- * app.use('/api/protected', oxyServices.auth({ loadUser: true }));
43
+ * // Access user in route handler
44
+ * app.get('/api/protected/me', (req, res) => {
45
+ * res.json({ userId: req.userId, user: req.user });
46
+ * });
47
+ *
48
+ * // Load full user profile from API
49
+ * app.use('/api/admin', oxy.auth({ loadUser: true }));
50
+ *
51
+ * // Optional auth - attach user if present, don't block if absent
52
+ * app.use('/api/public', oxy.auth({ optional: true }));
48
53
  * ```
49
54
  *
50
55
  * @param options Optional configuration
51
- * @param options.debug Enable debug logging (default: false)
52
- * @param options.onError Custom error handler
53
- * @param options.loadUser Load full user data (default: false for performance)
54
- * @param options.session Use session-based validation (default: false)
55
56
  * @returns Express middleware function
56
57
  */
57
58
  auth(options = {}) {
58
- const { debug = false, onError, loadUser = false, session = false } = options;
59
- // Return a synchronous middleware function
60
- return (req, res, next) => {
59
+ const { debug = false, onError, loadUser = false, optional = false } = options;
60
+ // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
61
+ const oxyInstance = this;
62
+ // Return an async middleware function
63
+ return async (req, res, next) => {
61
64
  try {
62
- // Extract token from Authorization header
65
+ // Extract token from Authorization header or query params
63
66
  const authHeader = req.headers['authorization'];
64
- const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
67
+ let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
68
+ // Fallback to query params (useful for WebSocket upgrades)
69
+ if (!token) {
70
+ const q = req.query || {};
71
+ if (typeof q.token === 'string' && q.token)
72
+ token = q.token;
73
+ else if (typeof q.access_token === 'string' && q.access_token)
74
+ token = q.access_token;
75
+ }
65
76
  if (debug) {
66
- console.log(`🔐 Auth: Processing ${req.method} ${req.path}`);
67
- console.log(`🔐 Auth: Token present: ${!!token}`);
77
+ console.log(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`);
68
78
  }
69
79
  if (!token) {
80
+ if (optional) {
81
+ req.userId = null;
82
+ req.user = null;
83
+ return next();
84
+ }
70
85
  const error = {
71
86
  message: 'Access token required',
72
87
  code: 'MISSING_TOKEN',
73
88
  status: 401
74
89
  };
75
- if (debug)
76
- console.log(`❌ Auth: Missing token`);
77
90
  if (onError)
78
91
  return onError(error);
79
92
  return res.status(401).json(error);
80
93
  }
81
- // Decode and validate token
94
+ // Decode token to extract claims
82
95
  let decoded;
83
96
  try {
84
97
  decoded = jwtDecode(token);
85
- if (debug) {
86
- console.log(`🔐 Auth: Token decoded, User ID: ${decoded.userId || decoded.id}`);
87
- }
88
98
  }
89
99
  catch (decodeError) {
100
+ if (optional) {
101
+ req.userId = null;
102
+ req.user = null;
103
+ return next();
104
+ }
90
105
  const error = {
91
106
  message: 'Invalid token format',
92
107
  code: 'INVALID_TOKEN_FORMAT',
93
- status: 403
108
+ status: 401
94
109
  };
95
- if (debug)
96
- console.log(`❌ Auth: Token decode failed`);
97
110
  if (onError)
98
111
  return onError(error);
99
- return res.status(403).json(error);
112
+ return res.status(401).json(error);
100
113
  }
101
114
  const userId = decoded.userId || decoded.id;
102
115
  if (!userId) {
116
+ if (optional) {
117
+ req.userId = null;
118
+ req.user = null;
119
+ return next();
120
+ }
103
121
  const error = {
104
122
  message: 'Token missing user ID',
105
123
  code: 'INVALID_TOKEN_PAYLOAD',
106
- status: 403
124
+ status: 401
107
125
  };
108
- if (debug)
109
- console.log(`❌ Auth: Token missing user ID`);
110
126
  if (onError)
111
127
  return onError(error);
112
- return res.status(403).json(error);
128
+ return res.status(401).json(error);
113
129
  }
114
- // Check token expiration
130
+ // Check token expiration locally first (fast path)
115
131
  if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
132
+ if (optional) {
133
+ req.userId = null;
134
+ req.user = null;
135
+ return next();
136
+ }
116
137
  const error = {
117
138
  message: 'Token expired',
118
139
  code: 'TOKEN_EXPIRED',
119
- status: 403
140
+ status: 401
120
141
  };
121
- if (debug)
122
- console.log(`❌ Auth: Token expired`);
123
142
  if (onError)
124
143
  return onError(error);
125
- return res.status(403).json(error);
144
+ return res.status(401).json(error);
145
+ }
146
+ // Validate token against the Oxy API for session-based verification
147
+ // This ensures the session hasn't been revoked server-side
148
+ if (decoded.sessionId) {
149
+ try {
150
+ const validationResult = await oxyInstance.validateSession(decoded.sessionId, {
151
+ useHeaderValidation: true,
152
+ });
153
+ if (!validationResult || !validationResult.valid) {
154
+ if (optional) {
155
+ req.userId = null;
156
+ req.user = null;
157
+ return next();
158
+ }
159
+ const error = {
160
+ message: 'Session invalid or expired',
161
+ code: 'INVALID_SESSION',
162
+ status: 401
163
+ };
164
+ if (onError)
165
+ return onError(error);
166
+ return res.status(401).json(error);
167
+ }
168
+ // Use validated user data from session validation (already has full user)
169
+ req.userId = userId;
170
+ req.accessToken = token;
171
+ req.sessionId = decoded.sessionId;
172
+ if (loadUser && validationResult.user) {
173
+ // Session validation already returns full user data
174
+ req.user = validationResult.user;
175
+ }
176
+ else {
177
+ req.user = { id: userId };
178
+ }
179
+ if (debug) {
180
+ console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
181
+ }
182
+ return next();
183
+ }
184
+ catch (validationError) {
185
+ if (debug) {
186
+ console.log(`[oxy.auth] Session validation failed:`, validationError);
187
+ }
188
+ if (optional) {
189
+ req.userId = null;
190
+ req.user = null;
191
+ return next();
192
+ }
193
+ const error = {
194
+ message: 'Session validation failed',
195
+ code: 'SESSION_VALIDATION_ERROR',
196
+ status: 401
197
+ };
198
+ if (onError)
199
+ return onError(error);
200
+ return res.status(401).json(error);
201
+ }
126
202
  }
127
- // For now, skip session validation to keep it simple
128
- // Session validation can be added later if needed
129
- // Set request properties immediately
203
+ // Non-session token: use local validation only (userId from JWT)
130
204
  req.userId = userId;
131
205
  req.accessToken = token;
132
206
  req.user = { id: userId };
133
- // Automatically authenticate the OxyServices instance so all subsequent API calls are authenticated
134
- this.setTokens(token);
207
+ // If loadUser requested with non-session token, fetch from API
208
+ if (loadUser) {
209
+ try {
210
+ // Temporarily set token to make the API call
211
+ const prevToken = oxyInstance.getAccessToken();
212
+ oxyInstance.setTokens(token);
213
+ const fullUser = await oxyInstance.getCurrentUser();
214
+ // Restore previous token
215
+ if (prevToken) {
216
+ oxyInstance.setTokens(prevToken);
217
+ }
218
+ else {
219
+ oxyInstance.clearTokens();
220
+ }
221
+ if (fullUser) {
222
+ req.user = fullUser;
223
+ }
224
+ }
225
+ catch {
226
+ // Failed to load user, continue with basic user object
227
+ }
228
+ }
135
229
  if (debug) {
136
- console.log(`✅ Auth: Authentication successful for user ${userId}`);
230
+ console.log(`[oxy.auth] OK user=${userId} (no session)`);
137
231
  }
138
232
  next();
139
233
  }
140
234
  catch (error) {
141
- const apiError = this.handleError(error);
235
+ const apiError = oxyInstance.handleError(error);
142
236
  if (debug) {
143
- // Debug logging - using console.log is acceptable here for development
144
- console.log(`❌ Auth: Unexpected error:`, apiError);
237
+ console.log(`[oxy.auth] Error:`, apiError);
145
238
  }
146
239
  if (onError)
147
240
  return onError(apiError);
@@ -149,5 +242,86 @@ export function OxyServicesUtilityMixin(Base) {
149
242
  }
150
243
  };
151
244
  }
245
+ /**
246
+ * Socket.IO authentication middleware factory
247
+ *
248
+ * Returns a middleware function for Socket.IO that validates JWT tokens
249
+ * from the handshake auth object and attaches user data to the socket.
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * import { OxyServices } from '@oxyhq/core';
254
+ * import { Server } from 'socket.io';
255
+ *
256
+ * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
257
+ * const io = new Server(server);
258
+ *
259
+ * // Authenticate all socket connections
260
+ * io.use(oxy.authSocket());
261
+ *
262
+ * io.on('connection', (socket) => {
263
+ * console.log('Authenticated user:', socket.data.userId);
264
+ * });
265
+ * ```
266
+ */
267
+ authSocket(options = {}) {
268
+ const { debug = false } = options;
269
+ // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
270
+ const oxyInstance = this;
271
+ return async (socket, next) => {
272
+ try {
273
+ const token = socket.handshake?.auth?.token;
274
+ if (!token) {
275
+ return next(new Error('Authentication required'));
276
+ }
277
+ let decoded;
278
+ try {
279
+ decoded = jwtDecode(token);
280
+ }
281
+ catch {
282
+ return next(new Error('Invalid token'));
283
+ }
284
+ const userId = decoded.userId || decoded.id;
285
+ if (!userId) {
286
+ return next(new Error('Invalid token payload'));
287
+ }
288
+ // Check expiration
289
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
290
+ return next(new Error('Token expired'));
291
+ }
292
+ // Validate session if available
293
+ if (decoded.sessionId) {
294
+ try {
295
+ const result = await oxyInstance.validateSession(decoded.sessionId, {
296
+ useHeaderValidation: true,
297
+ });
298
+ if (!result || !result.valid) {
299
+ return next(new Error('Session invalid'));
300
+ }
301
+ }
302
+ catch {
303
+ return next(new Error('Session validation failed'));
304
+ }
305
+ }
306
+ // Attach user data to socket
307
+ socket.data = socket.data || {};
308
+ socket.data.userId = userId;
309
+ socket.data.sessionId = decoded.sessionId || null;
310
+ socket.data.token = token;
311
+ // Also set on socket.user for backward compatibility
312
+ socket.user = { id: userId, userId, sessionId: decoded.sessionId };
313
+ if (debug) {
314
+ console.log(`[oxy.authSocket] OK user=${userId}`);
315
+ }
316
+ next();
317
+ }
318
+ catch (err) {
319
+ if (debug) {
320
+ console.log(`[oxy.authSocket] Error:`, err);
321
+ }
322
+ next(new Error('Authentication error'));
323
+ }
324
+ };
325
+ }
152
326
  };
153
327
  }
@@ -28,6 +28,8 @@ interface RequestConfig extends RequestOptions {
28
28
  url: string;
29
29
  data?: unknown;
30
30
  params?: Record<string, unknown>;
31
+ /** @internal Used to prevent infinite auth retry loops */
32
+ _isAuthRetry?: boolean;
31
33
  }
32
34
  /**
33
35
  * Unified HTTP Service
@@ -44,6 +46,7 @@ export declare class HttpService {
44
46
  private logger;
45
47
  private config;
46
48
  private tokenRefreshPromise;
49
+ private _onTokenRefreshed;
47
50
  private requestMetrics;
48
51
  constructor(config: OxyConfig);
49
52
  /**
@@ -140,6 +143,7 @@ export declare class HttpService {
140
143
  data: T;
141
144
  }>;
142
145
  setTokens(accessToken: string, refreshToken?: string): void;
146
+ set onTokenRefreshed(callback: ((accessToken: string) => void) | null);
143
147
  clearTokens(): void;
144
148
  getAccessToken(): string | null;
145
149
  hasAccessToken(): boolean;
@@ -77,6 +77,15 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
77
77
  signUpWithPopup(options?: PopupAuthOptions): Promise<SessionLoginResponse>;
78
78
  signInWithRedirect(options?: RedirectAuthOptions): void;
79
79
  signUpWithRedirect(options?: RedirectAuthOptions): void;
80
+ auth(options?: {
81
+ debug?: boolean;
82
+ onError?: (error: any) => any;
83
+ loadUser?: boolean;
84
+ optional?: boolean;
85
+ }): (req: any, res: any, next: any) => Promise<void>;
86
+ authSocket(options?: {
87
+ debug?: boolean;
88
+ }): (socket: any, next: (err?: Error) => void) => Promise<void>;
80
89
  }
81
90
  export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
82
91
  /**
@@ -1,5 +1,18 @@
1
1
  import type { ApiError } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
+ /**
4
+ * Options for oxyClient.auth() middleware
5
+ */
6
+ interface AuthMiddlewareOptions {
7
+ /** Enable debug logging (default: false) */
8
+ debug?: boolean;
9
+ /** Custom error handler - receives error object, can return response */
10
+ onError?: (error: ApiError) => any;
11
+ /** Load full user profile from API (default: false for performance) */
12
+ loadUser?: boolean;
13
+ /** Optional auth - attach user if token present but don't block (default: false) */
14
+ optional?: boolean;
15
+ }
3
16
  export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T): {
4
17
  new (...args: any[]): {
5
18
  /**
@@ -12,40 +25,61 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
12
25
  image?: string;
13
26
  }>;
14
27
  /**
15
- * Simple Express.js authentication middleware
28
+ * Express.js authentication middleware
16
29
  *
17
- * Built-in authentication middleware that validates JWT tokens and adds user data to requests.
30
+ * Validates JWT tokens against the Oxy API and attaches user data to requests.
31
+ * Uses server-side session validation for security (not just JWT decode).
18
32
  *
19
33
  * @example
20
34
  * ```typescript
21
- * // Basic usage - just add it to your routes
22
- * app.use('/api/protected', oxyServices.auth());
35
+ * import { OxyServices } from '@oxyhq/core';
23
36
  *
24
- * // With debug logging
25
- * app.use('/api/protected', oxyServices.auth({ debug: true }));
37
+ * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
26
38
  *
27
- * // With custom error handling
28
- * app.use('/api/protected', oxyServices.auth({
29
- * onError: (error) => console.error('Auth failed:', error)
30
- * }));
39
+ * // Protect all routes under /api/protected
40
+ * app.use('/api/protected', oxy.auth());
31
41
  *
32
- * // Load full user data
33
- * app.use('/api/protected', oxyServices.auth({ loadUser: true }));
42
+ * // Access user in route handler
43
+ * app.get('/api/protected/me', (req, res) => {
44
+ * res.json({ userId: req.userId, user: req.user });
45
+ * });
46
+ *
47
+ * // Load full user profile from API
48
+ * app.use('/api/admin', oxy.auth({ loadUser: true }));
49
+ *
50
+ * // Optional auth - attach user if present, don't block if absent
51
+ * app.use('/api/public', oxy.auth({ optional: true }));
34
52
  * ```
35
53
  *
36
54
  * @param options Optional configuration
37
- * @param options.debug Enable debug logging (default: false)
38
- * @param options.onError Custom error handler
39
- * @param options.loadUser Load full user data (default: false for performance)
40
- * @param options.session Use session-based validation (default: false)
41
55
  * @returns Express middleware function
42
56
  */
43
- auth(options?: {
57
+ auth(options?: AuthMiddlewareOptions): (req: any, res: any, next: any) => Promise<any>;
58
+ /**
59
+ * Socket.IO authentication middleware factory
60
+ *
61
+ * Returns a middleware function for Socket.IO that validates JWT tokens
62
+ * from the handshake auth object and attaches user data to the socket.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * import { OxyServices } from '@oxyhq/core';
67
+ * import { Server } from 'socket.io';
68
+ *
69
+ * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
70
+ * const io = new Server(server);
71
+ *
72
+ * // Authenticate all socket connections
73
+ * io.use(oxy.authSocket());
74
+ *
75
+ * io.on('connection', (socket) => {
76
+ * console.log('Authenticated user:', socket.data.userId);
77
+ * });
78
+ * ```
79
+ */
80
+ authSocket(options?: {
44
81
  debug?: boolean;
45
- onError?: (error: ApiError) => any;
46
- loadUser?: boolean;
47
- session?: boolean;
48
- }): (req: any, res: any, next: any) => any;
82
+ }): (socket: any, next: (err?: Error) => void) => Promise<void>;
49
83
  httpService: import("../HttpService").HttpService;
50
84
  cloudURL: string;
51
85
  config: import("../OxyServices.base").OxyConfig;
@@ -93,3 +127,4 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
93
127
  };
94
128
  __resetTokensForTests(): void;
95
129
  } & T;
130
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -153,6 +153,11 @@ export class AuthManager {
153
153
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
154
154
  };
155
155
  this.storage = this.config.storage;
156
+
157
+ // Persist tokens to storage when HttpService refreshes them automatically
158
+ this.oxyServices.httpService.onTokenRefreshed = (accessToken: string) => {
159
+ this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
160
+ };
156
161
  }
157
162
 
158
163
  /**
@@ -280,8 +285,19 @@ export class AuthManager {
280
285
  }
281
286
 
282
287
  private async _doRefreshToken(): Promise<boolean> {
283
- const refreshToken = await this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
284
- if (!refreshToken) {
288
+ // Get session info to find sessionId for token refresh
289
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
290
+ if (!sessionJson) {
291
+ return false;
292
+ }
293
+
294
+ let sessionId: string;
295
+ try {
296
+ const session = JSON.parse(sessionJson);
297
+ sessionId = session.sessionId;
298
+ if (!sessionId) return false;
299
+ } catch (err) {
300
+ console.error('AuthManager: Failed to parse session from storage.', err);
285
301
  return false;
286
302
  }
287
303
 
@@ -289,13 +305,37 @@ export class AuthManager {
289
305
  await retryAsync(
290
306
  async () => {
291
307
  const httpService = this.oxyServices.httpService as HttpService;
292
- const response = await httpService.request<SessionLoginResponse>({
293
- method: 'POST',
294
- url: '/api/auth/refresh',
295
- data: { refreshToken },
308
+ // Use session-based token endpoint which handles auto-refresh server-side
309
+ const response = await httpService.request<{ accessToken: string; expiresAt: string }>({
310
+ method: 'GET',
311
+ url: `/api/session/token/${sessionId}`,
296
312
  cache: false,
313
+ retry: false,
297
314
  });
298
- await this.handleAuthSuccess(response, 'credentials');
315
+
316
+ if (!response.accessToken) {
317
+ throw new Error('No access token in refresh response');
318
+ }
319
+
320
+ // Update access token in storage and HTTP client
321
+ await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
322
+ this.oxyServices.httpService.setTokens(response.accessToken);
323
+
324
+ // Update session expiry and schedule next refresh
325
+ if (response.expiresAt) {
326
+ try {
327
+ const session = JSON.parse(sessionJson);
328
+ session.expiresAt = response.expiresAt;
329
+ await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
330
+ } catch (err) {
331
+ // Ignore parse errors for session update, but log for debugging.
332
+ console.error('AuthManager: Failed to re-save session after token refresh.', err);
333
+ }
334
+
335
+ if (this.config.autoRefresh) {
336
+ this.setupTokenRefresh(response.expiresAt);
337
+ }
338
+ }
299
339
  },
300
340
  2, // 2 retries = 3 total attempts
301
341
  1000, // 1s base delay with exponential backoff + jitter
@@ -52,6 +52,8 @@ interface RequestConfig extends RequestOptions {
52
52
  url: string;
53
53
  data?: unknown;
54
54
  params?: Record<string, unknown>;
55
+ /** @internal Used to prevent infinite auth retry loops */
56
+ _isAuthRetry?: boolean;
55
57
  }
56
58
 
57
59
  /**
@@ -132,6 +134,7 @@ export class HttpService {
132
134
  private logger: SimpleLogger;
133
135
  private config: OxyConfig;
134
136
  private tokenRefreshPromise: Promise<string | null> | null = null;
137
+ private _onTokenRefreshed: ((accessToken: string) => void) | null = null;
135
138
 
136
139
  // Performance monitoring
137
140
  private requestMetrics = {
@@ -322,10 +325,27 @@ export class HttpService {
322
325
 
323
326
  // Handle response
324
327
  if (!response.ok) {
325
- if (response.status === 401) {
328
+ // On 401, attempt token refresh and retry once before giving up
329
+ if (response.status === 401 && !config._isAuthRetry) {
330
+ const currentToken = this.tokenStore.getAccessToken();
331
+ if (currentToken) {
332
+ try {
333
+ const decoded = jwtDecode<JwtPayload>(currentToken);
334
+ if (decoded.sessionId) {
335
+ const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
336
+ if (refreshResult) {
337
+ // Retry the request with the new token
338
+ return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
339
+ }
340
+ }
341
+ } catch {
342
+ // Token decode failed, fall through to clear
343
+ }
344
+ }
345
+ // Refresh failed or no token — clear tokens
326
346
  this.tokenStore.clearTokens();
327
347
  }
328
-
348
+
329
349
  // Try to parse error response (handle empty/malformed JSON)
330
350
  let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
331
351
  const contentType = response.headers.get('content-type');
@@ -343,10 +363,10 @@ export class HttpService {
343
363
  this.logger.warn('Failed to parse error response JSON:', parseError);
344
364
  }
345
365
  }
346
-
347
- const error = new Error(errorMessage) as Error & {
348
- status?: number;
349
- response?: { status: number; statusText: string }
366
+
367
+ const error = new Error(errorMessage) as Error & {
368
+ status?: number;
369
+ response?: { status: number; statusText: string }
350
370
  };
351
371
  error.status = response.status;
352
372
  error.response = { status: response.status, statusText: response.statusText };
@@ -596,6 +616,7 @@ export class HttpService {
596
616
  if (response.ok) {
597
617
  const { accessToken: newToken } = await response.json();
598
618
  this.tokenStore.setTokens(newToken);
619
+ this._onTokenRefreshed?.(newToken);
599
620
  this.logger.debug('Token refreshed');
600
621
  return `Bearer ${newToken}`;
601
622
  }
@@ -712,6 +733,10 @@ export class HttpService {
712
733
  this.tokenStore.setTokens(accessToken, refreshToken);
713
734
  }
714
735
 
736
+ set onTokenRefreshed(callback: ((accessToken: string) => void) | null) {
737
+ this._onTokenRefreshed = callback;
738
+ }
739
+
715
740
  clearTokens(): void {
716
741
  this.tokenStore.clearTokens();
717
742
  this.tokenStore.clearCsrfToken();