@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.
@@ -111,6 +111,10 @@ class AuthManager {
111
111
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
112
112
  };
113
113
  this.storage = this.config.storage;
114
+ // Persist tokens to storage when HttpService refreshes them automatically
115
+ this.oxyServices.httpService.onTokenRefreshed = (accessToken) => {
116
+ this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
117
+ };
114
118
  }
115
119
  /**
116
120
  * Get default storage based on environment.
@@ -221,20 +225,53 @@ class AuthManager {
221
225
  }
222
226
  }
223
227
  async _doRefreshToken() {
224
- const refreshToken = await this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
225
- if (!refreshToken) {
228
+ // Get session info to find sessionId for token refresh
229
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
230
+ if (!sessionJson) {
231
+ return false;
232
+ }
233
+ let sessionId;
234
+ try {
235
+ const session = JSON.parse(sessionJson);
236
+ sessionId = session.sessionId;
237
+ if (!sessionId)
238
+ return false;
239
+ }
240
+ catch (err) {
241
+ console.error('AuthManager: Failed to parse session from storage.', err);
226
242
  return false;
227
243
  }
228
244
  try {
229
245
  await (0, asyncUtils_1.retryAsync)(async () => {
230
246
  const httpService = this.oxyServices.httpService;
247
+ // Use session-based token endpoint which handles auto-refresh server-side
231
248
  const response = await httpService.request({
232
- method: 'POST',
233
- url: '/api/auth/refresh',
234
- data: { refreshToken },
249
+ method: 'GET',
250
+ url: `/api/session/token/${sessionId}`,
235
251
  cache: false,
252
+ retry: false,
236
253
  });
237
- await this.handleAuthSuccess(response, 'credentials');
254
+ if (!response.accessToken) {
255
+ throw new Error('No access token in refresh response');
256
+ }
257
+ // Update access token in storage and HTTP client
258
+ await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
259
+ this.oxyServices.httpService.setTokens(response.accessToken);
260
+ // Update session expiry and schedule next refresh
261
+ if (response.expiresAt) {
262
+ try {
263
+ const session = JSON.parse(sessionJson);
264
+ session.expiresAt = response.expiresAt;
265
+ await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
266
+ }
267
+ catch (err) {
268
+ // Ignore parse errors for session update, but log for debugging.
269
+ console.error('AuthManager: Failed to re-save session after token refresh.', err);
270
+ }
271
+ if (this.config.autoRefresh) {
272
+ this.setupTokenRefresh(response.expiresAt);
273
+ }
274
+ }
238
275
  }, 2, // 2 retries = 3 total attempts
239
276
  1000, // 1s base delay with exponential backoff + jitter
240
277
  (error) => {
@@ -86,6 +86,7 @@ class TokenStore {
86
86
  class HttpService {
87
87
  constructor(config) {
88
88
  this.tokenRefreshPromise = null;
89
+ this._onTokenRefreshed = null;
89
90
  // Performance monitoring
90
91
  this.requestMetrics = {
91
92
  totalRequests: 0,
@@ -228,7 +229,25 @@ class HttpService {
228
229
  clearTimeout(timeoutId);
229
230
  // Handle response
230
231
  if (!response.ok) {
231
- if (response.status === 401) {
232
+ // On 401, attempt token refresh and retry once before giving up
233
+ if (response.status === 401 && !config._isAuthRetry) {
234
+ const currentToken = this.tokenStore.getAccessToken();
235
+ if (currentToken) {
236
+ try {
237
+ const decoded = (0, jwt_decode_1.jwtDecode)(currentToken);
238
+ if (decoded.sessionId) {
239
+ const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
240
+ if (refreshResult) {
241
+ // Retry the request with the new token
242
+ return this.request({ ...config, _isAuthRetry: true, retry: false });
243
+ }
244
+ }
245
+ }
246
+ catch {
247
+ // Token decode failed, fall through to clear
248
+ }
249
+ }
250
+ // Refresh failed or no token — clear tokens
232
251
  this.tokenStore.clearTokens();
233
252
  }
234
253
  // Try to parse error response (handle empty/malformed JSON)
@@ -481,6 +500,7 @@ class HttpService {
481
500
  if (response.ok) {
482
501
  const { accessToken: newToken } = await response.json();
483
502
  this.tokenStore.setTokens(newToken);
503
+ this._onTokenRefreshed?.(newToken);
484
504
  this.logger.debug('Token refreshed');
485
505
  return `Bearer ${newToken}`;
486
506
  }
@@ -587,6 +607,9 @@ class HttpService {
587
607
  setTokens(accessToken, refreshToken = '') {
588
608
  this.tokenStore.setTokens(accessToken, refreshToken);
589
609
  }
610
+ set onTokenRefreshed(callback) {
611
+ this._onTokenRefreshed = callback;
612
+ }
590
613
  clearTokens() {
591
614
  this.tokenStore.clearTokens();
592
615
  this.tokenStore.clearCsrfToken();
@@ -29,122 +29,215 @@ function OxyServicesUtilityMixin(Base) {
29
29
  }
30
30
  }
31
31
  /**
32
- * Simple Express.js authentication middleware
32
+ * Express.js authentication middleware
33
33
  *
34
- * Built-in authentication middleware that validates JWT tokens and adds user data to requests.
34
+ * Validates JWT tokens against the Oxy API and attaches user data to requests.
35
+ * Uses server-side session validation for security (not just JWT decode).
35
36
  *
36
37
  * @example
37
38
  * ```typescript
38
- * // Basic usage - just add it to your routes
39
- * app.use('/api/protected', oxyServices.auth());
39
+ * import { OxyServices } from '@oxyhq/core';
40
40
  *
41
- * // With debug logging
42
- * app.use('/api/protected', oxyServices.auth({ debug: true }));
41
+ * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
43
42
  *
44
- * // With custom error handling
45
- * app.use('/api/protected', oxyServices.auth({
46
- * onError: (error) => console.error('Auth failed:', error)
47
- * }));
43
+ * // Protect all routes under /api/protected
44
+ * app.use('/api/protected', oxy.auth());
48
45
  *
49
- * // Load full user data
50
- * app.use('/api/protected', oxyServices.auth({ loadUser: true }));
46
+ * // Access user in route handler
47
+ * app.get('/api/protected/me', (req, res) => {
48
+ * res.json({ userId: req.userId, user: req.user });
49
+ * });
50
+ *
51
+ * // Load full user profile from API
52
+ * app.use('/api/admin', oxy.auth({ loadUser: true }));
53
+ *
54
+ * // Optional auth - attach user if present, don't block if absent
55
+ * app.use('/api/public', oxy.auth({ optional: true }));
51
56
  * ```
52
57
  *
53
58
  * @param options Optional configuration
54
- * @param options.debug Enable debug logging (default: false)
55
- * @param options.onError Custom error handler
56
- * @param options.loadUser Load full user data (default: false for performance)
57
- * @param options.session Use session-based validation (default: false)
58
59
  * @returns Express middleware function
59
60
  */
60
61
  auth(options = {}) {
61
- const { debug = false, onError, loadUser = false, session = false } = options;
62
- // Return a synchronous middleware function
63
- return (req, res, next) => {
62
+ const { debug = false, onError, loadUser = false, optional = false } = options;
63
+ // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
64
+ const oxyInstance = this;
65
+ // Return an async middleware function
66
+ return async (req, res, next) => {
64
67
  try {
65
- // Extract token from Authorization header
68
+ // Extract token from Authorization header or query params
66
69
  const authHeader = req.headers['authorization'];
67
- const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
70
+ let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
71
+ // Fallback to query params (useful for WebSocket upgrades)
72
+ if (!token) {
73
+ const q = req.query || {};
74
+ if (typeof q.token === 'string' && q.token)
75
+ token = q.token;
76
+ else if (typeof q.access_token === 'string' && q.access_token)
77
+ token = q.access_token;
78
+ }
68
79
  if (debug) {
69
- console.log(`🔐 Auth: Processing ${req.method} ${req.path}`);
70
- console.log(`🔐 Auth: Token present: ${!!token}`);
80
+ console.log(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`);
71
81
  }
72
82
  if (!token) {
83
+ if (optional) {
84
+ req.userId = null;
85
+ req.user = null;
86
+ return next();
87
+ }
73
88
  const error = {
74
89
  message: 'Access token required',
75
90
  code: 'MISSING_TOKEN',
76
91
  status: 401
77
92
  };
78
- if (debug)
79
- console.log(`❌ Auth: Missing token`);
80
93
  if (onError)
81
94
  return onError(error);
82
95
  return res.status(401).json(error);
83
96
  }
84
- // Decode and validate token
97
+ // Decode token to extract claims
85
98
  let decoded;
86
99
  try {
87
100
  decoded = (0, jwt_decode_1.jwtDecode)(token);
88
- if (debug) {
89
- console.log(`🔐 Auth: Token decoded, User ID: ${decoded.userId || decoded.id}`);
90
- }
91
101
  }
92
102
  catch (decodeError) {
103
+ if (optional) {
104
+ req.userId = null;
105
+ req.user = null;
106
+ return next();
107
+ }
93
108
  const error = {
94
109
  message: 'Invalid token format',
95
110
  code: 'INVALID_TOKEN_FORMAT',
96
- status: 403
111
+ status: 401
97
112
  };
98
- if (debug)
99
- console.log(`❌ Auth: Token decode failed`);
100
113
  if (onError)
101
114
  return onError(error);
102
- return res.status(403).json(error);
115
+ return res.status(401).json(error);
103
116
  }
104
117
  const userId = decoded.userId || decoded.id;
105
118
  if (!userId) {
119
+ if (optional) {
120
+ req.userId = null;
121
+ req.user = null;
122
+ return next();
123
+ }
106
124
  const error = {
107
125
  message: 'Token missing user ID',
108
126
  code: 'INVALID_TOKEN_PAYLOAD',
109
- status: 403
127
+ status: 401
110
128
  };
111
- if (debug)
112
- console.log(`❌ Auth: Token missing user ID`);
113
129
  if (onError)
114
130
  return onError(error);
115
- return res.status(403).json(error);
131
+ return res.status(401).json(error);
116
132
  }
117
- // Check token expiration
133
+ // Check token expiration locally first (fast path)
118
134
  if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
135
+ if (optional) {
136
+ req.userId = null;
137
+ req.user = null;
138
+ return next();
139
+ }
119
140
  const error = {
120
141
  message: 'Token expired',
121
142
  code: 'TOKEN_EXPIRED',
122
- status: 403
143
+ status: 401
123
144
  };
124
- if (debug)
125
- console.log(`❌ Auth: Token expired`);
126
145
  if (onError)
127
146
  return onError(error);
128
- return res.status(403).json(error);
147
+ return res.status(401).json(error);
148
+ }
149
+ // Validate token against the Oxy API for session-based verification
150
+ // This ensures the session hasn't been revoked server-side
151
+ if (decoded.sessionId) {
152
+ try {
153
+ const validationResult = await oxyInstance.validateSession(decoded.sessionId, {
154
+ useHeaderValidation: true,
155
+ });
156
+ if (!validationResult || !validationResult.valid) {
157
+ if (optional) {
158
+ req.userId = null;
159
+ req.user = null;
160
+ return next();
161
+ }
162
+ const error = {
163
+ message: 'Session invalid or expired',
164
+ code: 'INVALID_SESSION',
165
+ status: 401
166
+ };
167
+ if (onError)
168
+ return onError(error);
169
+ return res.status(401).json(error);
170
+ }
171
+ // Use validated user data from session validation (already has full user)
172
+ req.userId = userId;
173
+ req.accessToken = token;
174
+ req.sessionId = decoded.sessionId;
175
+ if (loadUser && validationResult.user) {
176
+ // Session validation already returns full user data
177
+ req.user = validationResult.user;
178
+ }
179
+ else {
180
+ req.user = { id: userId };
181
+ }
182
+ if (debug) {
183
+ console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
184
+ }
185
+ return next();
186
+ }
187
+ catch (validationError) {
188
+ if (debug) {
189
+ console.log(`[oxy.auth] Session validation failed:`, validationError);
190
+ }
191
+ if (optional) {
192
+ req.userId = null;
193
+ req.user = null;
194
+ return next();
195
+ }
196
+ const error = {
197
+ message: 'Session validation failed',
198
+ code: 'SESSION_VALIDATION_ERROR',
199
+ status: 401
200
+ };
201
+ if (onError)
202
+ return onError(error);
203
+ return res.status(401).json(error);
204
+ }
129
205
  }
130
- // For now, skip session validation to keep it simple
131
- // Session validation can be added later if needed
132
- // Set request properties immediately
206
+ // Non-session token: use local validation only (userId from JWT)
133
207
  req.userId = userId;
134
208
  req.accessToken = token;
135
209
  req.user = { id: userId };
136
- // Automatically authenticate the OxyServices instance so all subsequent API calls are authenticated
137
- this.setTokens(token);
210
+ // If loadUser requested with non-session token, fetch from API
211
+ if (loadUser) {
212
+ try {
213
+ // Temporarily set token to make the API call
214
+ const prevToken = oxyInstance.getAccessToken();
215
+ oxyInstance.setTokens(token);
216
+ const fullUser = await oxyInstance.getCurrentUser();
217
+ // Restore previous token
218
+ if (prevToken) {
219
+ oxyInstance.setTokens(prevToken);
220
+ }
221
+ else {
222
+ oxyInstance.clearTokens();
223
+ }
224
+ if (fullUser) {
225
+ req.user = fullUser;
226
+ }
227
+ }
228
+ catch {
229
+ // Failed to load user, continue with basic user object
230
+ }
231
+ }
138
232
  if (debug) {
139
- console.log(`✅ Auth: Authentication successful for user ${userId}`);
233
+ console.log(`[oxy.auth] OK user=${userId} (no session)`);
140
234
  }
141
235
  next();
142
236
  }
143
237
  catch (error) {
144
- const apiError = this.handleError(error);
238
+ const apiError = oxyInstance.handleError(error);
145
239
  if (debug) {
146
- // Debug logging - using console.log is acceptable here for development
147
- console.log(`❌ Auth: Unexpected error:`, apiError);
240
+ console.log(`[oxy.auth] Error:`, apiError);
148
241
  }
149
242
  if (onError)
150
243
  return onError(apiError);
@@ -152,5 +245,86 @@ function OxyServicesUtilityMixin(Base) {
152
245
  }
153
246
  };
154
247
  }
248
+ /**
249
+ * Socket.IO authentication middleware factory
250
+ *
251
+ * Returns a middleware function for Socket.IO that validates JWT tokens
252
+ * from the handshake auth object and attaches user data to the socket.
253
+ *
254
+ * @example
255
+ * ```typescript
256
+ * import { OxyServices } from '@oxyhq/core';
257
+ * import { Server } from 'socket.io';
258
+ *
259
+ * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
260
+ * const io = new Server(server);
261
+ *
262
+ * // Authenticate all socket connections
263
+ * io.use(oxy.authSocket());
264
+ *
265
+ * io.on('connection', (socket) => {
266
+ * console.log('Authenticated user:', socket.data.userId);
267
+ * });
268
+ * ```
269
+ */
270
+ authSocket(options = {}) {
271
+ const { debug = false } = options;
272
+ // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
273
+ const oxyInstance = this;
274
+ return async (socket, next) => {
275
+ try {
276
+ const token = socket.handshake?.auth?.token;
277
+ if (!token) {
278
+ return next(new Error('Authentication required'));
279
+ }
280
+ let decoded;
281
+ try {
282
+ decoded = (0, jwt_decode_1.jwtDecode)(token);
283
+ }
284
+ catch {
285
+ return next(new Error('Invalid token'));
286
+ }
287
+ const userId = decoded.userId || decoded.id;
288
+ if (!userId) {
289
+ return next(new Error('Invalid token payload'));
290
+ }
291
+ // Check expiration
292
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
293
+ return next(new Error('Token expired'));
294
+ }
295
+ // Validate session if available
296
+ if (decoded.sessionId) {
297
+ try {
298
+ const result = await oxyInstance.validateSession(decoded.sessionId, {
299
+ useHeaderValidation: true,
300
+ });
301
+ if (!result || !result.valid) {
302
+ return next(new Error('Session invalid'));
303
+ }
304
+ }
305
+ catch {
306
+ return next(new Error('Session validation failed'));
307
+ }
308
+ }
309
+ // Attach user data to socket
310
+ socket.data = socket.data || {};
311
+ socket.data.userId = userId;
312
+ socket.data.sessionId = decoded.sessionId || null;
313
+ socket.data.token = token;
314
+ // Also set on socket.user for backward compatibility
315
+ socket.user = { id: userId, userId, sessionId: decoded.sessionId };
316
+ if (debug) {
317
+ console.log(`[oxy.authSocket] OK user=${userId}`);
318
+ }
319
+ next();
320
+ }
321
+ catch (err) {
322
+ if (debug) {
323
+ console.log(`[oxy.authSocket] Error:`, err);
324
+ }
325
+ next(new Error('Authentication error'));
326
+ }
327
+ };
328
+ }
155
329
  };
156
330
  }
@@ -107,6 +107,10 @@ export class AuthManager {
107
107
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
108
108
  };
109
109
  this.storage = this.config.storage;
110
+ // Persist tokens to storage when HttpService refreshes them automatically
111
+ this.oxyServices.httpService.onTokenRefreshed = (accessToken) => {
112
+ this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
113
+ };
110
114
  }
111
115
  /**
112
116
  * Get default storage based on environment.
@@ -217,20 +221,53 @@ export class AuthManager {
217
221
  }
218
222
  }
219
223
  async _doRefreshToken() {
220
- const refreshToken = await this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
221
- if (!refreshToken) {
224
+ // Get session info to find sessionId for token refresh
225
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
226
+ if (!sessionJson) {
227
+ return false;
228
+ }
229
+ let sessionId;
230
+ try {
231
+ const session = JSON.parse(sessionJson);
232
+ sessionId = session.sessionId;
233
+ if (!sessionId)
234
+ return false;
235
+ }
236
+ catch (err) {
237
+ console.error('AuthManager: Failed to parse session from storage.', err);
222
238
  return false;
223
239
  }
224
240
  try {
225
241
  await retryAsync(async () => {
226
242
  const httpService = this.oxyServices.httpService;
243
+ // Use session-based token endpoint which handles auto-refresh server-side
227
244
  const response = await httpService.request({
228
- method: 'POST',
229
- url: '/api/auth/refresh',
230
- data: { refreshToken },
245
+ method: 'GET',
246
+ url: `/api/session/token/${sessionId}`,
231
247
  cache: false,
248
+ retry: false,
232
249
  });
233
- await this.handleAuthSuccess(response, 'credentials');
250
+ if (!response.accessToken) {
251
+ throw new Error('No access token in refresh response');
252
+ }
253
+ // Update access token in storage and HTTP client
254
+ await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
255
+ this.oxyServices.httpService.setTokens(response.accessToken);
256
+ // Update session expiry and schedule next refresh
257
+ if (response.expiresAt) {
258
+ try {
259
+ const session = JSON.parse(sessionJson);
260
+ session.expiresAt = response.expiresAt;
261
+ await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
262
+ }
263
+ catch (err) {
264
+ // Ignore parse errors for session update, but log for debugging.
265
+ console.error('AuthManager: Failed to re-save session after token refresh.', err);
266
+ }
267
+ if (this.config.autoRefresh) {
268
+ this.setupTokenRefresh(response.expiresAt);
269
+ }
270
+ }
234
271
  }, 2, // 2 retries = 3 total attempts
235
272
  1000, // 1s base delay with exponential backoff + jitter
236
273
  (error) => {
@@ -83,6 +83,7 @@ class TokenStore {
83
83
  export class HttpService {
84
84
  constructor(config) {
85
85
  this.tokenRefreshPromise = null;
86
+ this._onTokenRefreshed = null;
86
87
  // Performance monitoring
87
88
  this.requestMetrics = {
88
89
  totalRequests: 0,
@@ -225,7 +226,25 @@ export class HttpService {
225
226
  clearTimeout(timeoutId);
226
227
  // Handle response
227
228
  if (!response.ok) {
228
- if (response.status === 401) {
229
+ // On 401, attempt token refresh and retry once before giving up
230
+ if (response.status === 401 && !config._isAuthRetry) {
231
+ const currentToken = this.tokenStore.getAccessToken();
232
+ if (currentToken) {
233
+ try {
234
+ const decoded = jwtDecode(currentToken);
235
+ if (decoded.sessionId) {
236
+ const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
237
+ if (refreshResult) {
238
+ // Retry the request with the new token
239
+ return this.request({ ...config, _isAuthRetry: true, retry: false });
240
+ }
241
+ }
242
+ }
243
+ catch {
244
+ // Token decode failed, fall through to clear
245
+ }
246
+ }
247
+ // Refresh failed or no token — clear tokens
229
248
  this.tokenStore.clearTokens();
230
249
  }
231
250
  // Try to parse error response (handle empty/malformed JSON)
@@ -478,6 +497,7 @@ export class HttpService {
478
497
  if (response.ok) {
479
498
  const { accessToken: newToken } = await response.json();
480
499
  this.tokenStore.setTokens(newToken);
500
+ this._onTokenRefreshed?.(newToken);
481
501
  this.logger.debug('Token refreshed');
482
502
  return `Bearer ${newToken}`;
483
503
  }
@@ -584,6 +604,9 @@ export class HttpService {
584
604
  setTokens(accessToken, refreshToken = '') {
585
605
  this.tokenStore.setTokens(accessToken, refreshToken);
586
606
  }
607
+ set onTokenRefreshed(callback) {
608
+ this._onTokenRefreshed = callback;
609
+ }
587
610
  clearTokens() {
588
611
  this.tokenStore.clearTokens();
589
612
  this.tokenStore.clearCsrfToken();