servcraft 0.1.0 → 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/.claude/settings.local.json +29 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/README.md +1070 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Repository
|
|
3
|
+
* Prisma-based persistence for linked OAuth accounts
|
|
4
|
+
*/
|
|
5
|
+
import { Prisma } from '@prisma/client';
|
|
6
|
+
import type {
|
|
7
|
+
LinkedAccount as PrismaLinkedAccount,
|
|
8
|
+
OAuthProvider as PrismaOAuthProvider,
|
|
9
|
+
PrismaClient,
|
|
10
|
+
} from '@prisma/client';
|
|
11
|
+
import type { LinkedAccount, OAuthProvider } from './types.js';
|
|
12
|
+
|
|
13
|
+
// Enum mappings (Prisma UPPERCASE ↔ Application lowercase)
|
|
14
|
+
const providerToPrisma: Record<OAuthProvider, PrismaOAuthProvider> = {
|
|
15
|
+
google: 'GOOGLE',
|
|
16
|
+
facebook: 'FACEBOOK',
|
|
17
|
+
github: 'GITHUB',
|
|
18
|
+
twitter: 'TWITTER',
|
|
19
|
+
apple: 'APPLE',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const providerFromPrisma: Record<PrismaOAuthProvider, OAuthProvider> = {
|
|
23
|
+
GOOGLE: 'google',
|
|
24
|
+
FACEBOOK: 'facebook',
|
|
25
|
+
GITHUB: 'github',
|
|
26
|
+
TWITTER: 'twitter',
|
|
27
|
+
APPLE: 'apple',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class OAuthRepository {
|
|
31
|
+
constructor(private prisma: PrismaClient) {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a linked account
|
|
35
|
+
*/
|
|
36
|
+
async create(
|
|
37
|
+
data: Omit<LinkedAccount, 'id' | 'createdAt' | 'updatedAt'>
|
|
38
|
+
): Promise<LinkedAccount> {
|
|
39
|
+
const account = await this.prisma.linkedAccount.create({
|
|
40
|
+
data: {
|
|
41
|
+
userId: data.userId,
|
|
42
|
+
provider: providerToPrisma[data.provider],
|
|
43
|
+
providerAccountId: data.providerAccountId,
|
|
44
|
+
email: data.email,
|
|
45
|
+
name: data.name,
|
|
46
|
+
picture: data.picture,
|
|
47
|
+
accessToken: data.accessToken,
|
|
48
|
+
refreshToken: data.refreshToken,
|
|
49
|
+
expiresAt: data.expiresAt,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return this.mapFromPrisma(account);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get linked account by ID
|
|
58
|
+
*/
|
|
59
|
+
async getById(id: string): Promise<LinkedAccount | null> {
|
|
60
|
+
const account = await this.prisma.linkedAccount.findUnique({
|
|
61
|
+
where: { id },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return account ? this.mapFromPrisma(account) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find linked account by provider and provider account ID
|
|
69
|
+
*/
|
|
70
|
+
async findByProviderAccount(
|
|
71
|
+
provider: OAuthProvider,
|
|
72
|
+
providerAccountId: string
|
|
73
|
+
): Promise<LinkedAccount | null> {
|
|
74
|
+
const account = await this.prisma.linkedAccount.findUnique({
|
|
75
|
+
where: {
|
|
76
|
+
provider_providerAccountId: {
|
|
77
|
+
provider: providerToPrisma[provider],
|
|
78
|
+
providerAccountId,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return account ? this.mapFromPrisma(account) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get all linked accounts for a user
|
|
88
|
+
*/
|
|
89
|
+
async getByUserId(userId: string): Promise<LinkedAccount[]> {
|
|
90
|
+
const accounts = await this.prisma.linkedAccount.findMany({
|
|
91
|
+
where: { userId },
|
|
92
|
+
orderBy: { createdAt: 'desc' },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return accounts.map((a) => this.mapFromPrisma(a));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get linked account by user and provider
|
|
100
|
+
*/
|
|
101
|
+
async getByUserAndProvider(
|
|
102
|
+
userId: string,
|
|
103
|
+
provider: OAuthProvider
|
|
104
|
+
): Promise<LinkedAccount | null> {
|
|
105
|
+
const account = await this.prisma.linkedAccount.findFirst({
|
|
106
|
+
where: {
|
|
107
|
+
userId,
|
|
108
|
+
provider: providerToPrisma[provider],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return account ? this.mapFromPrisma(account) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Update linked account
|
|
117
|
+
*/
|
|
118
|
+
async update(
|
|
119
|
+
id: string,
|
|
120
|
+
data: Partial<
|
|
121
|
+
Pick<
|
|
122
|
+
LinkedAccount,
|
|
123
|
+
'email' | 'name' | 'picture' | 'accessToken' | 'refreshToken' | 'expiresAt'
|
|
124
|
+
>
|
|
125
|
+
>
|
|
126
|
+
): Promise<LinkedAccount | null> {
|
|
127
|
+
try {
|
|
128
|
+
const updateData: Prisma.LinkedAccountUpdateInput = {};
|
|
129
|
+
|
|
130
|
+
if (data.email !== undefined) updateData.email = data.email;
|
|
131
|
+
if (data.name !== undefined) updateData.name = data.name;
|
|
132
|
+
if (data.picture !== undefined) updateData.picture = data.picture;
|
|
133
|
+
if (data.accessToken !== undefined) updateData.accessToken = data.accessToken;
|
|
134
|
+
if (data.refreshToken !== undefined) updateData.refreshToken = data.refreshToken;
|
|
135
|
+
if (data.expiresAt !== undefined) updateData.expiresAt = data.expiresAt;
|
|
136
|
+
|
|
137
|
+
const account = await this.prisma.linkedAccount.update({
|
|
138
|
+
where: { id },
|
|
139
|
+
data: updateData,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return this.mapFromPrisma(account);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Delete linked account by ID
|
|
153
|
+
*/
|
|
154
|
+
async delete(id: string): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
await this.prisma.linkedAccount.delete({ where: { id } });
|
|
157
|
+
return true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Delete linked account by user and provider
|
|
168
|
+
*/
|
|
169
|
+
async deleteByUserAndProvider(userId: string, provider: OAuthProvider): Promise<boolean> {
|
|
170
|
+
const result = await this.prisma.linkedAccount.deleteMany({
|
|
171
|
+
where: {
|
|
172
|
+
userId,
|
|
173
|
+
provider: providerToPrisma[provider],
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return result.count > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Delete all linked accounts for a user
|
|
182
|
+
*/
|
|
183
|
+
async deleteByUserId(userId: string): Promise<number> {
|
|
184
|
+
const result = await this.prisma.linkedAccount.deleteMany({
|
|
185
|
+
where: { userId },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return result.count;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Count linked accounts for a user
|
|
193
|
+
*/
|
|
194
|
+
async countByUser(userId: string): Promise<number> {
|
|
195
|
+
return this.prisma.linkedAccount.count({
|
|
196
|
+
where: { userId },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Map Prisma model to application type
|
|
202
|
+
*/
|
|
203
|
+
private mapFromPrisma(prismaAccount: PrismaLinkedAccount): LinkedAccount {
|
|
204
|
+
return {
|
|
205
|
+
id: prismaAccount.id,
|
|
206
|
+
userId: prismaAccount.userId,
|
|
207
|
+
provider: providerFromPrisma[prismaAccount.provider],
|
|
208
|
+
providerAccountId: prismaAccount.providerAccountId,
|
|
209
|
+
email: prismaAccount.email || undefined,
|
|
210
|
+
name: prismaAccount.name || undefined,
|
|
211
|
+
picture: prismaAccount.picture || undefined,
|
|
212
|
+
accessToken: prismaAccount.accessToken || undefined,
|
|
213
|
+
refreshToken: prismaAccount.refreshToken || undefined,
|
|
214
|
+
expiresAt: prismaAccount.expiresAt || undefined,
|
|
215
|
+
createdAt: prismaAccount.createdAt,
|
|
216
|
+
updatedAt: prismaAccount.updatedAt,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import type { AuthService } from '../auth/auth.service.js';
|
|
3
|
+
import { createAuthMiddleware } from '../auth/auth.middleware.js';
|
|
4
|
+
import { commonResponses } from '../swagger/index.js';
|
|
5
|
+
import { getOAuthService } from './oauth.service.js';
|
|
6
|
+
import type { OAuthProvider, OAuthCallbackParams } from './types.js';
|
|
7
|
+
|
|
8
|
+
const oauthTag = 'OAuth';
|
|
9
|
+
|
|
10
|
+
export function registerOAuthRoutes(app: FastifyInstance, authService: AuthService): void {
|
|
11
|
+
const authenticate = createAuthMiddleware(authService);
|
|
12
|
+
const oauthService = getOAuthService();
|
|
13
|
+
|
|
14
|
+
// Get supported providers
|
|
15
|
+
app.get(
|
|
16
|
+
'/auth/oauth/providers',
|
|
17
|
+
{
|
|
18
|
+
schema: {
|
|
19
|
+
tags: [oauthTag],
|
|
20
|
+
summary: 'Get supported OAuth providers',
|
|
21
|
+
response: {
|
|
22
|
+
200: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
success: { type: 'boolean' },
|
|
26
|
+
data: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
providers: {
|
|
30
|
+
type: 'array',
|
|
31
|
+
items: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
44
|
+
const providers = oauthService.getSupportedProviders();
|
|
45
|
+
return reply.send({ success: true, data: { providers } });
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Initiate OAuth flow
|
|
50
|
+
app.get(
|
|
51
|
+
'/auth/oauth/:provider',
|
|
52
|
+
{
|
|
53
|
+
schema: {
|
|
54
|
+
tags: [oauthTag],
|
|
55
|
+
summary: 'Initiate OAuth authentication',
|
|
56
|
+
description: 'Redirects to the OAuth provider for authentication',
|
|
57
|
+
params: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
provider: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ['provider'],
|
|
66
|
+
},
|
|
67
|
+
querystring: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
redirect: { type: 'string', description: 'URL to redirect after auth' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
response: {
|
|
74
|
+
302: { description: 'Redirect to OAuth provider' },
|
|
75
|
+
400: commonResponses.error,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
async (
|
|
80
|
+
request: FastifyRequest<{
|
|
81
|
+
Params: { provider: string };
|
|
82
|
+
Querystring: { redirect?: string };
|
|
83
|
+
}>,
|
|
84
|
+
reply: FastifyReply
|
|
85
|
+
) => {
|
|
86
|
+
const provider = request.params.provider as OAuthProvider;
|
|
87
|
+
|
|
88
|
+
if (!oauthService.isProviderEnabled(provider)) {
|
|
89
|
+
return reply.status(400).send({
|
|
90
|
+
success: false,
|
|
91
|
+
message: `OAuth provider '${provider}' is not configured`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { url, state } = await oauthService.getAuthorizationUrl(provider);
|
|
96
|
+
|
|
97
|
+
// Store redirect URL in cookie if provided
|
|
98
|
+
if (request.query.redirect) {
|
|
99
|
+
reply.setCookie('oauth_redirect', request.query.redirect, {
|
|
100
|
+
httpOnly: true,
|
|
101
|
+
secure: process.env.NODE_ENV === 'production',
|
|
102
|
+
maxAge: 600, // 10 minutes
|
|
103
|
+
path: '/',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Store state in cookie for CSRF protection
|
|
108
|
+
reply.setCookie('oauth_state', state, {
|
|
109
|
+
httpOnly: true,
|
|
110
|
+
secure: process.env.NODE_ENV === 'production',
|
|
111
|
+
maxAge: 600,
|
|
112
|
+
path: '/',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return reply.redirect(url);
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// OAuth callback
|
|
120
|
+
app.get(
|
|
121
|
+
'/auth/oauth/:provider/callback',
|
|
122
|
+
{
|
|
123
|
+
schema: {
|
|
124
|
+
tags: [oauthTag],
|
|
125
|
+
summary: 'OAuth callback endpoint',
|
|
126
|
+
description: 'Handles the OAuth provider callback after authentication',
|
|
127
|
+
params: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
provider: {
|
|
131
|
+
type: 'string',
|
|
132
|
+
enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
required: ['provider'],
|
|
136
|
+
},
|
|
137
|
+
querystring: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
code: { type: 'string' },
|
|
141
|
+
state: { type: 'string' },
|
|
142
|
+
error: { type: 'string' },
|
|
143
|
+
error_description: { type: 'string' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
response: {
|
|
147
|
+
200: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
success: { type: 'boolean' },
|
|
151
|
+
data: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
user: { type: 'object' },
|
|
155
|
+
accessToken: { type: 'string' },
|
|
156
|
+
refreshToken: { type: 'string' },
|
|
157
|
+
isNewUser: { type: 'boolean' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
400: commonResponses.error,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
async (
|
|
167
|
+
request: FastifyRequest<{
|
|
168
|
+
Params: { provider: string };
|
|
169
|
+
Querystring: OAuthCallbackParams;
|
|
170
|
+
}>,
|
|
171
|
+
reply: FastifyReply
|
|
172
|
+
) => {
|
|
173
|
+
const provider = request.params.provider as OAuthProvider;
|
|
174
|
+
const { code, state, error, errorDescription } = request.query;
|
|
175
|
+
|
|
176
|
+
// Check for OAuth error
|
|
177
|
+
if (error) {
|
|
178
|
+
return reply.status(400).send({
|
|
179
|
+
success: false,
|
|
180
|
+
message: errorDescription || error,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!code || !state) {
|
|
185
|
+
return reply.status(400).send({
|
|
186
|
+
success: false,
|
|
187
|
+
message: 'Missing code or state parameter',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Verify state from cookie
|
|
192
|
+
const storedState = request.cookies.oauth_state;
|
|
193
|
+
if (storedState !== state) {
|
|
194
|
+
return reply.status(400).send({
|
|
195
|
+
success: false,
|
|
196
|
+
message: 'Invalid state parameter',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Clear state cookie
|
|
201
|
+
reply.clearCookie('oauth_state', { path: '/' });
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
// Handle OAuth callback
|
|
205
|
+
const oauthUser = await oauthService.handleCallback(provider, code, state);
|
|
206
|
+
|
|
207
|
+
// Check if user exists by linked account
|
|
208
|
+
let userId = await oauthService.findUserByOAuth(provider, oauthUser.providerAccountId);
|
|
209
|
+
let isNewUser = false;
|
|
210
|
+
|
|
211
|
+
if (!userId) {
|
|
212
|
+
// Check if user exists by email
|
|
213
|
+
if (oauthUser.email) {
|
|
214
|
+
const existingUser = await authService.findUserByEmail(oauthUser.email);
|
|
215
|
+
if (existingUser) {
|
|
216
|
+
userId = existingUser.id;
|
|
217
|
+
// Link the account
|
|
218
|
+
await oauthService.linkAccount(userId, oauthUser);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create new user if not found
|
|
223
|
+
if (!userId) {
|
|
224
|
+
const newUser = await authService.createUserFromOAuth({
|
|
225
|
+
email: oauthUser.email || `${oauthUser.providerAccountId}@${provider}.oauth`,
|
|
226
|
+
name: oauthUser.name ?? undefined,
|
|
227
|
+
picture: oauthUser.picture ?? undefined,
|
|
228
|
+
emailVerified: oauthUser.emailVerified,
|
|
229
|
+
});
|
|
230
|
+
userId = newUser.id;
|
|
231
|
+
isNewUser = true;
|
|
232
|
+
await oauthService.linkAccount(userId, oauthUser);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Generate JWT tokens
|
|
237
|
+
const tokens = await authService.generateTokensForUser(userId);
|
|
238
|
+
|
|
239
|
+
// Get redirect URL from cookie
|
|
240
|
+
const redirectUrl = request.cookies.oauth_redirect;
|
|
241
|
+
reply.clearCookie('oauth_redirect', { path: '/' });
|
|
242
|
+
|
|
243
|
+
// If redirect URL is provided, redirect with tokens
|
|
244
|
+
if (redirectUrl) {
|
|
245
|
+
const url = new URL(redirectUrl);
|
|
246
|
+
url.searchParams.set('access_token', tokens.accessToken);
|
|
247
|
+
url.searchParams.set('refresh_token', tokens.refreshToken);
|
|
248
|
+
url.searchParams.set('is_new_user', String(isNewUser));
|
|
249
|
+
return reply.redirect(url.toString());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Otherwise return JSON response
|
|
253
|
+
return reply.send({
|
|
254
|
+
success: true,
|
|
255
|
+
data: {
|
|
256
|
+
user: {
|
|
257
|
+
id: userId,
|
|
258
|
+
email: oauthUser.email,
|
|
259
|
+
name: oauthUser.name,
|
|
260
|
+
picture: oauthUser.picture,
|
|
261
|
+
},
|
|
262
|
+
accessToken: tokens.accessToken,
|
|
263
|
+
refreshToken: tokens.refreshToken,
|
|
264
|
+
isNewUser,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const message = err instanceof Error ? err.message : 'OAuth authentication failed';
|
|
269
|
+
return reply.status(400).send({ success: false, message });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Link OAuth account (authenticated users)
|
|
275
|
+
app.post(
|
|
276
|
+
'/auth/oauth/:provider/link',
|
|
277
|
+
{
|
|
278
|
+
preHandler: [authenticate],
|
|
279
|
+
schema: {
|
|
280
|
+
tags: [oauthTag],
|
|
281
|
+
summary: 'Link OAuth account to current user',
|
|
282
|
+
description: 'Initiates the process to link an OAuth account to the authenticated user',
|
|
283
|
+
security: [{ bearerAuth: [] }],
|
|
284
|
+
params: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
properties: {
|
|
287
|
+
provider: {
|
|
288
|
+
type: 'string',
|
|
289
|
+
enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
required: ['provider'],
|
|
293
|
+
},
|
|
294
|
+
response: {
|
|
295
|
+
200: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
properties: {
|
|
298
|
+
success: { type: 'boolean' },
|
|
299
|
+
data: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: {
|
|
302
|
+
authUrl: { type: 'string' },
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
400: commonResponses.error,
|
|
308
|
+
401: commonResponses.unauthorized,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
313
|
+
const params = request.params as { provider: string };
|
|
314
|
+
const provider = params.provider as OAuthProvider;
|
|
315
|
+
const user = (request as FastifyRequest & { user: { id: string } }).user;
|
|
316
|
+
|
|
317
|
+
if (!oauthService.isProviderEnabled(provider)) {
|
|
318
|
+
return reply.status(400).send({
|
|
319
|
+
success: false,
|
|
320
|
+
message: `OAuth provider '${provider}' is not configured`,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { url, state } = await oauthService.getAuthorizationUrl(provider);
|
|
325
|
+
|
|
326
|
+
// Store user ID in cookie for linking after callback
|
|
327
|
+
reply.setCookie('oauth_link_user', user.id, {
|
|
328
|
+
httpOnly: true,
|
|
329
|
+
secure: process.env.NODE_ENV === 'production',
|
|
330
|
+
maxAge: 600,
|
|
331
|
+
path: '/',
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
reply.setCookie('oauth_state', state, {
|
|
335
|
+
httpOnly: true,
|
|
336
|
+
secure: process.env.NODE_ENV === 'production',
|
|
337
|
+
maxAge: 600,
|
|
338
|
+
path: '/',
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return reply.send({ success: true, data: { authUrl: url } });
|
|
342
|
+
}
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// Unlink OAuth account
|
|
346
|
+
app.delete(
|
|
347
|
+
'/auth/oauth/:provider/unlink',
|
|
348
|
+
{
|
|
349
|
+
preHandler: [authenticate],
|
|
350
|
+
schema: {
|
|
351
|
+
tags: [oauthTag],
|
|
352
|
+
summary: 'Unlink OAuth account from current user',
|
|
353
|
+
security: [{ bearerAuth: [] }],
|
|
354
|
+
params: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
provider: {
|
|
358
|
+
type: 'string',
|
|
359
|
+
enum: ['google', 'facebook', 'github', 'twitter', 'apple'],
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
required: ['provider'],
|
|
363
|
+
},
|
|
364
|
+
response: {
|
|
365
|
+
200: {
|
|
366
|
+
type: 'object',
|
|
367
|
+
properties: {
|
|
368
|
+
success: { type: 'boolean' },
|
|
369
|
+
message: { type: 'string' },
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
400: commonResponses.error,
|
|
373
|
+
401: commonResponses.unauthorized,
|
|
374
|
+
404: commonResponses.notFound,
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
379
|
+
const params = request.params as { provider: string };
|
|
380
|
+
const provider = params.provider as OAuthProvider;
|
|
381
|
+
const user = (request as FastifyRequest & { user: { id: string } }).user;
|
|
382
|
+
|
|
383
|
+
await oauthService.unlinkAccount(user.id, provider);
|
|
384
|
+
|
|
385
|
+
return reply.send({
|
|
386
|
+
success: true,
|
|
387
|
+
message: `${provider} account unlinked successfully`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Get linked accounts
|
|
393
|
+
app.get(
|
|
394
|
+
'/auth/oauth/linked',
|
|
395
|
+
{
|
|
396
|
+
preHandler: [authenticate],
|
|
397
|
+
schema: {
|
|
398
|
+
tags: [oauthTag],
|
|
399
|
+
summary: 'Get linked OAuth accounts',
|
|
400
|
+
security: [{ bearerAuth: [] }],
|
|
401
|
+
response: {
|
|
402
|
+
200: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: {
|
|
405
|
+
success: { type: 'boolean' },
|
|
406
|
+
data: {
|
|
407
|
+
type: 'object',
|
|
408
|
+
properties: {
|
|
409
|
+
accounts: {
|
|
410
|
+
type: 'array',
|
|
411
|
+
items: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: {
|
|
414
|
+
provider: { type: 'string' },
|
|
415
|
+
email: { type: 'string' },
|
|
416
|
+
name: { type: 'string' },
|
|
417
|
+
picture: { type: 'string' },
|
|
418
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
401: commonResponses.unauthorized,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
431
|
+
const user = (request as FastifyRequest & { user: { id: string } }).user;
|
|
432
|
+
const accounts = await oauthService.getUserLinkedAccounts(user.id);
|
|
433
|
+
|
|
434
|
+
// Remove sensitive data
|
|
435
|
+
const safeAccounts = accounts.map((account) => ({
|
|
436
|
+
provider: account.provider,
|
|
437
|
+
email: account.email,
|
|
438
|
+
name: account.name,
|
|
439
|
+
picture: account.picture,
|
|
440
|
+
createdAt: account.createdAt,
|
|
441
|
+
}));
|
|
442
|
+
|
|
443
|
+
return reply.send({ success: true, data: { accounts: safeAccounts } });
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
}
|