@oxyhq/services 5.16.22 → 5.16.24
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/lib/commonjs/core/HttpService.js +15 -79
- package/lib/commonjs/core/HttpService.js.map +1 -1
- package/lib/commonjs/core/OxyServices.base.js +3 -11
- package/lib/commonjs/core/OxyServices.base.js.map +1 -1
- package/lib/commonjs/core/services/SessionService.js +163 -0
- package/lib/commonjs/core/services/SessionService.js.map +1 -0
- package/lib/commonjs/core/services/TokenService.js +206 -0
- package/lib/commonjs/core/services/TokenService.js.map +1 -0
- package/lib/commonjs/models/interfaces.js +15 -0
- package/lib/commonjs/models/interfaces.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +100 -22
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +11 -3
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionSocket.js +228 -57
- package/lib/commonjs/ui/hooks/useSessionSocket.js.map +1 -1
- package/lib/module/core/HttpService.js +15 -79
- package/lib/module/core/HttpService.js.map +1 -1
- package/lib/module/core/OxyServices.base.js +4 -11
- package/lib/module/core/OxyServices.base.js.map +1 -1
- package/lib/module/core/services/SessionService.js +159 -0
- package/lib/module/core/services/SessionService.js.map +1 -0
- package/lib/module/core/services/TokenService.js +203 -0
- package/lib/module/core/services/TokenService.js.map +1 -0
- package/lib/module/models/interfaces.js +15 -0
- package/lib/module/models/interfaces.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +100 -22
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +11 -3
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/hooks/useSessionSocket.js +228 -57
- package/lib/module/ui/hooks/useSessionSocket.js.map +1 -1
- package/lib/typescript/core/HttpService.d.ts +1 -1
- package/lib/typescript/core/HttpService.d.ts.map +1 -1
- package/lib/typescript/core/OxyServices.base.d.ts +6 -0
- package/lib/typescript/core/OxyServices.base.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.analytics.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.assets.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.auth.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.developer.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.devices.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.karma.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.language.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.location.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.payment.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.privacy.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.security.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.user.d.ts +1 -3
- package/lib/typescript/core/mixins/OxyServices.user.d.ts.map +1 -1
- package/lib/typescript/core/mixins/OxyServices.utility.d.ts.map +1 -1
- package/lib/typescript/core/mixins/index.d.ts.map +1 -1
- package/lib/typescript/core/services/SessionService.d.ts +78 -0
- package/lib/typescript/core/services/SessionService.d.ts.map +1 -0
- package/lib/typescript/core/services/TokenService.d.ts +72 -0
- package/lib/typescript/core/services/TokenService.d.ts.map +1 -0
- package/lib/typescript/models/interfaces.d.ts +14 -0
- package/lib/typescript/models/interfaces.d.ts.map +1 -1
- package/lib/typescript/models/session.d.ts +7 -0
- package/lib/typescript/models/session.d.ts.map +1 -1
- package/lib/typescript/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/ui/hooks/useSessionSocket.d.ts +8 -1
- package/lib/typescript/ui/hooks/useSessionSocket.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/HttpService.ts +15 -95
- package/src/core/OxyServices.base.ts +3 -20
- package/src/core/services/SessionService.ts +173 -0
- package/src/core/services/TokenService.ts +226 -0
- package/src/models/interfaces.ts +16 -2
- package/src/models/session.ts +8 -1
- package/src/ui/context/OxyContext.tsx +105 -23
- package/src/ui/context/hooks/useAuthOperations.ts +11 -3
- package/src/ui/hooks/useSessionSocket.ts +229 -55
package/src/core/HttpService.ts
CHANGED
|
@@ -17,17 +17,8 @@ import { TTLCache, registerCacheForCleanup } from '../utils/cache';
|
|
|
17
17
|
import { RequestDeduplicator, RequestQueue, SimpleLogger } from '../utils/requestUtils';
|
|
18
18
|
import { retryAsync } from '../utils/asyncUtils';
|
|
19
19
|
import { handleHttpError } from '../utils/errorUtils';
|
|
20
|
-
import { jwtDecode } from 'jwt-decode';
|
|
21
20
|
import type { OxyConfig } from '../models/interfaces';
|
|
22
21
|
|
|
23
|
-
interface JwtPayload {
|
|
24
|
-
exp?: number;
|
|
25
|
-
userId?: string;
|
|
26
|
-
id?: string;
|
|
27
|
-
sessionId?: string;
|
|
28
|
-
[key: string]: any;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
22
|
export interface RequestOptions {
|
|
32
23
|
cache?: boolean;
|
|
33
24
|
cacheTTL?: number;
|
|
@@ -46,45 +37,8 @@ interface RequestConfig extends RequestOptions {
|
|
|
46
37
|
params?: Record<string, unknown>;
|
|
47
38
|
}
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*/
|
|
52
|
-
class TokenStore {
|
|
53
|
-
private static instance: TokenStore;
|
|
54
|
-
private accessToken: string | null = null;
|
|
55
|
-
private refreshToken: string | null = null;
|
|
56
|
-
|
|
57
|
-
private constructor() {}
|
|
58
|
-
|
|
59
|
-
static getInstance(): TokenStore {
|
|
60
|
-
if (!TokenStore.instance) {
|
|
61
|
-
TokenStore.instance = new TokenStore();
|
|
62
|
-
}
|
|
63
|
-
return TokenStore.instance;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
setTokens(accessToken: string, refreshToken = ''): void {
|
|
67
|
-
this.accessToken = accessToken;
|
|
68
|
-
this.refreshToken = refreshToken;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
getAccessToken(): string | null {
|
|
72
|
-
return this.accessToken;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
getRefreshToken(): string | null {
|
|
76
|
-
return this.refreshToken;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
clearTokens(): void {
|
|
80
|
-
this.accessToken = null;
|
|
81
|
-
this.refreshToken = null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
hasAccessToken(): boolean {
|
|
85
|
-
return !!this.accessToken;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
40
|
+
// Token management moved to TokenService - import it instead
|
|
41
|
+
import { tokenService } from './services/TokenService';
|
|
88
42
|
|
|
89
43
|
/**
|
|
90
44
|
* Unified HTTP Service
|
|
@@ -94,7 +48,6 @@ class TokenStore {
|
|
|
94
48
|
*/
|
|
95
49
|
export class HttpService {
|
|
96
50
|
private baseURL: string;
|
|
97
|
-
private tokenStore: TokenStore;
|
|
98
51
|
private cache: TTLCache<any>;
|
|
99
52
|
private deduplicator: RequestDeduplicator;
|
|
100
53
|
private requestQueue: RequestQueue;
|
|
@@ -114,7 +67,9 @@ export class HttpService {
|
|
|
114
67
|
constructor(config: OxyConfig) {
|
|
115
68
|
this.config = config;
|
|
116
69
|
this.baseURL = config.baseURL;
|
|
117
|
-
|
|
70
|
+
|
|
71
|
+
// Initialize TokenService with baseURL
|
|
72
|
+
tokenService.initialize(this.baseURL);
|
|
118
73
|
|
|
119
74
|
this.logger = new SimpleLogger(
|
|
120
75
|
config.enableLogging || false,
|
|
@@ -261,7 +216,7 @@ export class HttpService {
|
|
|
261
216
|
// Handle response
|
|
262
217
|
if (!response.ok) {
|
|
263
218
|
if (response.status === 401) {
|
|
264
|
-
|
|
219
|
+
tokenService.clearTokens();
|
|
265
220
|
}
|
|
266
221
|
|
|
267
222
|
// Try to parse error response (handle empty/malformed JSON)
|
|
@@ -415,45 +370,10 @@ export class HttpService {
|
|
|
415
370
|
|
|
416
371
|
/**
|
|
417
372
|
* Get auth header with automatic token refresh
|
|
373
|
+
* Uses TokenService for all token operations
|
|
418
374
|
*/
|
|
419
375
|
private async getAuthHeader(): Promise<string | null> {
|
|
420
|
-
|
|
421
|
-
if (!accessToken) {
|
|
422
|
-
return null;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
try {
|
|
426
|
-
const decoded = jwtDecode<JwtPayload>(accessToken);
|
|
427
|
-
const currentTime = Math.floor(Date.now() / 1000);
|
|
428
|
-
|
|
429
|
-
// If token expires in less than 60 seconds, refresh it
|
|
430
|
-
if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
|
|
431
|
-
try {
|
|
432
|
-
const refreshUrl = `${this.baseURL}/api/session/token/${decoded.sessionId}`;
|
|
433
|
-
|
|
434
|
-
// Use AbortSignal.timeout for consistent timeout handling
|
|
435
|
-
const response = await fetch(refreshUrl, {
|
|
436
|
-
method: 'GET',
|
|
437
|
-
headers: { 'Accept': 'application/json' },
|
|
438
|
-
signal: AbortSignal.timeout(5000),
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
if (response.ok) {
|
|
442
|
-
const { accessToken: newToken } = await response.json();
|
|
443
|
-
this.tokenStore.setTokens(newToken);
|
|
444
|
-
this.logger.debug('Token refreshed');
|
|
445
|
-
return `Bearer ${newToken}`;
|
|
446
|
-
}
|
|
447
|
-
} catch (refreshError) {
|
|
448
|
-
this.logger.warn('Token refresh failed, using current token');
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return `Bearer ${accessToken}`;
|
|
453
|
-
} catch (error) {
|
|
454
|
-
this.logger.error('Error processing token:', error);
|
|
455
|
-
return `Bearer ${accessToken}`;
|
|
456
|
-
}
|
|
376
|
+
return await tokenService.getAuthHeader();
|
|
457
377
|
}
|
|
458
378
|
|
|
459
379
|
/**
|
|
@@ -558,21 +478,21 @@ export class HttpService {
|
|
|
558
478
|
return { data: result as T };
|
|
559
479
|
}
|
|
560
480
|
|
|
561
|
-
// Token management
|
|
481
|
+
// Token management - delegates to TokenService
|
|
562
482
|
setTokens(accessToken: string, refreshToken = ''): void {
|
|
563
|
-
|
|
483
|
+
tokenService.setTokens(accessToken, refreshToken);
|
|
564
484
|
}
|
|
565
485
|
|
|
566
486
|
clearTokens(): void {
|
|
567
|
-
|
|
487
|
+
tokenService.clearTokens();
|
|
568
488
|
}
|
|
569
489
|
|
|
570
490
|
getAccessToken(): string | null {
|
|
571
|
-
return
|
|
491
|
+
return tokenService.getAccessToken();
|
|
572
492
|
}
|
|
573
493
|
|
|
574
494
|
hasAccessToken(): boolean {
|
|
575
|
-
return
|
|
495
|
+
return tokenService.hasAccessToken();
|
|
576
496
|
}
|
|
577
497
|
|
|
578
498
|
getBaseURL(): string {
|
|
@@ -606,10 +526,10 @@ export class HttpService {
|
|
|
606
526
|
// Test-only utility
|
|
607
527
|
static __resetTokensForTests(): void {
|
|
608
528
|
try {
|
|
609
|
-
|
|
529
|
+
tokenService.clearTokens();
|
|
610
530
|
} catch (error) {
|
|
611
531
|
// Silently fail in test cleanup - this is expected behavior
|
|
612
|
-
//
|
|
532
|
+
// TokenService might not be initialized in some test scenarios
|
|
613
533
|
}
|
|
614
534
|
}
|
|
615
535
|
}
|
|
@@ -3,24 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Contains core infrastructure, HTTP client, request management, and error handling
|
|
5
5
|
*/
|
|
6
|
-
import { jwtDecode } from 'jwt-decode';
|
|
7
6
|
import type { OxyConfig as OxyConfigBase, ApiError, User } from '../models/interfaces';
|
|
8
7
|
import { handleHttpError } from '../utils/errorUtils';
|
|
9
8
|
import { HttpService, type RequestOptions } from './HttpService';
|
|
10
9
|
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
|
|
10
|
+
import { tokenService } from './services/TokenService';
|
|
11
11
|
|
|
12
12
|
export interface OxyConfig extends OxyConfigBase {
|
|
13
13
|
cloudURL?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
interface JwtPayload {
|
|
17
|
-
exp?: number;
|
|
18
|
-
userId?: string;
|
|
19
|
-
id?: string;
|
|
20
|
-
sessionId?: string;
|
|
21
|
-
[key: string]: any;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
16
|
/**
|
|
25
17
|
* Base class for OxyServices with core infrastructure
|
|
26
18
|
*/
|
|
@@ -135,19 +127,10 @@ export class OxyServicesBase {
|
|
|
135
127
|
|
|
136
128
|
/**
|
|
137
129
|
* Get the current user ID from the access token
|
|
130
|
+
* Returns MongoDB ObjectId (never publicKey)
|
|
138
131
|
*/
|
|
139
132
|
public getCurrentUserId(): string | null {
|
|
140
|
-
|
|
141
|
-
if (!accessToken) {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const decoded = jwtDecode<JwtPayload>(accessToken);
|
|
147
|
-
return decoded.userId || decoded.id || null;
|
|
148
|
-
} catch (error) {
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
133
|
+
return tokenService.getUserIdFromToken();
|
|
151
134
|
}
|
|
152
135
|
|
|
153
136
|
/**
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionService - Single Source of Truth for Session Management
|
|
3
|
+
*
|
|
4
|
+
* Handles all session operations: creation, validation, refresh, invalidation.
|
|
5
|
+
* Manages active session state and provides session data to other services.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Single source of truth for session operations
|
|
9
|
+
* - Handles both online and offline sessions
|
|
10
|
+
* - Integrates with TokenService for token management
|
|
11
|
+
* - userId is always MongoDB ObjectId, never publicKey
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OxyServices } from '../OxyServices';
|
|
15
|
+
import { tokenService } from './TokenService';
|
|
16
|
+
import type { ClientSession, SessionLoginResponse } from '../../models/session';
|
|
17
|
+
import type { User } from '../../models/interfaces';
|
|
18
|
+
|
|
19
|
+
export interface Session {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
deviceId: string;
|
|
22
|
+
userId: string; // MongoDB ObjectId - PRIMARY IDENTIFIER
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
lastActive: string;
|
|
25
|
+
isCurrent: boolean;
|
|
26
|
+
isOffline?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SessionService - Singleton pattern for global session management
|
|
31
|
+
*/
|
|
32
|
+
class SessionService {
|
|
33
|
+
private static instance: SessionService;
|
|
34
|
+
private oxyServices: OxyServices | null = null;
|
|
35
|
+
private activeSession: Session | null = null;
|
|
36
|
+
private sessions: Session[] = [];
|
|
37
|
+
|
|
38
|
+
private constructor() {}
|
|
39
|
+
|
|
40
|
+
static getInstance(): SessionService {
|
|
41
|
+
if (!SessionService.instance) {
|
|
42
|
+
SessionService.instance = new SessionService();
|
|
43
|
+
}
|
|
44
|
+
return SessionService.instance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Initialize SessionService with OxyServices instance
|
|
49
|
+
*/
|
|
50
|
+
initialize(oxyServices: OxyServices): void {
|
|
51
|
+
this.oxyServices = oxyServices;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get active session
|
|
56
|
+
*/
|
|
57
|
+
getActiveSession(): Session | null {
|
|
58
|
+
return this.activeSession;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get all sessions
|
|
63
|
+
*/
|
|
64
|
+
getAllSessions(): Session[] {
|
|
65
|
+
return [...this.sessions];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a new session (sign in)
|
|
70
|
+
* @param publicKey - User's public key for authentication
|
|
71
|
+
* @returns User object and session data
|
|
72
|
+
*/
|
|
73
|
+
async createSession(publicKey: string): Promise<{ user: User; session: Session }> {
|
|
74
|
+
if (!this.oxyServices) {
|
|
75
|
+
throw new Error('SessionService not initialized with OxyServices');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// This will be implemented by delegating to existing sign-in logic
|
|
79
|
+
// For now, this is a placeholder that shows the interface
|
|
80
|
+
throw new Error('SessionService.createSession not yet implemented - use existing signIn flow');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Refresh current session
|
|
85
|
+
*/
|
|
86
|
+
async refreshSession(): Promise<Session> {
|
|
87
|
+
if (!this.activeSession) {
|
|
88
|
+
throw new Error('No active session to refresh');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Refresh token first
|
|
92
|
+
await tokenService.refreshTokenIfNeeded();
|
|
93
|
+
|
|
94
|
+
// Then refresh session data from server
|
|
95
|
+
// Implementation will be added
|
|
96
|
+
return this.activeSession;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate current session
|
|
101
|
+
*/
|
|
102
|
+
async validateSession(): Promise<boolean> {
|
|
103
|
+
if (!this.activeSession) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check if session expired
|
|
108
|
+
if (new Date(this.activeSession.expiresAt) < new Date()) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if token is valid
|
|
113
|
+
const token = tokenService.getAccessToken();
|
|
114
|
+
if (!token) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Additional validation can be added here
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Invalidate current session (sign out)
|
|
124
|
+
*/
|
|
125
|
+
async invalidateSession(): Promise<void> {
|
|
126
|
+
if (!this.activeSession || !this.oxyServices) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Call API to invalidate session on server
|
|
132
|
+
await this.oxyServices.makeRequest('POST', `/api/session/${this.activeSession.sessionId}/logout`, undefined, { cache: false });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// Continue with local cleanup even if API call fails
|
|
135
|
+
console.warn('Failed to invalidate session on server:', error);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clear tokens
|
|
139
|
+
tokenService.clearTokens();
|
|
140
|
+
|
|
141
|
+
// Clear active session
|
|
142
|
+
this.activeSession = null;
|
|
143
|
+
this.sessions = this.sessions.filter(s => s.sessionId !== this.activeSession?.sessionId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Set active session (internal use)
|
|
148
|
+
*/
|
|
149
|
+
setActiveSession(session: Session): void {
|
|
150
|
+
this.activeSession = session;
|
|
151
|
+
|
|
152
|
+
// Update sessions list
|
|
153
|
+
const existingIndex = this.sessions.findIndex(s => s.sessionId === session.sessionId);
|
|
154
|
+
if (existingIndex >= 0) {
|
|
155
|
+
this.sessions[existingIndex] = session;
|
|
156
|
+
} else {
|
|
157
|
+
this.sessions.push(session);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Clear all sessions (logout all)
|
|
163
|
+
*/
|
|
164
|
+
clearAllSessions(): void {
|
|
165
|
+
this.activeSession = null;
|
|
166
|
+
this.sessions = [];
|
|
167
|
+
tokenService.clearTokens();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Export singleton instance
|
|
172
|
+
export const sessionService = SessionService.getInstance();
|
|
173
|
+
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TokenService - Single Source of Truth for Token Management
|
|
3
|
+
*
|
|
4
|
+
* Handles all token storage, retrieval, refresh, and validation.
|
|
5
|
+
* Used by HttpService, SocketService, and other services that need tokens.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Single storage location (no duplication)
|
|
9
|
+
* - Automatic token refresh when expiring soon
|
|
10
|
+
* - Type-safe token payload handling
|
|
11
|
+
* - userId is always MongoDB ObjectId, never publicKey
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { jwtDecode } from 'jwt-decode';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* AccessTokenPayload - Matches the token payload structure from API
|
|
18
|
+
* userId is always MongoDB ObjectId (24 hex characters), never publicKey
|
|
19
|
+
*/
|
|
20
|
+
interface AccessTokenPayload {
|
|
21
|
+
userId: string; // MongoDB ObjectId - PRIMARY IDENTIFIER
|
|
22
|
+
sessionId: string; // Session UUID
|
|
23
|
+
deviceId: string; // Device identifier
|
|
24
|
+
type: 'access';
|
|
25
|
+
iat?: number; // Issued at (added by JWT)
|
|
26
|
+
exp?: number; // Expiration (added by JWT)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TokenStore {
|
|
30
|
+
accessToken: string | null;
|
|
31
|
+
refreshToken: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* TokenService - Singleton pattern for global token management
|
|
36
|
+
*/
|
|
37
|
+
class TokenService {
|
|
38
|
+
private static instance: TokenService;
|
|
39
|
+
private tokenStore: TokenStore = {
|
|
40
|
+
accessToken: null,
|
|
41
|
+
refreshToken: null,
|
|
42
|
+
};
|
|
43
|
+
private refreshPromise: Promise<void> | null = null;
|
|
44
|
+
private baseURL: string | null = null;
|
|
45
|
+
|
|
46
|
+
private constructor() {}
|
|
47
|
+
|
|
48
|
+
static getInstance(): TokenService {
|
|
49
|
+
if (!TokenService.instance) {
|
|
50
|
+
TokenService.instance = new TokenService();
|
|
51
|
+
}
|
|
52
|
+
return TokenService.instance;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Initialize TokenService with base URL for refresh requests
|
|
57
|
+
*/
|
|
58
|
+
initialize(baseURL: string): void {
|
|
59
|
+
this.baseURL = baseURL;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get current access token
|
|
64
|
+
*/
|
|
65
|
+
getAccessToken(): string | null {
|
|
66
|
+
return this.tokenStore.accessToken;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get current refresh token
|
|
71
|
+
*/
|
|
72
|
+
getRefreshToken(): string | null {
|
|
73
|
+
return this.tokenStore.refreshToken;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Set tokens (called after login or token refresh)
|
|
78
|
+
*/
|
|
79
|
+
setTokens(accessToken: string, refreshToken: string = ''): void {
|
|
80
|
+
this.tokenStore.accessToken = accessToken;
|
|
81
|
+
this.tokenStore.refreshToken = refreshToken || this.tokenStore.refreshToken;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clear all tokens (called on logout)
|
|
86
|
+
*/
|
|
87
|
+
clearTokens(): void {
|
|
88
|
+
this.tokenStore.accessToken = null;
|
|
89
|
+
this.tokenStore.refreshToken = null;
|
|
90
|
+
this.refreshPromise = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if access token exists
|
|
95
|
+
*/
|
|
96
|
+
hasAccessToken(): boolean {
|
|
97
|
+
return !!this.tokenStore.accessToken;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if token is expiring soon (within 60 seconds)
|
|
102
|
+
*/
|
|
103
|
+
isTokenExpiringSoon(): boolean {
|
|
104
|
+
const token = this.tokenStore.accessToken;
|
|
105
|
+
if (!token) return false;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const decoded = jwtDecode<AccessTokenPayload>(token);
|
|
109
|
+
if (!decoded.exp) return false;
|
|
110
|
+
|
|
111
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
112
|
+
return decoded.exp - currentTime < 60; // Expiring within 60 seconds
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get userId from current access token
|
|
120
|
+
* Returns MongoDB ObjectId (never publicKey)
|
|
121
|
+
*/
|
|
122
|
+
getUserIdFromToken(): string | null {
|
|
123
|
+
const token = this.tokenStore.accessToken;
|
|
124
|
+
if (!token) return null;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const decoded = jwtDecode<AccessTokenPayload>(token);
|
|
128
|
+
return decoded.userId || null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Refresh access token if expiring soon
|
|
136
|
+
* Returns promise that resolves when token is refreshed (or already valid)
|
|
137
|
+
*/
|
|
138
|
+
async refreshTokenIfNeeded(): Promise<void> {
|
|
139
|
+
// If already refreshing, wait for that promise
|
|
140
|
+
if (this.refreshPromise) {
|
|
141
|
+
return this.refreshPromise;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// If token not expiring soon, no refresh needed
|
|
145
|
+
if (!this.isTokenExpiringSoon()) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Start refresh
|
|
150
|
+
this.refreshPromise = this._performRefresh();
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await this.refreshPromise;
|
|
154
|
+
} finally {
|
|
155
|
+
this.refreshPromise = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Perform token refresh
|
|
161
|
+
*/
|
|
162
|
+
private async _performRefresh(): Promise<void> {
|
|
163
|
+
const token = this.tokenStore.accessToken;
|
|
164
|
+
if (!token) {
|
|
165
|
+
throw new Error('No access token to refresh');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const decoded = jwtDecode<AccessTokenPayload>(token);
|
|
170
|
+
if (!decoded.sessionId) {
|
|
171
|
+
throw new Error('Token missing sessionId');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!this.baseURL) {
|
|
175
|
+
throw new Error('TokenService not initialized with baseURL');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const refreshUrl = `${this.baseURL}/api/session/token/${decoded.sessionId}`;
|
|
179
|
+
|
|
180
|
+
const response = await fetch(refreshUrl, {
|
|
181
|
+
method: 'GET',
|
|
182
|
+
headers: { 'Accept': 'application/json' },
|
|
183
|
+
signal: AbortSignal.timeout(5000),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { accessToken: newToken } = await response.json();
|
|
191
|
+
|
|
192
|
+
if (!newToken) {
|
|
193
|
+
throw new Error('No access token in refresh response');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate new token has correct userId format (ObjectId)
|
|
197
|
+
const newDecoded = jwtDecode<AccessTokenPayload>(newToken);
|
|
198
|
+
if (newDecoded.userId && !/^[0-9a-fA-F]{24}$/.test(newDecoded.userId)) {
|
|
199
|
+
throw new Error(`Invalid userId format in refreshed token: ${newDecoded.userId.substring(0, 20)}...`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.setTokens(newToken);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Clear tokens on refresh failure (likely expired or invalid)
|
|
205
|
+
this.clearTokens();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get authorization header with automatic refresh
|
|
212
|
+
*/
|
|
213
|
+
async getAuthHeader(): Promise<string | null> {
|
|
214
|
+
// Refresh if needed
|
|
215
|
+
await this.refreshTokenIfNeeded().catch(() => {
|
|
216
|
+
// Ignore refresh errors, will use current token or return null
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const token = this.tokenStore.accessToken;
|
|
220
|
+
return token ? `Bearer ${token}` : null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Export singleton instance
|
|
225
|
+
export const tokenService = TokenService.getInstance();
|
|
226
|
+
|
package/src/models/interfaces.ts
CHANGED
|
@@ -21,9 +21,23 @@ export interface OxyConfig {
|
|
|
21
21
|
onRequestError?: (url: string, method: string, error: Error) => void;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* User Model
|
|
26
|
+
*
|
|
27
|
+
* IMPORTANT:
|
|
28
|
+
* - id: MongoDB ObjectId (24 hex characters) - PRIMARY IDENTIFIER for all internal operations
|
|
29
|
+
* - publicKey: Cryptographic public key (130 hex characters) - LOOKUP KEY for authentication and identity operations
|
|
30
|
+
*
|
|
31
|
+
* Never use publicKey as an ID. Always use id (ObjectId) for:
|
|
32
|
+
* - Database queries
|
|
33
|
+
* - Session userId
|
|
34
|
+
* - Token userId
|
|
35
|
+
* - Socket room names
|
|
36
|
+
* - API route parameters (unless explicitly doing publicKey lookup)
|
|
37
|
+
*/
|
|
24
38
|
export interface User {
|
|
25
|
-
id: string;
|
|
26
|
-
publicKey: string;
|
|
39
|
+
id: string; // MongoDB ObjectId - PRIMARY IDENTIFIER (always 24 hex chars)
|
|
40
|
+
publicKey: string; // Cryptographic public key - LOOKUP KEY (130 hex chars for secp256k1)
|
|
27
41
|
username: string;
|
|
28
42
|
email?: string;
|
|
29
43
|
// Avatar file id (asset id)
|
package/src/models/session.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Session Model
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT:
|
|
5
|
+
* - userId: MongoDB ObjectId (24 hex characters), never publicKey
|
|
6
|
+
* - Used for session management and user identification
|
|
7
|
+
*/
|
|
1
8
|
export interface ClientSession {
|
|
2
9
|
sessionId: string;
|
|
3
10
|
deviceId: string;
|
|
4
11
|
expiresAt: string;
|
|
5
12
|
lastActive: string;
|
|
6
|
-
userId?: string;
|
|
13
|
+
userId?: string; // MongoDB ObjectId - PRIMARY IDENTIFIER (never publicKey)
|
|
7
14
|
isCurrent?: boolean;
|
|
8
15
|
}
|
|
9
16
|
|