@plyaz/auth 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/pull_request_template.md +71 -0
- package/.github/workflows/deploy.yml +9 -0
- package/.github/workflows/publish.yml +14 -0
- package/.github/workflows/security.yml +20 -0
- package/README.md +89 -0
- package/commits.txt +5 -0
- package/dist/common/index.cjs +48 -0
- package/dist/common/index.cjs.map +1 -0
- package/dist/common/index.mjs +43 -0
- package/dist/common/index.mjs.map +1 -0
- package/dist/index.cjs +20411 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +5139 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.mjs +13 -0
- package/index.html +13 -0
- package/package.json +141 -0
- package/src/adapters/auth-adapter-factory.ts +26 -0
- package/src/adapters/auth-adapter.mapper.ts +53 -0
- package/src/adapters/base-auth.adapter.ts +119 -0
- package/src/adapters/clerk/clerk.adapter.ts +204 -0
- package/src/adapters/custom/custom.adapter.ts +119 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/next-auth/authOptions.ts +81 -0
- package/src/adapters/next-auth/next-auth.adapter.ts +211 -0
- package/src/api/client.ts +37 -0
- package/src/audit/audit.logger.ts +52 -0
- package/src/client/components/ProtectedRoute.tsx +37 -0
- package/src/client/hooks/useAuth.ts +128 -0
- package/src/client/hooks/useConnectedAccounts.ts +108 -0
- package/src/client/hooks/usePermissions.ts +36 -0
- package/src/client/hooks/useRBAC.ts +36 -0
- package/src/client/hooks/useSession.ts +18 -0
- package/src/client/providers/AuthProvider.tsx +104 -0
- package/src/client/store/auth.store.ts +306 -0
- package/src/client/utils/storage.ts +70 -0
- package/src/common/constants/oauth-providers.ts +49 -0
- package/src/common/errors/auth.errors.ts +64 -0
- package/src/common/errors/specific-auth-errors.ts +201 -0
- package/src/common/index.ts +19 -0
- package/src/common/regex/index.ts +27 -0
- package/src/common/types/auth.types.ts +641 -0
- package/src/common/types/index.ts +297 -0
- package/src/common/utils/index.ts +84 -0
- package/src/core/blacklist/token.blacklist.ts +60 -0
- package/src/core/index.ts +2 -0
- package/src/core/jwt/jwt.manager.ts +131 -0
- package/src/core/session/session.manager.ts +56 -0
- package/src/db/repositories/connected-account.repository.ts +415 -0
- package/src/db/repositories/role.repository.ts +519 -0
- package/src/db/repositories/session.repository.ts +308 -0
- package/src/db/repositories/user.repository.ts +320 -0
- package/src/flows/index.ts +2 -0
- package/src/flows/sign-in.flow.ts +106 -0
- package/src/flows/sign-up.flow.ts +121 -0
- package/src/index.ts +54 -0
- package/src/libs/clerk.helper.ts +36 -0
- package/src/libs/supabase.helper.ts +255 -0
- package/src/libs/supabaseClient.ts +6 -0
- package/src/providers/base/auth-provider.interface.ts +42 -0
- package/src/providers/base/index.ts +1 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/oauth/facebook.provider.ts +97 -0
- package/src/providers/oauth/github.provider.ts +148 -0
- package/src/providers/oauth/google.provider.ts +126 -0
- package/src/providers/oauth/index.ts +3 -0
- package/src/rbac/dynamic-roles.ts +552 -0
- package/src/rbac/index.ts +4 -0
- package/src/rbac/permission-checker.ts +464 -0
- package/src/rbac/role-hierarchy.ts +545 -0
- package/src/rbac/role.manager.ts +75 -0
- package/src/security/csrf/csrf.protection.ts +37 -0
- package/src/security/index.ts +3 -0
- package/src/security/rate-limiting/auth/auth.controller.ts +12 -0
- package/src/security/rate-limiting/auth/rate-limiting.interface.ts +67 -0
- package/src/security/rate-limiting/auth.module.ts +32 -0
- package/src/server/auth.module.ts +158 -0
- package/src/server/decorators/auth.decorator.ts +43 -0
- package/src/server/decorators/auth.decorators.ts +31 -0
- package/src/server/decorators/current-user.decorator.ts +49 -0
- package/src/server/decorators/permission.decorator.ts +49 -0
- package/src/server/guards/auth.guard.ts +56 -0
- package/src/server/guards/custom-throttler.guard.ts +46 -0
- package/src/server/guards/permissions.guard.ts +115 -0
- package/src/server/guards/roles.guard.ts +31 -0
- package/src/server/middleware/auth.middleware.ts +46 -0
- package/src/server/middleware/index.ts +2 -0
- package/src/server/middleware/middleware.ts +11 -0
- package/src/server/middleware/session.middleware.ts +255 -0
- package/src/server/services/account.service.ts +269 -0
- package/src/server/services/auth.service.ts +79 -0
- package/src/server/services/brute-force.service.ts +98 -0
- package/src/server/services/index.ts +15 -0
- package/src/server/services/rate-limiter.service.ts +60 -0
- package/src/server/services/session.service.ts +287 -0
- package/src/server/services/token.service.ts +262 -0
- package/src/session/cookie-store.ts +255 -0
- package/src/session/enhanced-session-manager.ts +406 -0
- package/src/session/index.ts +14 -0
- package/src/session/memory-store.ts +320 -0
- package/src/session/redis-store.ts +443 -0
- package/src/strategies/oauth.strategy.ts +128 -0
- package/src/strategies/traditional-auth.strategy.ts +116 -0
- package/src/tokens/index.ts +4 -0
- package/src/tokens/refresh-token-manager.ts +448 -0
- package/src/tokens/token-validator.ts +311 -0
- package/tsconfig.build.json +28 -0
- package/tsconfig.json +38 -0
- package/tsup.config.mjs +28 -0
- package/vitest.config.mjs +16 -0
- package/vitest.setup.d.ts +2 -0
- package/vitest.setup.d.ts.map +1 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Redis-based session store for @plyaz/auth
|
|
3
|
+
* @module @plyaz/auth/session/redis-store
|
|
4
|
+
*
|
|
5
|
+
* @description
|
|
6
|
+
* Implements session storage using Redis. Provides persistent, scalable
|
|
7
|
+
* session management with automatic expiration and clustering support.
|
|
8
|
+
* Suitable for production applications requiring session persistence
|
|
9
|
+
* across server restarts and horizontal scaling.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { RedisStore } from '@plyaz/auth';
|
|
14
|
+
*
|
|
15
|
+
* const store = new RedisStore({
|
|
16
|
+
* host: 'localhost',
|
|
17
|
+
* port: 6379,
|
|
18
|
+
* keyPrefix: 'auth:session:'
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* await store.set('session_123', sessionData, 3600);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { NUMERIX } from "@plyaz/config";
|
|
26
|
+
import type { SessionData, SessionStore, SessionStoreConfig } from "@plyaz/types";
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Redis connection configuration
|
|
31
|
+
*/
|
|
32
|
+
export interface RedisConfig {
|
|
33
|
+
/** Redis host */
|
|
34
|
+
host: string;
|
|
35
|
+
/** Redis port */
|
|
36
|
+
port: number;
|
|
37
|
+
/** Redis password */
|
|
38
|
+
password?: string;
|
|
39
|
+
/** Redis database number */
|
|
40
|
+
db?: number;
|
|
41
|
+
/** Connection timeout in milliseconds */
|
|
42
|
+
connectTimeout?: number;
|
|
43
|
+
/** Command timeout in milliseconds */
|
|
44
|
+
commandTimeout?: number;
|
|
45
|
+
/** Enable TLS */
|
|
46
|
+
tls?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Redis store configuration
|
|
51
|
+
*/
|
|
52
|
+
export interface RedisStoreConfig extends Partial<SessionStoreConfig> {
|
|
53
|
+
/** Redis connection configuration */
|
|
54
|
+
redis: RedisConfig;
|
|
55
|
+
/** Enable compression for large sessions */
|
|
56
|
+
compression?: boolean;
|
|
57
|
+
/** Serialization format */
|
|
58
|
+
serialization?: 'json' | 'msgpack';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mock Redis client interface for development
|
|
63
|
+
* In production, this would be replaced with actual Redis client (ioredis, node-redis, etc.)
|
|
64
|
+
*/
|
|
65
|
+
interface MockRedisClient {
|
|
66
|
+
set(key: string, value: string, ex: number): Promise<string>;
|
|
67
|
+
get(key: string): Promise<string | null>;
|
|
68
|
+
del(key: string): Promise<number>;
|
|
69
|
+
keys(pattern: string): Promise<string[]>;
|
|
70
|
+
expire(key: string, seconds: number): Promise<number>;
|
|
71
|
+
exists(key: string): Promise<number>;
|
|
72
|
+
scan(cursor: string, match?: string, count?: number): Promise<[string, string[]]>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Redis-based session store implementation
|
|
77
|
+
* Stores session data in Redis with automatic expiration
|
|
78
|
+
*/
|
|
79
|
+
export class RedisStore implements SessionStore {
|
|
80
|
+
private readonly config: Required<RedisStoreConfig>;
|
|
81
|
+
private readonly client: MockRedisClient;
|
|
82
|
+
private readonly userSessionsKey = 'user_sessions:';
|
|
83
|
+
|
|
84
|
+
constructor(config: RedisStoreConfig) {
|
|
85
|
+
this.config = {
|
|
86
|
+
defaultTTL: 3600,
|
|
87
|
+
maxSessionsPerUser: 5,
|
|
88
|
+
cleanupInterval: 300,
|
|
89
|
+
keyPrefix: 'session:',
|
|
90
|
+
compression: false,
|
|
91
|
+
serialization: 'json',
|
|
92
|
+
...config
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Initialize mock Redis client
|
|
96
|
+
// In production, this would be: new Redis(config.redis)
|
|
97
|
+
this.client = this.createMockRedisClient();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Store session data in Redis
|
|
102
|
+
* @param sessionId - Session identifier
|
|
103
|
+
* @param data - Session data to store
|
|
104
|
+
* @param ttlSeconds - Time to live in seconds
|
|
105
|
+
*/
|
|
106
|
+
async set(sessionId: string, data: SessionData, ttlSeconds: number): Promise<void> {
|
|
107
|
+
const key = this.getSessionKey(sessionId);
|
|
108
|
+
const userSessionsKey = this.getUserSessionsKey(data.userId);
|
|
109
|
+
|
|
110
|
+
// Enforce session limits per user
|
|
111
|
+
await this.enforceSessionLimits(data.userId);
|
|
112
|
+
|
|
113
|
+
// Serialize session data
|
|
114
|
+
const serializedData = this.serialize(data);
|
|
115
|
+
|
|
116
|
+
// Store session with expiration
|
|
117
|
+
await this.client.set(key, serializedData, ttlSeconds);
|
|
118
|
+
|
|
119
|
+
// Track user sessions (with longer TTL for cleanup)
|
|
120
|
+
const userSessionsTTL = Math.max(ttlSeconds, this.config.defaultTTL);
|
|
121
|
+
await this.client.set(`${userSessionsKey}${sessionId}`, '1', userSessionsTTL);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Retrieve session data from Redis
|
|
126
|
+
* @param sessionId - Session identifier
|
|
127
|
+
* @returns Session data or null if not found/expired
|
|
128
|
+
*/
|
|
129
|
+
async get(sessionId: string): Promise<SessionData | null> {
|
|
130
|
+
const key = this.getSessionKey(sessionId);
|
|
131
|
+
const serializedData = await this.client.get(key);
|
|
132
|
+
|
|
133
|
+
if (!serializedData) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const data = this.deserialize(serializedData);
|
|
139
|
+
|
|
140
|
+
// Double-check expiration (Redis should handle this, but be safe)
|
|
141
|
+
if (data.expiresAt < new Date()) {
|
|
142
|
+
await this.delete(sessionId);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return data;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// If deserialization fails, delete the corrupted session
|
|
149
|
+
globalThis.console.log("error",error)
|
|
150
|
+
await this.delete(sessionId);
|
|
151
|
+
return null ;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Delete session from Redis
|
|
157
|
+
* @param sessionId - Session identifier
|
|
158
|
+
*/
|
|
159
|
+
async delete(sessionId: string): Promise<void> {
|
|
160
|
+
const key = this.getSessionKey(sessionId);
|
|
161
|
+
|
|
162
|
+
// Get session data to find user ID
|
|
163
|
+
const data = await this.get(sessionId);
|
|
164
|
+
|
|
165
|
+
// Delete session
|
|
166
|
+
await this.client.del(key);
|
|
167
|
+
|
|
168
|
+
// Remove from user sessions tracking
|
|
169
|
+
if (data) {
|
|
170
|
+
const userSessionKey = `${this.getUserSessionsKey(data.userId)}${sessionId}`;
|
|
171
|
+
await this.client.del(userSessionKey);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete all sessions for a user
|
|
177
|
+
* @param userId - User identifier
|
|
178
|
+
* @returns Number of deleted sessions
|
|
179
|
+
*/
|
|
180
|
+
async deleteByUserId(userId: string): Promise<number> {
|
|
181
|
+
const userSessionsPattern = `${this.getUserSessionsKey(userId)}*`;
|
|
182
|
+
const userSessionKeys = await this.client.keys(userSessionsPattern);
|
|
183
|
+
|
|
184
|
+
let deletedCount = 0;
|
|
185
|
+
|
|
186
|
+
for (const userSessionKey of userSessionKeys) {
|
|
187
|
+
// Extract session ID from key
|
|
188
|
+
const sessionId = userSessionKey.replace(this.getUserSessionsKey(userId), '');
|
|
189
|
+
const sessionKey = this.getSessionKey(sessionId);
|
|
190
|
+
|
|
191
|
+
// Delete both session and user session tracking
|
|
192
|
+
const sessionDeleted = await this.client.del(sessionKey);
|
|
193
|
+
await this.client.del(userSessionKey);
|
|
194
|
+
|
|
195
|
+
if (sessionDeleted > 0) {
|
|
196
|
+
deletedCount++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return deletedCount;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Update session activity timestamp
|
|
205
|
+
* @param sessionId - Session identifier
|
|
206
|
+
*/
|
|
207
|
+
async updateActivity(sessionId: string): Promise<void> {
|
|
208
|
+
const data = await this.get(sessionId);
|
|
209
|
+
|
|
210
|
+
if (data) {
|
|
211
|
+
data.lastActivityAt = new Date();
|
|
212
|
+
|
|
213
|
+
// Calculate remaining TTL
|
|
214
|
+
const remainingTTL = Math.max(0, Math.floor((data.expiresAt.getTime() - Date.now()) / NUMERIX.THOUSAND));
|
|
215
|
+
|
|
216
|
+
if (remainingTTL > 0) {
|
|
217
|
+
await this.set(sessionId, data, remainingTTL);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Check if session exists in Redis
|
|
224
|
+
* @param sessionId - Session identifier
|
|
225
|
+
* @returns True if session exists
|
|
226
|
+
*/
|
|
227
|
+
async exists(sessionId: string): Promise<boolean> {
|
|
228
|
+
const key = this.getSessionKey(sessionId);
|
|
229
|
+
const exists = await this.client.exists(key);
|
|
230
|
+
return exists > 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get all active sessions for a user
|
|
235
|
+
* @param userId - User identifier
|
|
236
|
+
* @returns Array of session data
|
|
237
|
+
*/
|
|
238
|
+
async getByUserId(userId: string): Promise<SessionData[]> {
|
|
239
|
+
const userSessionsPattern = `${this.getUserSessionsKey(userId)}*`;
|
|
240
|
+
const userSessionKeys = await this.client.keys(userSessionsPattern);
|
|
241
|
+
|
|
242
|
+
const sessions: SessionData[] = [];
|
|
243
|
+
|
|
244
|
+
for (const userSessionKey of userSessionKeys) {
|
|
245
|
+
// Extract session ID from key
|
|
246
|
+
const sessionId = userSessionKey.replace(this.getUserSessionsKey(userId), '');
|
|
247
|
+
const sessionData = await this.get(sessionId);
|
|
248
|
+
|
|
249
|
+
if (sessionData) {
|
|
250
|
+
sessions.push(sessionData);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return sessions;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clean up expired sessions (Redis handles this automatically, but useful for stats)
|
|
259
|
+
* @returns Number of cleaned sessions (always 0 for Redis due to auto-expiration)
|
|
260
|
+
*/
|
|
261
|
+
async cleanup(): Promise<number> {
|
|
262
|
+
// Redis automatically expires keys, so this is mainly for compatibility
|
|
263
|
+
// Could be used to clean up orphaned user session tracking keys
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get session count for a user
|
|
269
|
+
* @param userId - User identifier
|
|
270
|
+
* @returns Session count
|
|
271
|
+
*/
|
|
272
|
+
async getSessionCount(userId: string): Promise<number> {
|
|
273
|
+
const sessions = await this.getByUserId(userId);
|
|
274
|
+
return sessions.length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get Redis connection statistics
|
|
279
|
+
* @returns Connection and usage statistics
|
|
280
|
+
*/
|
|
281
|
+
async getStats(): Promise<{
|
|
282
|
+
connected: boolean;
|
|
283
|
+
totalSessions: number;
|
|
284
|
+
memoryUsage: string;
|
|
285
|
+
}> {
|
|
286
|
+
// Mock implementation - in production would use Redis INFO command
|
|
287
|
+
return {
|
|
288
|
+
connected: true,
|
|
289
|
+
totalSessions: 0, // Would use DBSIZE or scan
|
|
290
|
+
memoryUsage: '0MB'
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Close Redis connection
|
|
296
|
+
*/
|
|
297
|
+
async disconnect(): Promise<void> {
|
|
298
|
+
// Mock implementation - in production would close Redis connection
|
|
299
|
+
globalThis.console.log('Redis connection closed');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get session key with prefix
|
|
304
|
+
* @param sessionId - Session identifier
|
|
305
|
+
* @returns Prefixed session key
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
private getSessionKey(sessionId: string): string {
|
|
309
|
+
return `${this.config.keyPrefix}${sessionId}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get user sessions tracking key
|
|
314
|
+
* @param userId - User identifier
|
|
315
|
+
* @returns User sessions key prefix
|
|
316
|
+
* @private
|
|
317
|
+
*/
|
|
318
|
+
private getUserSessionsKey(userId: string): string {
|
|
319
|
+
return `${this.config.keyPrefix}${this.userSessionsKey}${userId}:`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Serialize session data
|
|
324
|
+
* @param data - Session data to serialize
|
|
325
|
+
* @returns Serialized string
|
|
326
|
+
* @private
|
|
327
|
+
*/
|
|
328
|
+
private serialize(data: SessionData): string {
|
|
329
|
+
if (this.config.serialization === 'json') {
|
|
330
|
+
return JSON.stringify(data);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// In production, could use msgpack for better compression
|
|
334
|
+
return JSON.stringify(data);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Deserialize session data
|
|
339
|
+
* @param serializedData - Serialized session data
|
|
340
|
+
* @returns Parsed session data
|
|
341
|
+
* @private
|
|
342
|
+
*/
|
|
343
|
+
private deserialize(serializedData: string): SessionData {
|
|
344
|
+
const data = JSON.parse(serializedData);
|
|
345
|
+
|
|
346
|
+
// Convert date strings back to Date objects
|
|
347
|
+
return {
|
|
348
|
+
...data,
|
|
349
|
+
expiresAt: new Date(data.expiresAt),
|
|
350
|
+
createdAt: new Date(data.createdAt),
|
|
351
|
+
lastActivityAt: new Date(data.lastActivityAt)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Enforce session limits per user
|
|
357
|
+
* @param userId - User identifier
|
|
358
|
+
* @private
|
|
359
|
+
*/
|
|
360
|
+
private async enforceSessionLimits(userId: string): Promise<void> {
|
|
361
|
+
const userSessions = await this.getByUserId(userId);
|
|
362
|
+
|
|
363
|
+
if (userSessions.length >= this.config.maxSessionsPerUser) {
|
|
364
|
+
// Sort by last activity (oldest first)
|
|
365
|
+
userSessions.sort((a, b) => a.lastActivityAt.getTime() - b.lastActivityAt.getTime());
|
|
366
|
+
|
|
367
|
+
// Remove oldest sessions to make room
|
|
368
|
+
const sessionsToRemove = userSessions.length - this.config.maxSessionsPerUser + 1;
|
|
369
|
+
for (let i = 0; i < sessionsToRemove; i++) {
|
|
370
|
+
await this.delete(userSessions[i].id);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Create mock Redis client for development
|
|
377
|
+
* @returns Mock Redis client
|
|
378
|
+
* @private
|
|
379
|
+
*/
|
|
380
|
+
private createMockRedisClient(): MockRedisClient {
|
|
381
|
+
const storage = new Map<string, { value: string; expires: number }>();
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
async set(key: string, value: string, ex: number): Promise<string> {
|
|
385
|
+
storage.set(key, {
|
|
386
|
+
value,
|
|
387
|
+
expires: Date.now() + ex * NUMERIX.THOUSAND
|
|
388
|
+
});
|
|
389
|
+
return 'OK';
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
async get(key: string): Promise<string | null> {
|
|
393
|
+
const item = storage.get(key);
|
|
394
|
+
if (!item) return null;
|
|
395
|
+
|
|
396
|
+
if (item.expires < Date.now()) {
|
|
397
|
+
storage.delete(key);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return item.value;
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async del(key: string): Promise<number> {
|
|
405
|
+
const existed = storage.has(key);
|
|
406
|
+
storage.delete(key);
|
|
407
|
+
return existed ? 1 : 0;
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
async keys(pattern: string): Promise<string[]> {
|
|
411
|
+
const regex = new RegExp(pattern.replace('*', '.*'));
|
|
412
|
+
return Array.from(storage.keys()).filter(key => regex.test(key));
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async expire(key: string, seconds: number): Promise<number> {
|
|
416
|
+
const item = storage.get(key);
|
|
417
|
+
if (!item) return 0;
|
|
418
|
+
|
|
419
|
+
item.expires = Date.now() + seconds * NUMERIX.THOUSAND;
|
|
420
|
+
storage.set(key, item);
|
|
421
|
+
return 1;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async exists(key: string): Promise<number> {
|
|
425
|
+
const item = storage.get(key);
|
|
426
|
+
if (!item) return 0;
|
|
427
|
+
|
|
428
|
+
if (item.expires < Date.now()) {
|
|
429
|
+
storage.delete(key);
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return 1;
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
async scan(cursor: string, match?: string, count?: number): Promise<[string, string[]]> {
|
|
437
|
+
// Simplified scan implementation
|
|
438
|
+
const keys = match ? await this.keys(match) : Array.from(storage.keys());
|
|
439
|
+
return ['0', keys.slice(0, count ?? NUMERIX.TEN)];
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { UserRepository } from "../db/repositories/user.repository";
|
|
2
|
+
import type { ConnectedAccountRepository } from "../db/repositories/connected-account.repository";
|
|
3
|
+
import type { AuthUser, ConnectedAccount } from "@plyaz/types";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export interface OAuthProfile {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
name: string;
|
|
10
|
+
avatar?: string;
|
|
11
|
+
provider: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class OAuthStrategy {
|
|
15
|
+
constructor(
|
|
16
|
+
private userRepo: UserRepository,
|
|
17
|
+
private connectedAccountRepo: ConnectedAccountRepository
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async authenticate(
|
|
21
|
+
profile: OAuthProfile,
|
|
22
|
+
accessToken: string,
|
|
23
|
+
refreshToken?: string
|
|
24
|
+
): Promise<AuthUser> {
|
|
25
|
+
let connectedAccount = await this.connectedAccountRepo.findByProviderAndId(
|
|
26
|
+
profile.provider,
|
|
27
|
+
profile.id
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (connectedAccount) {
|
|
31
|
+
await this.updateTokens(connectedAccount.id, accessToken, refreshToken);
|
|
32
|
+
const user = await this.userRepo.findById(connectedAccount.userId);
|
|
33
|
+
if (!user) throw new Error("User not found");
|
|
34
|
+
return user;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let user = await this.userRepo.findByEmail(profile.email);
|
|
38
|
+
user ??= await this.createUserFromProfile(profile);
|
|
39
|
+
if (!user) throw new Error("Failed to create user");
|
|
40
|
+
|
|
41
|
+
connectedAccount = await this.createConnectedAccount(
|
|
42
|
+
user.id,
|
|
43
|
+
profile,
|
|
44
|
+
accessToken,
|
|
45
|
+
refreshToken
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return user;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async createUserFromProfile(profile: OAuthProfile): Promise<AuthUser> {
|
|
52
|
+
return this.userRepo.create({
|
|
53
|
+
email: profile.email,
|
|
54
|
+
displayName: profile.name,
|
|
55
|
+
authProvider: "GOOGLE",
|
|
56
|
+
isActive: true,
|
|
57
|
+
isVerified: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async createConnectedAccount(
|
|
62
|
+
userId: string,
|
|
63
|
+
profile: OAuthProfile,
|
|
64
|
+
accessToken: string,
|
|
65
|
+
refreshToken?: string
|
|
66
|
+
): Promise<ConnectedAccount> {
|
|
67
|
+
return this.connectedAccountRepo.create({
|
|
68
|
+
userId,
|
|
69
|
+
providerType: "OAUTH",
|
|
70
|
+
provider: profile.provider,
|
|
71
|
+
providerAccountId: profile.id,
|
|
72
|
+
providerEmail: profile.email,
|
|
73
|
+
providerDisplayName: profile.name,
|
|
74
|
+
providerAvatarUrl: profile.avatar,
|
|
75
|
+
accessTokenEncrypted: accessToken,
|
|
76
|
+
refreshTokenEncrypted: refreshToken,
|
|
77
|
+
isVerified: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async updateTokens(
|
|
82
|
+
accountId: string,
|
|
83
|
+
accessToken: string,
|
|
84
|
+
refreshToken?: string
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
await this.connectedAccountRepo.update(accountId, {
|
|
87
|
+
accessTokenEncrypted: accessToken,
|
|
88
|
+
refreshTokenEncrypted: refreshToken,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// getAuthorizationUrl(provider: string, redirectUri: string, state?: string): string {
|
|
93
|
+
// const baseUrls = {
|
|
94
|
+
// google: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
95
|
+
// facebook: 'https://www.facebook.com/v18.0/dialog/oauth',
|
|
96
|
+
// github: 'https://github.com/login/oauth/authorize'
|
|
97
|
+
// };
|
|
98
|
+
|
|
99
|
+
// const baseUrl = baseUrls[provider as keyof typeof baseUrls] || `https://oauth.${provider}.com/authorize`;
|
|
100
|
+
// const params = new URLSearchParams({
|
|
101
|
+
// response_type: 'code',
|
|
102
|
+
// client_id: 'your_client_id',
|
|
103
|
+
// redirect_uri: redirectUri,
|
|
104
|
+
// scope: 'email profile',
|
|
105
|
+
// ...(state && { state })
|
|
106
|
+
// });
|
|
107
|
+
|
|
108
|
+
// return `${baseUrl}?${params.toString()}`;
|
|
109
|
+
// }
|
|
110
|
+
|
|
111
|
+
async linkAccount(
|
|
112
|
+
userId: string,
|
|
113
|
+
provider: string,
|
|
114
|
+
profileData: {id:string,email:string,avatar:string,name:string}
|
|
115
|
+
): Promise<ConnectedAccount> {
|
|
116
|
+
return this.connectedAccountRepo.create({
|
|
117
|
+
userId,
|
|
118
|
+
providerType: "OAUTH",
|
|
119
|
+
provider,
|
|
120
|
+
providerAccountId: profileData.id,
|
|
121
|
+
providerEmail: profileData.email,
|
|
122
|
+
providerDisplayName: profileData.name,
|
|
123
|
+
providerAvatarUrl: profileData.avatar,
|
|
124
|
+
isVerified: true,
|
|
125
|
+
isPrimary: false,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes, pbkdf2Sync } from 'crypto';
|
|
2
|
+
import type { UserRepository } from '../db/repositories/user.repository';
|
|
3
|
+
import type { SessionManager } from '../core/session/session.manager';
|
|
4
|
+
import type { AuthTokens, AuthUser, Session } from '@plyaz/types';
|
|
5
|
+
import { AuthenticationError } from '@plyaz/errors';
|
|
6
|
+
import { NUMERIX } from '@plyaz/config';
|
|
7
|
+
import type { JwtManager } from '@/core/jwt/jwt.manager';
|
|
8
|
+
|
|
9
|
+
export class TraditionalAuthStrategy {
|
|
10
|
+
constructor(
|
|
11
|
+
private userRepo: UserRepository,
|
|
12
|
+
private jwtManager: JwtManager,
|
|
13
|
+
private sessionManager: SessionManager
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async authenticate(email: string, password: string): Promise<AuthUser> {
|
|
17
|
+
const user = await this.userRepo.findByEmail(email);
|
|
18
|
+
if (!user?.passwordHash) {
|
|
19
|
+
throw new AuthenticationError('AUTH_INVALID_CREDENTIALS');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isValid = await this.verifyPassword(password, user.passwordHash);
|
|
23
|
+
if (!isValid) {
|
|
24
|
+
throw new AuthenticationError('AUTH_INVALID_CREDENTIALS');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!user.isActive) {
|
|
28
|
+
throw new AuthenticationError('AUTH_ACCOUNT_LOCKED');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (user.isSuspended) {
|
|
32
|
+
throw new AuthenticationError('AUTH_ACCOUNT_SUSPENDED');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return user;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async hashPassword(password: string): Promise<string> {
|
|
39
|
+
const SALT_LENGTH = 32;
|
|
40
|
+
const HASH_LENGTH = 64;
|
|
41
|
+
const salt = randomBytes(SALT_LENGTH).toString('hex');
|
|
42
|
+
const hash = pbkdf2Sync(password, salt, NUMERIX.THOUSAND, HASH_LENGTH, 'sha512').toString('hex');
|
|
43
|
+
return `pbkdf2$${salt}$${hash}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
47
|
+
const HASH_LENGTH = 64;
|
|
48
|
+
if (hash.startsWith('pbkdf2$')) {
|
|
49
|
+
const [, salt, storedHash] = hash.split('$');
|
|
50
|
+
const computedHash = pbkdf2Sync(password, salt, NUMERIX.THOUSAND, HASH_LENGTH, 'sha512').toString('hex');
|
|
51
|
+
return computedHash === storedHash;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async signUp(data: {
|
|
57
|
+
email: string;
|
|
58
|
+
password: string;
|
|
59
|
+
displayName: string;
|
|
60
|
+
firstName?: string;
|
|
61
|
+
lastName?: string;
|
|
62
|
+
}): Promise<{ user: AuthUser; session: Session; tokens: AuthTokens }> {
|
|
63
|
+
// Check if user already exists
|
|
64
|
+
const existingUser = await this.userRepo.findByEmail(data.email);
|
|
65
|
+
if (existingUser) {
|
|
66
|
+
throw new AuthenticationError('AUTH_INVALID_CREDENTIALS');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Hash password
|
|
70
|
+
const passwordHash = await this.hashPassword(data.password);
|
|
71
|
+
|
|
72
|
+
// Create user
|
|
73
|
+
const user = await this.userRepo.create({
|
|
74
|
+
email: data.email,
|
|
75
|
+
displayName: data.displayName,
|
|
76
|
+
firstName: data.firstName,
|
|
77
|
+
lastName: data.lastName,
|
|
78
|
+
passwordHash,
|
|
79
|
+
authProvider: 'EMAIL',
|
|
80
|
+
isActive: true,
|
|
81
|
+
isVerified: false
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
// Create session
|
|
85
|
+
const session = await this.sessionManager.createSession(user.id, {
|
|
86
|
+
provider: 'EMAIL',
|
|
87
|
+
userAgent: 'test',
|
|
88
|
+
ipAddress: '127.0.0.1'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Generate tokens
|
|
92
|
+
const tokens = this.jwtManager.generateTokens(user);
|
|
93
|
+
|
|
94
|
+
return { user, session, tokens };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async signIn(data: {
|
|
98
|
+
email: string;
|
|
99
|
+
password: string;
|
|
100
|
+
}): Promise<{ user: AuthUser; session: Session; tokens: AuthTokens }> {
|
|
101
|
+
// Authenticate user
|
|
102
|
+
const user = await this.authenticate(data.email, data.password);
|
|
103
|
+
|
|
104
|
+
// Create session
|
|
105
|
+
const session = await this.sessionManager.createSession(user.id, {
|
|
106
|
+
provider: 'EMAIL',
|
|
107
|
+
userAgent: 'test',
|
|
108
|
+
ipAddress: '127.0.0.1'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Generate tokens
|
|
112
|
+
const tokens = this.jwtManager.generateTokens(user);
|
|
113
|
+
|
|
114
|
+
return { user, session, tokens };
|
|
115
|
+
}
|
|
116
|
+
}
|