@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.
- package/dist/cjs/AuthManager.js +43 -6
- package/dist/cjs/HttpService.js +24 -1
- package/dist/cjs/mixins/OxyServices.utility.js +225 -51
- package/dist/esm/AuthManager.js +43 -6
- package/dist/esm/HttpService.js +24 -1
- package/dist/esm/mixins/OxyServices.utility.js +225 -51
- package/dist/types/HttpService.d.ts +4 -0
- package/dist/types/OxyServices.d.ts +9 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +56 -21
- package/package.json +1 -1
- package/src/AuthManager.ts +47 -7
- package/src/HttpService.ts +31 -6
- package/src/OxyServices.ts +13 -0
- package/src/mixins/OxyServices.utility.ts +274 -82
|
@@ -26,122 +26,215 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Express.js authentication middleware
|
|
30
30
|
*
|
|
31
|
-
*
|
|
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
|
-
*
|
|
36
|
-
* app.use('/api/protected', oxyServices.auth());
|
|
36
|
+
* import { OxyServices } from '@oxyhq/core';
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
* app.use('/api/protected', oxyServices.auth({ debug: true }));
|
|
38
|
+
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
40
39
|
*
|
|
41
|
-
* //
|
|
42
|
-
* app.use('/api/protected',
|
|
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
|
-
* //
|
|
47
|
-
* app.
|
|
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,
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
134
|
-
|
|
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(
|
|
230
|
+
console.log(`[oxy.auth] OK user=${userId} (no session)`);
|
|
137
231
|
}
|
|
138
232
|
next();
|
|
139
233
|
}
|
|
140
234
|
catch (error) {
|
|
141
|
-
const apiError =
|
|
235
|
+
const apiError = oxyInstance.handleError(error);
|
|
142
236
|
if (debug) {
|
|
143
|
-
|
|
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
|
-
*
|
|
28
|
+
* Express.js authentication middleware
|
|
16
29
|
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
22
|
-
* app.use('/api/protected', oxyServices.auth());
|
|
35
|
+
* import { OxyServices } from '@oxyhq/core';
|
|
23
36
|
*
|
|
24
|
-
*
|
|
25
|
-
* app.use('/api/protected', oxyServices.auth({ debug: true }));
|
|
37
|
+
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
26
38
|
*
|
|
27
|
-
* //
|
|
28
|
-
* app.use('/api/protected',
|
|
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
|
-
* //
|
|
33
|
-
* app.
|
|
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
|
-
|
|
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
package/src/AuthManager.ts
CHANGED
|
@@ -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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
package/src/HttpService.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|