@rapidd/core 2.1.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/.dockerignore +71 -0
- package/.env.example +70 -0
- package/.gitignore +11 -0
- package/LICENSE +15 -0
- package/README.md +231 -0
- package/bin/cli.js +145 -0
- package/config/app.json +166 -0
- package/config/rate-limit.json +12 -0
- package/dist/main.js +26 -0
- package/dockerfile +57 -0
- package/locales/ar_SA.json +179 -0
- package/locales/de_DE.json +179 -0
- package/locales/en_US.json +180 -0
- package/locales/es_ES.json +179 -0
- package/locales/fr_FR.json +179 -0
- package/locales/it_IT.json +179 -0
- package/locales/ja_JP.json +179 -0
- package/locales/pt_BR.json +179 -0
- package/locales/ru_RU.json +179 -0
- package/locales/tr_TR.json +179 -0
- package/main.ts +25 -0
- package/package.json +126 -0
- package/prisma/schema.prisma +9 -0
- package/prisma.config.ts +12 -0
- package/public/static/favicon.ico +0 -0
- package/public/static/image/logo.png +0 -0
- package/routes/api/v1/index.ts +113 -0
- package/src/app.ts +197 -0
- package/src/auth/Auth.ts +446 -0
- package/src/auth/stores/ISessionStore.ts +19 -0
- package/src/auth/stores/MemoryStore.ts +70 -0
- package/src/auth/stores/RedisStore.ts +92 -0
- package/src/auth/stores/index.ts +149 -0
- package/src/config/acl.ts +9 -0
- package/src/config/rls.ts +38 -0
- package/src/core/dmmf.ts +226 -0
- package/src/core/env.ts +183 -0
- package/src/core/errors.ts +87 -0
- package/src/core/i18n.ts +144 -0
- package/src/core/middleware.ts +123 -0
- package/src/core/prisma.ts +236 -0
- package/src/index.ts +112 -0
- package/src/middleware/model.ts +61 -0
- package/src/orm/Model.ts +881 -0
- package/src/orm/QueryBuilder.ts +2078 -0
- package/src/plugins/auth.ts +162 -0
- package/src/plugins/language.ts +79 -0
- package/src/plugins/rateLimit.ts +210 -0
- package/src/plugins/response.ts +80 -0
- package/src/plugins/rls.ts +51 -0
- package/src/plugins/security.ts +23 -0
- package/src/plugins/upload.ts +299 -0
- package/src/types.ts +308 -0
- package/src/utils/ApiClient.ts +526 -0
- package/src/utils/Mailer.ts +348 -0
- package/src/utils/index.ts +25 -0
- package/templates/email/example.ejs +17 -0
- package/templates/layouts/email.ejs +35 -0
- package/tsconfig.json +33 -0
package/src/auth/Auth.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import bcrypt from 'bcrypt';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { authPrisma } from '../core/prisma';
|
|
5
|
+
import { ErrorResponse } from '../core/errors';
|
|
6
|
+
import { createStore, SessionStoreManager } from './stores';
|
|
7
|
+
import { loadDMMF, findUserModel, findIdentifierFields, findPasswordField } from '../core/dmmf';
|
|
8
|
+
import type { RapiddUser, AuthOptions, AuthStrategy, ISessionStore } from '../types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Authentication class for user login, logout, and session management
|
|
12
|
+
*
|
|
13
|
+
* Provides JWT-based authentication with access/refresh token rotation,
|
|
14
|
+
* password hashing with bcrypt, and pluggable session storage (Redis/memory).
|
|
15
|
+
*
|
|
16
|
+
* Auto-detects user model, identifier fields, and password field from Prisma schema.
|
|
17
|
+
* When no user table is found, auth is gracefully disabled.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const auth = new Auth();
|
|
21
|
+
* await auth.initialize(); // Auto-detects config from DMMF
|
|
22
|
+
* if (auth.isEnabled()) {
|
|
23
|
+
* const result = await auth.login(request.body);
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
export class Auth {
|
|
27
|
+
options: Required<Pick<AuthOptions, 'passwordField' | 'saltRounds'>> & AuthOptions & {
|
|
28
|
+
identifierFields: string[];
|
|
29
|
+
strategies: AuthStrategy[];
|
|
30
|
+
cookieName: string;
|
|
31
|
+
customHeaderName: string;
|
|
32
|
+
session: { ttl: number; store?: string };
|
|
33
|
+
jwt: { secret?: string; refreshSecret?: string; accessExpiry: string; refreshExpiry: string };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
private _userModel: any = null;
|
|
37
|
+
private _sessionStore: ISessionStore | null = null;
|
|
38
|
+
private _authDisabled = false;
|
|
39
|
+
private _initPromise: Promise<void> | null = null;
|
|
40
|
+
private _explicitIdentifierFields: boolean;
|
|
41
|
+
private _explicitPasswordField: boolean;
|
|
42
|
+
|
|
43
|
+
constructor(options: AuthOptions = {}) {
|
|
44
|
+
const envIdentifierFields = process.env.DB_USER_IDENTIFIER_FIELDS
|
|
45
|
+
?.split(',').map(s => s.trim()).filter(Boolean);
|
|
46
|
+
|
|
47
|
+
this._explicitIdentifierFields = !!(
|
|
48
|
+
(options.identifierFields && options.identifierFields.length > 0)
|
|
49
|
+
|| (envIdentifierFields && envIdentifierFields.length > 0)
|
|
50
|
+
);
|
|
51
|
+
this._explicitPasswordField = !!(options.passwordField || process.env.DB_USER_PASSWORD_FIELD);
|
|
52
|
+
|
|
53
|
+
this.options = {
|
|
54
|
+
userModel: options.userModel || process.env.DB_USER_TABLE,
|
|
55
|
+
userSelect: options.userSelect || null,
|
|
56
|
+
userInclude: options.userInclude || null,
|
|
57
|
+
identifierFields: options.identifierFields || envIdentifierFields || ['email'],
|
|
58
|
+
passwordField: options.passwordField || process.env.DB_USER_PASSWORD_FIELD || 'password',
|
|
59
|
+
session: {
|
|
60
|
+
ttl: parseInt(process.env.AUTH_SESSION_TTL || '86400', 10),
|
|
61
|
+
...options.session,
|
|
62
|
+
},
|
|
63
|
+
jwt: {
|
|
64
|
+
secret: process.env.JWT_SECRET,
|
|
65
|
+
refreshSecret: process.env.JWT_REFRESH_SECRET,
|
|
66
|
+
accessExpiry: process.env.AUTH_ACCESS_TOKEN_EXPIRY || '1d',
|
|
67
|
+
refreshExpiry: process.env.AUTH_REFRESH_TOKEN_EXPIRY || '7d',
|
|
68
|
+
...options.jwt,
|
|
69
|
+
},
|
|
70
|
+
saltRounds: options.saltRounds || parseInt(process.env.AUTH_SALT_ROUNDS || '10', 10),
|
|
71
|
+
strategies: options.strategies
|
|
72
|
+
|| (process.env.AUTH_STRATEGIES?.split(',').map(s => s.trim()) as AuthStrategy[])
|
|
73
|
+
|| ['bearer'],
|
|
74
|
+
cookieName: options.cookieName || process.env.AUTH_COOKIE_NAME || 'token',
|
|
75
|
+
customHeaderName: options.customHeaderName || process.env.AUTH_CUSTOM_HEADER || 'X-Auth-Token',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Bind methods for use as handlers
|
|
79
|
+
this.login = this.login.bind(this);
|
|
80
|
+
this.logout = this.logout.bind(this);
|
|
81
|
+
this.refresh = this.refresh.bind(this);
|
|
82
|
+
this.me = this.me.bind(this);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Initialization ──────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Initialize auth by auto-detecting config from DMMF.
|
|
89
|
+
* Must be called before using auth (the auth plugin calls this automatically).
|
|
90
|
+
*/
|
|
91
|
+
async initialize(): Promise<void> {
|
|
92
|
+
if (this._initPromise) return this._initPromise;
|
|
93
|
+
|
|
94
|
+
this._initPromise = this._doInitialize();
|
|
95
|
+
return this._initPromise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async _doInitialize(): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await loadDMMF();
|
|
101
|
+
} catch {
|
|
102
|
+
console.warn('[Auth] Could not load DMMF, auth auto-detection skipped');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Auto-detect user model from DMMF
|
|
107
|
+
const userModel = findUserModel();
|
|
108
|
+
if (!userModel) {
|
|
109
|
+
// Check if a model name was explicitly configured
|
|
110
|
+
if (!this.options.userModel) {
|
|
111
|
+
this._authDisabled = true;
|
|
112
|
+
console.log('[Auth] No user table detected in schema, auth disabled');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Model name was set but not found in DMMF — warn but don't disable
|
|
116
|
+
// (it might still exist in the Prisma client under a different casing)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const modelName = this.options.userModel || userModel?.name;
|
|
120
|
+
|
|
121
|
+
if (modelName && !this.options.userModel) {
|
|
122
|
+
this.options.userModel = modelName;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Auto-detect identifier fields
|
|
126
|
+
if (!this._explicitIdentifierFields && modelName) {
|
|
127
|
+
const detected = findIdentifierFields(modelName);
|
|
128
|
+
this.options.identifierFields = detected;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Auto-detect password field
|
|
132
|
+
if (!this._explicitPasswordField && modelName) {
|
|
133
|
+
const detected = findPasswordField(modelName);
|
|
134
|
+
if (detected) {
|
|
135
|
+
this.options.passwordField = detected;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// JWT secret handling: require explicit secrets in production
|
|
140
|
+
if (!this.options.jwt.secret) {
|
|
141
|
+
if (process.env.NODE_ENV === 'production') {
|
|
142
|
+
throw new Error('[Auth] JWT_SECRET is required in production. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
|
|
143
|
+
}
|
|
144
|
+
this.options.jwt.secret = crypto.randomBytes(32).toString('hex');
|
|
145
|
+
console.warn('[Auth] No JWT_SECRET set, using auto-generated secret (sessions won\'t persist across restarts)');
|
|
146
|
+
}
|
|
147
|
+
if (!this.options.jwt.refreshSecret) {
|
|
148
|
+
if (process.env.NODE_ENV === 'production') {
|
|
149
|
+
throw new Error('[Auth] JWT_REFRESH_SECRET is required in production. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
|
|
150
|
+
}
|
|
151
|
+
this.options.jwt.refreshSecret = crypto.randomBytes(32).toString('hex');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether auth is enabled (user table was found or configured)
|
|
157
|
+
*/
|
|
158
|
+
isEnabled(): boolean {
|
|
159
|
+
return !this._authDisabled;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── User Model ──────────────────────────────────
|
|
163
|
+
|
|
164
|
+
getUserModel(): any | null {
|
|
165
|
+
if (this._userModel) return this._userModel;
|
|
166
|
+
|
|
167
|
+
const modelName = this.options.userModel?.toLowerCase();
|
|
168
|
+
const models = Object.keys(authPrisma).filter((k: string) => !k.startsWith('$') && !k.startsWith('_'));
|
|
169
|
+
|
|
170
|
+
if (modelName) {
|
|
171
|
+
const match = models.find((m: string) => m.toLowerCase() === modelName);
|
|
172
|
+
if (match) {
|
|
173
|
+
this._userModel = (authPrisma as any)[match];
|
|
174
|
+
return this._userModel;
|
|
175
|
+
}
|
|
176
|
+
console.warn(`[Auth] userModel="${modelName}" not found in Prisma models`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const name of ['users', 'user', 'Users', 'User']) {
|
|
180
|
+
if ((authPrisma as any)[name]) {
|
|
181
|
+
this._userModel = (authPrisma as any)[name];
|
|
182
|
+
return this._userModel;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private _buildUserQuery(includePassword = false): Record<string, unknown> {
|
|
190
|
+
const query: Record<string, unknown> = {};
|
|
191
|
+
|
|
192
|
+
if (this.options.userSelect) {
|
|
193
|
+
query.select = { ...this.options.userSelect };
|
|
194
|
+
if (includePassword) {
|
|
195
|
+
(query.select as Record<string, boolean>)[this.options.passwordField] = true;
|
|
196
|
+
}
|
|
197
|
+
} else if (this.options.userInclude) {
|
|
198
|
+
query.include = { ...this.options.userInclude };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return query;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private _sanitizeUser(user: Record<string, unknown> | null): RapiddUser | null {
|
|
205
|
+
if (!user) return null;
|
|
206
|
+
const { [this.options.passwordField]: _, ...sanitized } = user;
|
|
207
|
+
return sanitized as RapiddUser;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Session Store ───────────────────────────────
|
|
211
|
+
|
|
212
|
+
getSessionStore(): ISessionStore {
|
|
213
|
+
if (!this._sessionStore) {
|
|
214
|
+
this._sessionStore = createStore({ ttl: this.options.session.ttl });
|
|
215
|
+
}
|
|
216
|
+
return this._sessionStore;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setSessionStore(store: ISessionStore): void {
|
|
220
|
+
this._sessionStore = store;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── JWT Helpers ─────────────────────────────────
|
|
224
|
+
|
|
225
|
+
generateAccessToken(user: Record<string, unknown>, sessionId: string | null = null): string {
|
|
226
|
+
if (!this.options.jwt.secret) {
|
|
227
|
+
throw new Error('[Auth] JWT_SECRET is required');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sanitizedUser = this._sanitizeUser(user);
|
|
231
|
+
return jwt.sign(
|
|
232
|
+
{ sub: user.id, sessionId, user: sanitizedUser },
|
|
233
|
+
this.options.jwt.secret,
|
|
234
|
+
{ algorithm: 'HS256', expiresIn: this.options.jwt.accessExpiry as any }
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
generateRefreshToken(user: Record<string, unknown>): string {
|
|
239
|
+
const secret = this.options.jwt.refreshSecret || this.options.jwt.secret;
|
|
240
|
+
if (!secret) throw new Error('[Auth] JWT_SECRET is required');
|
|
241
|
+
|
|
242
|
+
return jwt.sign(
|
|
243
|
+
{ sub: user.id, type: 'refresh' },
|
|
244
|
+
secret,
|
|
245
|
+
{ algorithm: 'HS256', expiresIn: this.options.jwt.refreshExpiry as any }
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
verifyToken(token: string, isRefresh = false): any | null {
|
|
250
|
+
try {
|
|
251
|
+
const secret = isRefresh
|
|
252
|
+
? (this.options.jwt.refreshSecret || this.options.jwt.secret)
|
|
253
|
+
: this.options.jwt.secret;
|
|
254
|
+
if (!secret) return null;
|
|
255
|
+
return jwt.verify(token, secret, { algorithms: ['HS256'] });
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
generateSessionId(): string {
|
|
262
|
+
return crypto.randomUUID();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Auth Handlers ───────────────────────────────
|
|
266
|
+
|
|
267
|
+
async handleBasicAuth(credentials: string): Promise<RapiddUser | null> {
|
|
268
|
+
try {
|
|
269
|
+
const decoded = Buffer.from(credentials, 'base64').toString('utf-8');
|
|
270
|
+
const colonIndex = decoded.indexOf(':');
|
|
271
|
+
if (colonIndex === -1) return null;
|
|
272
|
+
|
|
273
|
+
const identifier = decoded.substring(0, colonIndex);
|
|
274
|
+
const password = decoded.substring(colonIndex + 1);
|
|
275
|
+
if (!identifier || !password) return null;
|
|
276
|
+
|
|
277
|
+
const User = this.getUserModel();
|
|
278
|
+
if (!User) return null;
|
|
279
|
+
let user: Record<string, unknown> | null = null;
|
|
280
|
+
|
|
281
|
+
for (const field of this.options.identifierFields) {
|
|
282
|
+
try {
|
|
283
|
+
user = await User.findUnique({
|
|
284
|
+
where: { [field]: identifier },
|
|
285
|
+
...this._buildUserQuery(true),
|
|
286
|
+
});
|
|
287
|
+
if (user) break;
|
|
288
|
+
} catch {
|
|
289
|
+
// Field might not exist or not be unique
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!user?.[this.options.passwordField]) return null;
|
|
294
|
+
|
|
295
|
+
const valid = await bcrypt.compare(password, user[this.options.passwordField] as string);
|
|
296
|
+
if (!valid) return null;
|
|
297
|
+
|
|
298
|
+
return this._sanitizeUser(user);
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async handleBearerAuth(token: string): Promise<RapiddUser | null> {
|
|
305
|
+
const decoded = this.verifyToken(token);
|
|
306
|
+
if (!decoded) return null;
|
|
307
|
+
|
|
308
|
+
if (decoded.sessionId) {
|
|
309
|
+
const store = this.getSessionStore();
|
|
310
|
+
const session = await store.get(decoded.sessionId);
|
|
311
|
+
if (session) {
|
|
312
|
+
await store.refresh(decoded.sessionId);
|
|
313
|
+
return session as unknown as RapiddUser;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (decoded.user) return decoded.user;
|
|
319
|
+
if (decoded.sub) return { id: decoded.sub, role: 'user' } as RapiddUser;
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async handleCookieAuth(cookieValue: string): Promise<RapiddUser | null> {
|
|
324
|
+
if (!cookieValue) return null;
|
|
325
|
+
return this.handleBearerAuth(cookieValue);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async handleCustomHeaderAuth(headerValue: string): Promise<RapiddUser | null> {
|
|
329
|
+
if (!headerValue) return null;
|
|
330
|
+
return this.handleBearerAuth(headerValue);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Route Handlers (framework-agnostic) ─────────
|
|
334
|
+
|
|
335
|
+
async login(body: { user: string; password: string }): Promise<{
|
|
336
|
+
user: RapiddUser;
|
|
337
|
+
accessToken: string;
|
|
338
|
+
refreshToken: string;
|
|
339
|
+
}> {
|
|
340
|
+
const { user: identifier, password } = body;
|
|
341
|
+
|
|
342
|
+
if (!identifier || !password) {
|
|
343
|
+
throw new ErrorResponse(400, 'user_and_password_required');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const User = this.getUserModel();
|
|
347
|
+
if (!User) {
|
|
348
|
+
throw new ErrorResponse(500, 'auth_not_configured');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const search = this.options.identifierFields.reduce((acc: any, curr: string) => {
|
|
352
|
+
acc.OR.push({[curr]: identifier});
|
|
353
|
+
return acc;
|
|
354
|
+
}, {'OR': []});
|
|
355
|
+
|
|
356
|
+
const user = await User.findFirst({
|
|
357
|
+
where: search,
|
|
358
|
+
...this._buildUserQuery(true),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (!user?.[this.options.passwordField]) {
|
|
362
|
+
throw new ErrorResponse(401, 'invalid_credentials');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const valid = await bcrypt.compare(password, user[this.options.passwordField]);
|
|
366
|
+
if (!valid) {
|
|
367
|
+
throw new ErrorResponse(401, 'invalid_credentials');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const sanitizedUser = this._sanitizeUser(user)!;
|
|
371
|
+
const sessionId = this.generateSessionId();
|
|
372
|
+
await this.getSessionStore().create(sessionId, sanitizedUser as unknown as Record<string, unknown>);
|
|
373
|
+
|
|
374
|
+
const accessToken = this.generateAccessToken(user, sessionId);
|
|
375
|
+
const refreshToken = this.generateRefreshToken(user);
|
|
376
|
+
|
|
377
|
+
return { user: sanitizedUser, accessToken, refreshToken };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async logout(authHeader: string | undefined): Promise<{ message: string }> {
|
|
381
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
382
|
+
const token = authHeader.substring(7);
|
|
383
|
+
const decoded = this.verifyToken(token);
|
|
384
|
+
if (decoded?.sessionId) {
|
|
385
|
+
await this.getSessionStore().delete(decoded.sessionId);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { message: 'logged_out' };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async refresh(body: { refreshToken: string }): Promise<{
|
|
392
|
+
accessToken: string;
|
|
393
|
+
refreshToken: string;
|
|
394
|
+
}> {
|
|
395
|
+
const { refreshToken } = body;
|
|
396
|
+
|
|
397
|
+
if (!refreshToken) {
|
|
398
|
+
throw new ErrorResponse(400, 'refresh_token_required');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const decoded = this.verifyToken(refreshToken, true);
|
|
402
|
+
if (!decoded || decoded.type !== 'refresh') {
|
|
403
|
+
throw new ErrorResponse(401, 'invalid_refresh_token');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const User = this.getUserModel();
|
|
407
|
+
if (!User) {
|
|
408
|
+
throw new ErrorResponse(500, 'auth_not_configured');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const user = await User.findUnique({
|
|
412
|
+
where: { id: decoded.sub },
|
|
413
|
+
...this._buildUserQuery(false),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
if (!user) {
|
|
417
|
+
throw new ErrorResponse(401, 'user_not_found');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const sanitizedUser = this._sanitizeUser(user)!;
|
|
421
|
+
const sessionId = this.generateSessionId();
|
|
422
|
+
await this.getSessionStore().create(sessionId, sanitizedUser as unknown as Record<string, unknown>);
|
|
423
|
+
|
|
424
|
+
const accessToken = this.generateAccessToken(user, sessionId);
|
|
425
|
+
const newRefreshToken = this.generateRefreshToken(user);
|
|
426
|
+
|
|
427
|
+
return { accessToken, refreshToken: newRefreshToken };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async me(user: RapiddUser | null): Promise<{ user: RapiddUser }> {
|
|
431
|
+
if (!user) {
|
|
432
|
+
throw new ErrorResponse(401, 'not_authenticated');
|
|
433
|
+
}
|
|
434
|
+
return { user };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Password Helpers ────────────────────────────
|
|
438
|
+
|
|
439
|
+
async hashPassword(password: string): Promise<string> {
|
|
440
|
+
return bcrypt.hash(password, this.options.saltRounds);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async comparePassword(password: string, hash: string): Promise<boolean> {
|
|
444
|
+
return bcrypt.compare(password, hash);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Store Interface
|
|
3
|
+
* All session stores must implement these methods
|
|
4
|
+
*/
|
|
5
|
+
export abstract class ISessionStore {
|
|
6
|
+
abstract create(sessionId: string, data: Record<string, unknown>): Promise<void>;
|
|
7
|
+
abstract get(sessionId: string): Promise<Record<string, unknown> | null>;
|
|
8
|
+
abstract delete(sessionId: string): Promise<void>;
|
|
9
|
+
|
|
10
|
+
async refresh(_sessionId: string): Promise<void> {
|
|
11
|
+
// Optional - default no-op
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async isHealthy(): Promise<boolean> {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
destroy?(): void | Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ISessionStore } from './ISessionStore';
|
|
2
|
+
|
|
3
|
+
interface SessionEntry {
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory session store.
|
|
10
|
+
* Suitable for development or single-instance deployments.
|
|
11
|
+
*/
|
|
12
|
+
export class MemoryStore extends ISessionStore {
|
|
13
|
+
private ttl: number;
|
|
14
|
+
private sessions = new Map<string, SessionEntry>();
|
|
15
|
+
private _cleanupInterval: ReturnType<typeof setInterval>;
|
|
16
|
+
|
|
17
|
+
constructor(options: { ttl?: number } = {}) {
|
|
18
|
+
super();
|
|
19
|
+
this.ttl = (options.ttl || 86400) * 1000; // Convert to ms
|
|
20
|
+
this._cleanupInterval = setInterval(() => this._cleanup(), 60000);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async create(sessionId: string, data: Record<string, unknown>): Promise<void> {
|
|
24
|
+
this.sessions.set(sessionId, {
|
|
25
|
+
data,
|
|
26
|
+
expiresAt: Date.now() + this.ttl,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(sessionId: string): Promise<Record<string, unknown> | null> {
|
|
31
|
+
const session = this.sessions.get(sessionId);
|
|
32
|
+
if (!session) return null;
|
|
33
|
+
|
|
34
|
+
if (Date.now() > session.expiresAt) {
|
|
35
|
+
this.sessions.delete(sessionId);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return session.data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async delete(sessionId: string): Promise<void> {
|
|
43
|
+
this.sessions.delete(sessionId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async refresh(sessionId: string): Promise<void> {
|
|
47
|
+
const session = this.sessions.get(sessionId);
|
|
48
|
+
if (session) {
|
|
49
|
+
session.expiresAt = Date.now() + this.ttl;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async isHealthy(): Promise<boolean> {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private _cleanup(): void {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const [id, session] of this.sessions) {
|
|
60
|
+
if (session.expiresAt < now) {
|
|
61
|
+
this.sessions.delete(id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
destroy(): void {
|
|
67
|
+
clearInterval(this._cleanupInterval);
|
|
68
|
+
this.sessions.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Redis from 'ioredis';
|
|
2
|
+
import { ISessionStore } from './ISessionStore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redis session store using ioredis.
|
|
6
|
+
* Recommended for production and multi-instance deployments.
|
|
7
|
+
*/
|
|
8
|
+
export class RedisStore extends ISessionStore {
|
|
9
|
+
private ttl: number;
|
|
10
|
+
private prefix: string;
|
|
11
|
+
private client: Redis | null = null;
|
|
12
|
+
private _initialized = false;
|
|
13
|
+
|
|
14
|
+
constructor(options: { ttl?: number; prefix?: string } = {}) {
|
|
15
|
+
super();
|
|
16
|
+
this.ttl = options.ttl || 86400;
|
|
17
|
+
this.prefix = options.prefix || 'session:';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private _ensureClient(): Redis {
|
|
21
|
+
if (this._initialized && this.client) return this.client;
|
|
22
|
+
|
|
23
|
+
const host = process.env.REDIS_HOST || 'localhost';
|
|
24
|
+
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
|
|
25
|
+
const db = parseInt(process.env.REDIS_DB_AUTH || '1', 10);
|
|
26
|
+
const password = process.env.REDIS_PASSWORD || undefined;
|
|
27
|
+
|
|
28
|
+
this.client = new Redis({
|
|
29
|
+
host,
|
|
30
|
+
port,
|
|
31
|
+
db,
|
|
32
|
+
password,
|
|
33
|
+
retryStrategy: (times: number) => Math.min(times * 500, 30000),
|
|
34
|
+
maxRetriesPerRequest: 3,
|
|
35
|
+
enableReadyCheck: true,
|
|
36
|
+
connectTimeout: 5000,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.client.on('connect', () => console.log('[RedisStore] Connected'));
|
|
40
|
+
this.client.on('ready', () => console.log('[RedisStore] Ready'));
|
|
41
|
+
this.client.on('error', (err: Error) => console.error('[RedisStore] Error:', err.message));
|
|
42
|
+
this.client.on('close', () => console.warn('[RedisStore] Connection closed'));
|
|
43
|
+
this.client.on('reconnecting', () => console.log('[RedisStore] Reconnecting...'));
|
|
44
|
+
|
|
45
|
+
this._initialized = true;
|
|
46
|
+
return this.client;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private _key(sessionId: string): string {
|
|
50
|
+
return `${this.prefix}${sessionId}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async create(sessionId: string, data: Record<string, unknown>): Promise<void> {
|
|
54
|
+
const client = this._ensureClient();
|
|
55
|
+
await client.set(this._key(sessionId), JSON.stringify(data), 'EX', this.ttl);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async get(sessionId: string): Promise<Record<string, unknown> | null> {
|
|
59
|
+
const client = this._ensureClient();
|
|
60
|
+
const result = await client.get(this._key(sessionId));
|
|
61
|
+
return result ? JSON.parse(result) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async delete(sessionId: string): Promise<void> {
|
|
65
|
+
const client = this._ensureClient();
|
|
66
|
+
await client.del(this._key(sessionId));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async refresh(sessionId: string): Promise<void> {
|
|
70
|
+
const client = this._ensureClient();
|
|
71
|
+
await client.expire(this._key(sessionId), this.ttl);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async isHealthy(): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
const client = this._ensureClient();
|
|
77
|
+
if (client.status !== 'ready') return false;
|
|
78
|
+
const result = await client.ping();
|
|
79
|
+
return result === 'PONG';
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async destroy(): Promise<void> {
|
|
86
|
+
if (this.client) {
|
|
87
|
+
await this.client.quit();
|
|
88
|
+
this.client = null;
|
|
89
|
+
this._initialized = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|