@xcelsior/auth 0.1.1
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/.turbo/turbo-build.log +22 -0
- package/.turbo/turbo-lint.log +5 -0
- package/.turbo/turbo-test.log +12 -0
- package/CHANGELOG.md +7 -0
- package/README.md +213 -0
- package/biome.json +3 -0
- package/dist/index.d.mts +139 -0
- package/dist/index.d.ts +139 -0
- package/dist/index.js +532 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +499 -0
- package/dist/index.mjs.map +1 -0
- package/jest.config.js +10 -0
- package/package.json +40 -0
- package/src/email/defaultTemplates.ts +57 -0
- package/src/email/index.ts +22 -0
- package/src/email/ses.ts +80 -0
- package/src/email/smtp.ts +43 -0
- package/src/email/types.ts +42 -0
- package/src/index.ts +3 -0
- package/src/middleware/auth.ts +50 -0
- package/src/services/auth.ts +165 -0
- package/src/storage/dynamodb.ts +153 -0
- package/src/storage/index.ts +18 -0
- package/src/storage/types.ts +33 -0
- package/src/types/index.ts +32 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IEmailProvider, EmailConfig, SMTPConfig, SESConfig } from './types';
|
|
2
|
+
import { SMTPEmailProvider } from './smtp';
|
|
3
|
+
import { SESEmailProvider } from './ses';
|
|
4
|
+
|
|
5
|
+
export function createEmailProvider(config: EmailConfig): IEmailProvider {
|
|
6
|
+
switch (config.type) {
|
|
7
|
+
case 'smtp':
|
|
8
|
+
return new SMTPEmailProvider(
|
|
9
|
+
config.from,
|
|
10
|
+
config.options as SMTPConfig,
|
|
11
|
+
config.templates
|
|
12
|
+
);
|
|
13
|
+
case 'ses':
|
|
14
|
+
return new SESEmailProvider(config.from, config.options as SESConfig, config.templates);
|
|
15
|
+
default:
|
|
16
|
+
throw new Error(`Unsupported email provider type: ${config.type}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export * from './types';
|
|
21
|
+
export * from './smtp';
|
|
22
|
+
export * from './ses';
|
package/src/email/ses.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
|
|
2
|
+
import type { IEmailProvider, SESConfig, EmailTemplates } from './types';
|
|
3
|
+
import { defaultTemplates } from './defaultTemplates';
|
|
4
|
+
|
|
5
|
+
export class SESEmailProvider implements IEmailProvider {
|
|
6
|
+
private client: SESv2Client;
|
|
7
|
+
private config: { from: string; templates: EmailTemplates; sourceArn?: string };
|
|
8
|
+
|
|
9
|
+
constructor(from: string, config: SESConfig, templates?: EmailTemplates) {
|
|
10
|
+
this.client = new SESv2Client({
|
|
11
|
+
region: config.region,
|
|
12
|
+
credentials: config.credentials,
|
|
13
|
+
});
|
|
14
|
+
this.config = {
|
|
15
|
+
from,
|
|
16
|
+
templates: templates ?? defaultTemplates,
|
|
17
|
+
sourceArn: config.sourceArn,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private async sendEmail(to: string, subject: string, html: string): Promise<void> {
|
|
22
|
+
const command = new SendEmailCommand({
|
|
23
|
+
FromEmailAddress: this.config.from,
|
|
24
|
+
FromEmailAddressIdentityArn: this.config.sourceArn,
|
|
25
|
+
Destination: {
|
|
26
|
+
ToAddresses: [to],
|
|
27
|
+
},
|
|
28
|
+
Content: {
|
|
29
|
+
Simple: {
|
|
30
|
+
Subject: {
|
|
31
|
+
Data: subject,
|
|
32
|
+
Charset: 'UTF-8',
|
|
33
|
+
},
|
|
34
|
+
Body: {
|
|
35
|
+
Html: {
|
|
36
|
+
Data: html,
|
|
37
|
+
Charset: 'UTF-8',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await this.client.send(command);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async sendVerificationEmail(email: string, token: string): Promise<void> {
|
|
48
|
+
const { subject, html } = this.config.templates.verification;
|
|
49
|
+
await this.sendEmail(email, subject, html(token));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async sendPasswordResetEmail(email: string, token: string): Promise<void> {
|
|
53
|
+
const { subject, html } = this.config.templates.resetPassword;
|
|
54
|
+
await this.sendEmail(email, subject, html(token));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async verifyConnection(): Promise<boolean> {
|
|
58
|
+
try {
|
|
59
|
+
// SES doesn't have a direct verify method, so we'll check if we can describe our sending status
|
|
60
|
+
await this.client.send(
|
|
61
|
+
new SendEmailCommand({
|
|
62
|
+
FromEmailAddress: this.config.from,
|
|
63
|
+
FromEmailAddressIdentityArn: this.config.sourceArn,
|
|
64
|
+
Destination: {
|
|
65
|
+
ToAddresses: [this.config.from], // Send to ourselves as a test
|
|
66
|
+
},
|
|
67
|
+
Content: {
|
|
68
|
+
Simple: {
|
|
69
|
+
Subject: { Data: 'Test Connection', Charset: 'UTF-8' },
|
|
70
|
+
Body: { Text: { Data: 'Test', Charset: 'UTF-8' } },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
return true;
|
|
76
|
+
} catch (_error) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import type { IEmailProvider, SMTPConfig, EmailTemplates } from './types';
|
|
3
|
+
|
|
4
|
+
export class SMTPEmailProvider implements IEmailProvider {
|
|
5
|
+
private transporter: nodemailer.Transporter;
|
|
6
|
+
private config: { from: string; templates: EmailTemplates };
|
|
7
|
+
|
|
8
|
+
constructor(from: string, config: SMTPConfig, templates: EmailTemplates) {
|
|
9
|
+
this.transporter = nodemailer.createTransport(config);
|
|
10
|
+
this.config = { from, templates };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async sendVerificationEmail(email: string, token: string): Promise<void> {
|
|
14
|
+
const { subject, html } = this.config.templates.verification;
|
|
15
|
+
|
|
16
|
+
await this.transporter.sendMail({
|
|
17
|
+
from: this.config.from,
|
|
18
|
+
to: email,
|
|
19
|
+
subject,
|
|
20
|
+
html: html(token),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async sendPasswordResetEmail(email: string, token: string): Promise<void> {
|
|
25
|
+
const { subject, html } = this.config.templates.resetPassword;
|
|
26
|
+
|
|
27
|
+
await this.transporter.sendMail({
|
|
28
|
+
from: this.config.from,
|
|
29
|
+
to: email,
|
|
30
|
+
subject,
|
|
31
|
+
html: html(token),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async verifyConnection(): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await this.transporter.verify();
|
|
38
|
+
return true;
|
|
39
|
+
} catch (_error) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface IEmailProvider {
|
|
2
|
+
sendVerificationEmail(email: string, token: string): Promise<void>;
|
|
3
|
+
sendPasswordResetEmail(email: string, token: string): Promise<void>;
|
|
4
|
+
verifyConnection(): Promise<boolean>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface EmailTemplates {
|
|
8
|
+
verification: {
|
|
9
|
+
subject: string;
|
|
10
|
+
html: (token: string) => string;
|
|
11
|
+
};
|
|
12
|
+
resetPassword: {
|
|
13
|
+
subject: string;
|
|
14
|
+
html: (token: string) => string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SMTPConfig {
|
|
19
|
+
host: string;
|
|
20
|
+
port: number;
|
|
21
|
+
secure: boolean;
|
|
22
|
+
auth: {
|
|
23
|
+
user: string;
|
|
24
|
+
pass: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SESConfig {
|
|
29
|
+
region: string;
|
|
30
|
+
credentials?: {
|
|
31
|
+
accessKeyId: string;
|
|
32
|
+
secretAccessKey: string;
|
|
33
|
+
};
|
|
34
|
+
sourceArn?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EmailConfig {
|
|
38
|
+
type: 'smtp' | 'ses';
|
|
39
|
+
from: string;
|
|
40
|
+
options: SMTPConfig | SESConfig;
|
|
41
|
+
templates?: EmailTemplates;
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AuthService } from '../services/auth';
|
|
2
|
+
import type { UserRole } from '../types';
|
|
3
|
+
|
|
4
|
+
export class AuthMiddleware {
|
|
5
|
+
private authService: AuthService;
|
|
6
|
+
|
|
7
|
+
constructor(authService: AuthService) {
|
|
8
|
+
this.authService = authService;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
verifyToken() {
|
|
12
|
+
return async (req: any, res: any, next: any) => {
|
|
13
|
+
try {
|
|
14
|
+
const token = req.headers.authorization?.split(' ')[1];
|
|
15
|
+
if (!token) {
|
|
16
|
+
throw new Error('No token provided');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const decoded = await this.authService.verifyToken(token);
|
|
20
|
+
req.user = decoded;
|
|
21
|
+
next();
|
|
22
|
+
} catch (_error) {
|
|
23
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
requireRoles(roles: UserRole[]) {
|
|
29
|
+
return async (req: any, res: any, next: any) => {
|
|
30
|
+
try {
|
|
31
|
+
const hasRole = await this.authService.hasRole(req.user.id, roles);
|
|
32
|
+
if (!hasRole) {
|
|
33
|
+
throw new Error('Insufficient permissions');
|
|
34
|
+
}
|
|
35
|
+
next();
|
|
36
|
+
} catch (_error) {
|
|
37
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
requireEmailVerified() {
|
|
43
|
+
return (req: any, res: any, next: any) => {
|
|
44
|
+
if (!req.user.isEmailVerified) {
|
|
45
|
+
return res.status(403).json({ error: 'Email not verified' });
|
|
46
|
+
}
|
|
47
|
+
next();
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import type { AuthConfig, User, UserRole } from '../types';
|
|
5
|
+
import type { IStorageProvider } from '../storage/types';
|
|
6
|
+
import type { IEmailProvider } from '../email/types';
|
|
7
|
+
import { createStorageProvider } from '../storage';
|
|
8
|
+
import { createEmailProvider } from '../email';
|
|
9
|
+
|
|
10
|
+
export class AuthService {
|
|
11
|
+
private storage: IStorageProvider;
|
|
12
|
+
private email: IEmailProvider;
|
|
13
|
+
private config: AuthConfig;
|
|
14
|
+
|
|
15
|
+
constructor(config: AuthConfig) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.storage = createStorageProvider(config.storage);
|
|
18
|
+
this.email = createEmailProvider(config.email);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private generateToken(user: User): string {
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
return jwt.sign(
|
|
24
|
+
{
|
|
25
|
+
id: user.id,
|
|
26
|
+
email: user.email,
|
|
27
|
+
roles: user.roles,
|
|
28
|
+
isEmailVerified: user.isEmailVerified,
|
|
29
|
+
},
|
|
30
|
+
this.config.jwt.privateKey,
|
|
31
|
+
{
|
|
32
|
+
algorithm: 'RS256',
|
|
33
|
+
expiresIn: this.config.jwt.expiresIn,
|
|
34
|
+
keyid: this.config.jwt.keyId,
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async hashPassword(password: string): Promise<string> {
|
|
40
|
+
return bcrypt.hash(password, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async signup(
|
|
44
|
+
email: string,
|
|
45
|
+
password: string,
|
|
46
|
+
roles: UserRole[] = ['USER']
|
|
47
|
+
): Promise<{ user: User; token: string }> {
|
|
48
|
+
const existingUser = await this.storage.getUserByEmail(email);
|
|
49
|
+
if (existingUser) {
|
|
50
|
+
throw new Error('User already exists');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const verificationToken = uuidv4();
|
|
54
|
+
const user: User = {
|
|
55
|
+
id: uuidv4(),
|
|
56
|
+
email,
|
|
57
|
+
passwordHash: await this.hashPassword(password),
|
|
58
|
+
roles,
|
|
59
|
+
isEmailVerified: false,
|
|
60
|
+
verificationToken,
|
|
61
|
+
createdAt: Date.now(),
|
|
62
|
+
updatedAt: Date.now(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await this.storage.createUser(user);
|
|
66
|
+
await this.email.sendVerificationEmail(email, verificationToken);
|
|
67
|
+
|
|
68
|
+
const token = this.generateToken(user);
|
|
69
|
+
return { user, token };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async signin(email: string, password: string): Promise<{ user: User; token: string }> {
|
|
73
|
+
const user = await this.storage.getUserByEmail(email);
|
|
74
|
+
if (!user) {
|
|
75
|
+
throw new Error('Invalid credentials');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
|
|
79
|
+
if (!isValidPassword) {
|
|
80
|
+
throw new Error('Invalid credentials');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!user.isEmailVerified) {
|
|
84
|
+
throw new Error('Please verify your email before signing in');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const token = this.generateToken(user);
|
|
88
|
+
return { user, token };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async verifyEmail(token: string): Promise<void> {
|
|
92
|
+
const user = await this.storage.getUserByVerifyEmailToken(token);
|
|
93
|
+
if (!user || user.verificationToken !== token) {
|
|
94
|
+
throw new Error('Invalid verification token');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await this.storage.updateUser(user.id, {
|
|
98
|
+
isEmailVerified: true,
|
|
99
|
+
verificationToken: undefined,
|
|
100
|
+
updatedAt: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async initiatePasswordReset(email: string): Promise<void> {
|
|
105
|
+
const user = await this.storage.getUserByEmail(email);
|
|
106
|
+
if (!user) {
|
|
107
|
+
throw new Error('User not found');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const resetToken = uuidv4();
|
|
111
|
+
const resetExpires = Date.now() + 3600000; // 1 hour
|
|
112
|
+
|
|
113
|
+
await this.storage.updateUser(user.id, {
|
|
114
|
+
resetPasswordToken: resetToken,
|
|
115
|
+
resetPasswordExpires: resetExpires,
|
|
116
|
+
updatedAt: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await this.email.sendPasswordResetEmail(email, resetToken);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async resetPassword(token: string, newPassword: string): Promise<void> {
|
|
123
|
+
const user = await this.storage.getUserByResetPasswordToken(token);
|
|
124
|
+
if (
|
|
125
|
+
!user ||
|
|
126
|
+
!user.resetPasswordToken ||
|
|
127
|
+
user.resetPasswordToken !== token ||
|
|
128
|
+
!user.resetPasswordExpires ||
|
|
129
|
+
user.resetPasswordExpires < Date.now()
|
|
130
|
+
) {
|
|
131
|
+
throw new Error('Invalid or expired reset token');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await this.storage.updateUser(user.id, {
|
|
135
|
+
passwordHash: await this.hashPassword(newPassword),
|
|
136
|
+
resetPasswordToken: undefined,
|
|
137
|
+
resetPasswordExpires: undefined,
|
|
138
|
+
updatedAt: Date.now(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async verifyToken(
|
|
143
|
+
token: string
|
|
144
|
+
): Promise<{ id: string; email: string; roles: UserRole[]; isEmailVerified: boolean }> {
|
|
145
|
+
try {
|
|
146
|
+
return jwt.verify(token, this.config.jwt.publicKey, { algorithms: ['RS256'] }) as {
|
|
147
|
+
id: string;
|
|
148
|
+
email: string;
|
|
149
|
+
roles: UserRole[];
|
|
150
|
+
isEmailVerified: boolean;
|
|
151
|
+
};
|
|
152
|
+
} catch (_error) {
|
|
153
|
+
throw new Error('Invalid token');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async hasRole(userId: string, requiredRoles: UserRole[]): Promise<boolean> {
|
|
158
|
+
const user = await this.storage.getUserById(userId);
|
|
159
|
+
if (!user) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return requiredRoles.some((role) => user.roles.includes(role));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
import {
|
|
3
|
+
DynamoDBDocumentClient,
|
|
4
|
+
PutCommand,
|
|
5
|
+
GetCommand,
|
|
6
|
+
UpdateCommand,
|
|
7
|
+
DeleteCommand,
|
|
8
|
+
QueryCommand,
|
|
9
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
10
|
+
import type { User } from '../types';
|
|
11
|
+
import type { IStorageProvider, DynamoDBConfig } from './types';
|
|
12
|
+
|
|
13
|
+
export class DynamoDBStorageProvider implements IStorageProvider {
|
|
14
|
+
private client: DynamoDBDocumentClient;
|
|
15
|
+
private tableName: string;
|
|
16
|
+
|
|
17
|
+
constructor(config: DynamoDBConfig) {
|
|
18
|
+
const dbClient = new DynamoDBClient({ region: config.region });
|
|
19
|
+
this.client = DynamoDBDocumentClient.from(dbClient, {});
|
|
20
|
+
this.tableName = config.tableName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async createUser(user: User): Promise<void> {
|
|
24
|
+
await this.client.send(
|
|
25
|
+
new PutCommand({
|
|
26
|
+
TableName: this.tableName,
|
|
27
|
+
Item: user,
|
|
28
|
+
ConditionExpression: 'attribute_not_exists(email)',
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getUserByResetPasswordToken(resetPasswordToken: string): Promise<User | null> {
|
|
34
|
+
const response = await this.client.send(
|
|
35
|
+
new QueryCommand({
|
|
36
|
+
TableName: this.tableName,
|
|
37
|
+
IndexName: 'ResetPasswordTokenIndex',
|
|
38
|
+
KeyConditionExpression: 'resetPasswordToken = :token',
|
|
39
|
+
ExpressionAttributeValues: {
|
|
40
|
+
':token': resetPasswordToken,
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return response.Items?.[0] as User | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getUserByVerifyEmailToken(verifyEmailToken: string): Promise<User | null> {
|
|
49
|
+
const response = await this.client.send(
|
|
50
|
+
new QueryCommand({
|
|
51
|
+
TableName: this.tableName,
|
|
52
|
+
IndexName: 'VerifyEmailTokenIndex',
|
|
53
|
+
KeyConditionExpression: 'verificationToken = :token',
|
|
54
|
+
ExpressionAttributeValues: {
|
|
55
|
+
':token': verifyEmailToken,
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return response.Items?.[0] as User | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getUserById(id: string): Promise<User | null> {
|
|
64
|
+
const response = await this.client.send(
|
|
65
|
+
new GetCommand({
|
|
66
|
+
TableName: this.tableName,
|
|
67
|
+
Key: { id },
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return response.Item as User | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getUserByEmail(email: string): Promise<User | null> {
|
|
75
|
+
const response = await this.client.send(
|
|
76
|
+
new QueryCommand({
|
|
77
|
+
TableName: this.tableName,
|
|
78
|
+
IndexName: 'EmailIndex',
|
|
79
|
+
KeyConditionExpression: 'email = :email',
|
|
80
|
+
ExpressionAttributeValues: {
|
|
81
|
+
':email': email,
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return response.Items?.[0] as User | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async updateUser(id: string, updates: Partial<User>): Promise<void> {
|
|
90
|
+
const toSet: Record<string, any> = {};
|
|
91
|
+
const toRemove: string[] = [];
|
|
92
|
+
|
|
93
|
+
// Separate attributes to set and remove
|
|
94
|
+
Object.entries(updates).forEach(([key, value]) => {
|
|
95
|
+
if (value === undefined) {
|
|
96
|
+
toRemove.push(key);
|
|
97
|
+
} else {
|
|
98
|
+
toSet[key] = value;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// If no updates at all, return early
|
|
103
|
+
if (Object.keys(toSet).length === 0 && toRemove.length === 0) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Build the update expression
|
|
108
|
+
const setPart =
|
|
109
|
+
Object.keys(toSet).length > 0
|
|
110
|
+
? `SET ${Object.keys(toSet)
|
|
111
|
+
.map((key) => `#${key} = :${key}`)
|
|
112
|
+
.join(', ')}`
|
|
113
|
+
: '';
|
|
114
|
+
|
|
115
|
+
const removePart =
|
|
116
|
+
toRemove.length > 0 ? `REMOVE ${toRemove.map((key) => `#${key}`).join(', ')}` : '';
|
|
117
|
+
|
|
118
|
+
const updateExpression = [setPart, removePart].filter(Boolean).join(' ');
|
|
119
|
+
|
|
120
|
+
// Build expression attribute names (needed for both SET and REMOVE)
|
|
121
|
+
const expressionAttributeNames = [...Object.keys(toSet), ...toRemove].reduce(
|
|
122
|
+
(acc, key) => ({ ...acc, [`#${key}`]: key }),
|
|
123
|
+
{}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Build expression attribute values (only needed for SET)
|
|
127
|
+
const expressionAttributeValues = Object.entries(toSet).reduce(
|
|
128
|
+
(acc, [key, value]) => ({ ...acc, [`:${key}`]: value }),
|
|
129
|
+
{}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await this.client.send(
|
|
133
|
+
new UpdateCommand({
|
|
134
|
+
TableName: this.tableName,
|
|
135
|
+
Key: { id },
|
|
136
|
+
UpdateExpression: updateExpression,
|
|
137
|
+
ExpressionAttributeNames: expressionAttributeNames,
|
|
138
|
+
...(Object.keys(expressionAttributeValues).length > 0 && {
|
|
139
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
140
|
+
}),
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async deleteUser(id: string): Promise<void> {
|
|
146
|
+
await this.client.send(
|
|
147
|
+
new DeleteCommand({
|
|
148
|
+
TableName: this.tableName,
|
|
149
|
+
Key: { id },
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DynamoDBConfig, IStorageProvider, StorageConfig } from './types';
|
|
2
|
+
import { DynamoDBStorageProvider } from './dynamodb';
|
|
3
|
+
|
|
4
|
+
export function createStorageProvider(config: StorageConfig): IStorageProvider {
|
|
5
|
+
switch (config.type) {
|
|
6
|
+
case 'dynamodb':
|
|
7
|
+
return new DynamoDBStorageProvider(config.options as DynamoDBConfig);
|
|
8
|
+
case 'mongodb':
|
|
9
|
+
throw new Error('MongoDB storage provider not implemented yet');
|
|
10
|
+
case 'postgres':
|
|
11
|
+
throw new Error('PostgreSQL storage provider not implemented yet');
|
|
12
|
+
default:
|
|
13
|
+
throw new Error(`Unsupported storage type: ${config.type}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export * from './types';
|
|
18
|
+
export * from './dynamodb';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { User } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface IStorageProvider {
|
|
4
|
+
createUser(user: User): Promise<void>;
|
|
5
|
+
getUserById(id: string): Promise<User | null>;
|
|
6
|
+
getUserByEmail(email: string): Promise<User | null>;
|
|
7
|
+
getUserByResetPasswordToken(resetPasswordToken: string): Promise<User | null>;
|
|
8
|
+
getUserByVerifyEmailToken(resetPasswordToken: string): Promise<User | null>;
|
|
9
|
+
updateUser(id: string, updates: Partial<User>): Promise<void>;
|
|
10
|
+
deleteUser(id: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StorageConfig {
|
|
14
|
+
type: 'dynamodb' | 'mongodb' | 'postgres';
|
|
15
|
+
options: DynamoDBConfig | MongoDBConfig | PostgresConfig;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DynamoDBConfig {
|
|
19
|
+
tableName: string;
|
|
20
|
+
region: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MongoDBConfig {
|
|
24
|
+
uri: string;
|
|
25
|
+
dbName: string;
|
|
26
|
+
collectionName: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PostgresConfig {
|
|
30
|
+
connectionString: string;
|
|
31
|
+
schema?: string;
|
|
32
|
+
tableName: string;
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { StorageConfig } from '../storage/types';
|
|
3
|
+
import type { EmailConfig } from '../email/types';
|
|
4
|
+
|
|
5
|
+
export const UserRoleSchema = z.enum(['ADMIN', 'USER', 'GUEST']);
|
|
6
|
+
export type UserRole = z.infer<typeof UserRoleSchema>;
|
|
7
|
+
|
|
8
|
+
export const UserSchema = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
email: z.string().email(),
|
|
11
|
+
passwordHash: z.string(),
|
|
12
|
+
roles: z.array(UserRoleSchema),
|
|
13
|
+
isEmailVerified: z.boolean(),
|
|
14
|
+
verificationToken: z.string().optional(),
|
|
15
|
+
resetPasswordToken: z.string().optional(),
|
|
16
|
+
resetPasswordExpires: z.number().optional(),
|
|
17
|
+
createdAt: z.number(),
|
|
18
|
+
updatedAt: z.number(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type User = z.infer<typeof UserSchema>;
|
|
22
|
+
|
|
23
|
+
export interface AuthConfig {
|
|
24
|
+
jwt: {
|
|
25
|
+
privateKey: string;
|
|
26
|
+
publicKey: string;
|
|
27
|
+
keyId: string;
|
|
28
|
+
expiresIn: string;
|
|
29
|
+
};
|
|
30
|
+
storage: StorageConfig;
|
|
31
|
+
email: EmailConfig;
|
|
32
|
+
}
|
package/tsconfig.json
ADDED