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.
Files changed (216) hide show
  1. package/.claude/settings.local.json +29 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/README.md +1070 -1
  9. package/dist/cli/index.cjs +2026 -2168
  10. package/dist/cli/index.cjs.map +1 -1
  11. package/dist/cli/index.js +2026 -2168
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/index.cjs +595 -616
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +114 -52
  16. package/dist/index.d.ts +114 -52
  17. package/dist/index.js +595 -616
  18. package/dist/index.js.map +1 -1
  19. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  20. package/docs/DATABASE_MULTI_ORM.md +399 -0
  21. package/docs/PHASE1_BREAKDOWN.md +346 -0
  22. package/docs/PROGRESS.md +550 -0
  23. package/docs/modules/ANALYTICS.md +226 -0
  24. package/docs/modules/API-VERSIONING.md +252 -0
  25. package/docs/modules/AUDIT.md +192 -0
  26. package/docs/modules/AUTH.md +431 -0
  27. package/docs/modules/CACHE.md +346 -0
  28. package/docs/modules/EMAIL.md +254 -0
  29. package/docs/modules/FEATURE-FLAG.md +291 -0
  30. package/docs/modules/I18N.md +294 -0
  31. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  32. package/docs/modules/MFA.md +266 -0
  33. package/docs/modules/NOTIFICATION.md +311 -0
  34. package/docs/modules/OAUTH.md +237 -0
  35. package/docs/modules/PAYMENT.md +804 -0
  36. package/docs/modules/QUEUE.md +540 -0
  37. package/docs/modules/RATE-LIMIT.md +339 -0
  38. package/docs/modules/SEARCH.md +288 -0
  39. package/docs/modules/SECURITY.md +327 -0
  40. package/docs/modules/SESSION.md +382 -0
  41. package/docs/modules/SWAGGER.md +305 -0
  42. package/docs/modules/UPLOAD.md +296 -0
  43. package/docs/modules/USER.md +505 -0
  44. package/docs/modules/VALIDATION.md +294 -0
  45. package/docs/modules/WEBHOOK.md +270 -0
  46. package/docs/modules/WEBSOCKET.md +691 -0
  47. package/package.json +53 -38
  48. package/prisma/schema.prisma +395 -1
  49. package/src/cli/commands/add-module.ts +520 -87
  50. package/src/cli/commands/db.ts +3 -4
  51. package/src/cli/commands/docs.ts +256 -6
  52. package/src/cli/commands/generate.ts +12 -19
  53. package/src/cli/commands/init.ts +384 -214
  54. package/src/cli/index.ts +0 -4
  55. package/src/cli/templates/repository.ts +6 -1
  56. package/src/cli/templates/routes.ts +6 -21
  57. package/src/cli/utils/docs-generator.ts +6 -7
  58. package/src/cli/utils/env-manager.ts +717 -0
  59. package/src/cli/utils/field-parser.ts +16 -7
  60. package/src/cli/utils/interactive-prompt.ts +223 -0
  61. package/src/cli/utils/template-manager.ts +346 -0
  62. package/src/config/database.config.ts +183 -0
  63. package/src/config/env.ts +0 -10
  64. package/src/config/index.ts +0 -14
  65. package/src/core/server.ts +1 -1
  66. package/src/database/adapters/mongoose.adapter.ts +132 -0
  67. package/src/database/adapters/prisma.adapter.ts +118 -0
  68. package/src/database/connection.ts +190 -0
  69. package/src/database/interfaces/database.interface.ts +85 -0
  70. package/src/database/interfaces/index.ts +7 -0
  71. package/src/database/interfaces/repository.interface.ts +129 -0
  72. package/src/database/models/mongoose/index.ts +7 -0
  73. package/src/database/models/mongoose/payment.schema.ts +347 -0
  74. package/src/database/models/mongoose/user.schema.ts +154 -0
  75. package/src/database/prisma.ts +1 -4
  76. package/src/database/redis.ts +101 -0
  77. package/src/database/repositories/mongoose/index.ts +7 -0
  78. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  79. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  80. package/src/database/seed.ts +6 -1
  81. package/src/index.ts +9 -20
  82. package/src/middleware/security.ts +2 -6
  83. package/src/modules/analytics/analytics.routes.ts +80 -0
  84. package/src/modules/analytics/analytics.service.ts +364 -0
  85. package/src/modules/analytics/index.ts +18 -0
  86. package/src/modules/analytics/types.ts +180 -0
  87. package/src/modules/api-versioning/index.ts +15 -0
  88. package/src/modules/api-versioning/types.ts +86 -0
  89. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  90. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  91. package/src/modules/api-versioning/versioning.service.ts +189 -0
  92. package/src/modules/audit/audit.repository.ts +206 -0
  93. package/src/modules/audit/audit.service.ts +27 -59
  94. package/src/modules/auth/auth.controller.ts +2 -2
  95. package/src/modules/auth/auth.middleware.ts +3 -9
  96. package/src/modules/auth/auth.routes.ts +10 -107
  97. package/src/modules/auth/auth.service.ts +126 -23
  98. package/src/modules/auth/index.ts +3 -4
  99. package/src/modules/cache/cache.service.ts +367 -0
  100. package/src/modules/cache/index.ts +10 -0
  101. package/src/modules/cache/types.ts +44 -0
  102. package/src/modules/email/email.service.ts +3 -10
  103. package/src/modules/email/templates.ts +2 -8
  104. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  105. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  106. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  107. package/src/modules/feature-flag/index.ts +20 -0
  108. package/src/modules/feature-flag/types.ts +192 -0
  109. package/src/modules/i18n/i18n.middleware.ts +186 -0
  110. package/src/modules/i18n/i18n.routes.ts +191 -0
  111. package/src/modules/i18n/i18n.service.ts +456 -0
  112. package/src/modules/i18n/index.ts +18 -0
  113. package/src/modules/i18n/types.ts +118 -0
  114. package/src/modules/media-processing/index.ts +17 -0
  115. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  116. package/src/modules/media-processing/media-processing.service.ts +245 -0
  117. package/src/modules/media-processing/types.ts +156 -0
  118. package/src/modules/mfa/index.ts +20 -0
  119. package/src/modules/mfa/mfa.repository.ts +206 -0
  120. package/src/modules/mfa/mfa.routes.ts +595 -0
  121. package/src/modules/mfa/mfa.service.ts +572 -0
  122. package/src/modules/mfa/totp.ts +150 -0
  123. package/src/modules/mfa/types.ts +57 -0
  124. package/src/modules/notification/index.ts +20 -0
  125. package/src/modules/notification/notification.repository.ts +356 -0
  126. package/src/modules/notification/notification.service.ts +483 -0
  127. package/src/modules/notification/types.ts +119 -0
  128. package/src/modules/oauth/index.ts +20 -0
  129. package/src/modules/oauth/oauth.repository.ts +219 -0
  130. package/src/modules/oauth/oauth.routes.ts +446 -0
  131. package/src/modules/oauth/oauth.service.ts +293 -0
  132. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  133. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  134. package/src/modules/oauth/providers/github.provider.ts +248 -0
  135. package/src/modules/oauth/providers/google.provider.ts +189 -0
  136. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  137. package/src/modules/oauth/types.ts +94 -0
  138. package/src/modules/payment/index.ts +19 -0
  139. package/src/modules/payment/payment.repository.ts +733 -0
  140. package/src/modules/payment/payment.routes.ts +390 -0
  141. package/src/modules/payment/payment.service.ts +354 -0
  142. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  143. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  144. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  145. package/src/modules/payment/types.ts +140 -0
  146. package/src/modules/queue/cron.ts +438 -0
  147. package/src/modules/queue/index.ts +87 -0
  148. package/src/modules/queue/queue.routes.ts +600 -0
  149. package/src/modules/queue/queue.service.ts +842 -0
  150. package/src/modules/queue/types.ts +222 -0
  151. package/src/modules/queue/workers.ts +366 -0
  152. package/src/modules/rate-limit/index.ts +59 -0
  153. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  154. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  155. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  156. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  157. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  158. package/src/modules/rate-limit/types.ts +153 -0
  159. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  160. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  161. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  162. package/src/modules/search/index.ts +21 -0
  163. package/src/modules/search/search.service.ts +234 -0
  164. package/src/modules/search/types.ts +214 -0
  165. package/src/modules/security/index.ts +40 -0
  166. package/src/modules/security/sanitize.ts +223 -0
  167. package/src/modules/security/security-audit.service.ts +388 -0
  168. package/src/modules/security/security.middleware.ts +398 -0
  169. package/src/modules/session/index.ts +3 -0
  170. package/src/modules/session/session.repository.ts +159 -0
  171. package/src/modules/session/session.service.ts +340 -0
  172. package/src/modules/session/types.ts +38 -0
  173. package/src/modules/swagger/index.ts +7 -1
  174. package/src/modules/swagger/schema-builder.ts +16 -4
  175. package/src/modules/swagger/swagger.service.ts +9 -10
  176. package/src/modules/swagger/types.ts +0 -2
  177. package/src/modules/upload/index.ts +14 -0
  178. package/src/modules/upload/types.ts +83 -0
  179. package/src/modules/upload/upload.repository.ts +199 -0
  180. package/src/modules/upload/upload.routes.ts +311 -0
  181. package/src/modules/upload/upload.service.ts +448 -0
  182. package/src/modules/user/index.ts +3 -3
  183. package/src/modules/user/user.controller.ts +15 -9
  184. package/src/modules/user/user.repository.ts +237 -113
  185. package/src/modules/user/user.routes.ts +39 -164
  186. package/src/modules/user/user.service.ts +4 -3
  187. package/src/modules/validation/validator.ts +12 -17
  188. package/src/modules/webhook/index.ts +91 -0
  189. package/src/modules/webhook/retry.ts +196 -0
  190. package/src/modules/webhook/signature.ts +135 -0
  191. package/src/modules/webhook/types.ts +181 -0
  192. package/src/modules/webhook/webhook.repository.ts +358 -0
  193. package/src/modules/webhook/webhook.routes.ts +442 -0
  194. package/src/modules/webhook/webhook.service.ts +457 -0
  195. package/src/modules/websocket/features.ts +504 -0
  196. package/src/modules/websocket/index.ts +106 -0
  197. package/src/modules/websocket/middlewares.ts +298 -0
  198. package/src/modules/websocket/types.ts +181 -0
  199. package/src/modules/websocket/websocket.service.ts +692 -0
  200. package/src/utils/errors.ts +7 -0
  201. package/src/utils/pagination.ts +4 -1
  202. package/tests/helpers/db-check.ts +79 -0
  203. package/tests/integration/auth-redis.test.ts +94 -0
  204. package/tests/integration/cache-redis.test.ts +387 -0
  205. package/tests/integration/mongoose-repositories.test.ts +410 -0
  206. package/tests/integration/payment-prisma.test.ts +637 -0
  207. package/tests/integration/queue-bullmq.test.ts +417 -0
  208. package/tests/integration/user-prisma.test.ts +441 -0
  209. package/tests/integration/websocket-socketio.test.ts +552 -0
  210. package/tests/setup.ts +11 -9
  211. package/vitest.config.ts +3 -8
  212. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  213. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  216. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,293 @@
1
+ /**
2
+ * OAuth Service
3
+ * Handles OAuth authentication with multiple providers
4
+ *
5
+ * Persistence:
6
+ * - OAuth states: Redis with TTL (temporary, 10-minute expiration)
7
+ * - Linked accounts: Prisma/PostgreSQL (persistent)
8
+ */
9
+ import { logger } from '../../core/logger.js';
10
+ import { BadRequestError, NotFoundError } from '../../utils/errors.js';
11
+ import { prisma } from '../../database/prisma.js';
12
+ import { getRedis } from '../../database/redis.js';
13
+ import { OAuthRepository } from './oauth.repository.js';
14
+ import { GoogleOAuthProvider } from './providers/google.provider.js';
15
+ import { FacebookOAuthProvider } from './providers/facebook.provider.js';
16
+ import { GitHubOAuthProvider } from './providers/github.provider.js';
17
+ import type { OAuthConfig, OAuthProvider, OAuthUser, OAuthState, LinkedAccount } from './types.js';
18
+
19
+ // State expiration time (10 minutes)
20
+ const STATE_EXPIRATION_SECONDS = 10 * 60;
21
+ const OAUTH_STATE_PREFIX = 'oauth:state:';
22
+
23
+ export class OAuthService {
24
+ private config: OAuthConfig;
25
+ private repository: OAuthRepository;
26
+ private googleProvider?: GoogleOAuthProvider;
27
+ private facebookProvider?: FacebookOAuthProvider;
28
+ private githubProvider?: GitHubOAuthProvider;
29
+
30
+ constructor(config: OAuthConfig) {
31
+ this.config = config;
32
+ this.repository = new OAuthRepository(prisma);
33
+
34
+ if (config.google) {
35
+ this.googleProvider = new GoogleOAuthProvider(config.google, config.callbackBaseUrl);
36
+ }
37
+ if (config.facebook) {
38
+ this.facebookProvider = new FacebookOAuthProvider(config.facebook, config.callbackBaseUrl);
39
+ }
40
+ if (config.github) {
41
+ this.githubProvider = new GitHubOAuthProvider(config.github, config.callbackBaseUrl);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get authorization URL for a provider
47
+ */
48
+ async getAuthorizationUrl(provider: OAuthProvider): Promise<{ url: string; state: string }> {
49
+ const providerInstance = this.getProvider(provider);
50
+ const { url, state } = providerInstance.generateAuthUrl();
51
+
52
+ // Store state in Redis with TTL
53
+ const redis = getRedis();
54
+ await redis.setex(
55
+ `${OAUTH_STATE_PREFIX}${state.state}`,
56
+ STATE_EXPIRATION_SECONDS,
57
+ JSON.stringify(state)
58
+ );
59
+
60
+ logger.debug({ provider, state: state.state }, 'OAuth authorization URL generated');
61
+
62
+ return { url, state: state.state };
63
+ }
64
+
65
+ /**
66
+ * Handle OAuth callback
67
+ */
68
+ async handleCallback(provider: OAuthProvider, code: string, state: string): Promise<OAuthUser> {
69
+ // Validate state from Redis
70
+ const redis = getRedis();
71
+ const storedStateJson = await redis.get(`${OAUTH_STATE_PREFIX}${state}`);
72
+
73
+ if (!storedStateJson) {
74
+ throw new BadRequestError('Invalid or expired OAuth state');
75
+ }
76
+
77
+ const storedState = JSON.parse(storedStateJson) as OAuthState;
78
+
79
+ // Remove used state immediately
80
+ await redis.del(`${OAUTH_STATE_PREFIX}${state}`);
81
+
82
+ const providerInstance = this.getProvider(provider);
83
+
84
+ // Exchange code for tokens
85
+ const tokens = await providerInstance.exchangeCode(code, storedState.codeVerifier);
86
+
87
+ // Get user info
88
+ const user = await providerInstance.getUser(tokens.accessToken);
89
+
90
+ // Update tokens in user object
91
+ user.accessToken = tokens.accessToken;
92
+ user.refreshToken = tokens.refreshToken;
93
+ user.expiresAt = tokens.expiresIn ? Date.now() + tokens.expiresIn * 1000 : undefined;
94
+
95
+ logger.info({ provider, userId: user.providerAccountId }, 'OAuth user authenticated');
96
+
97
+ return user;
98
+ }
99
+
100
+ /**
101
+ * Link an OAuth account to a user
102
+ */
103
+ async linkAccount(userId: string, oauthUser: OAuthUser): Promise<LinkedAccount> {
104
+ // Check if already linked
105
+ const existingLink = await this.repository.findByProviderAccount(
106
+ oauthUser.provider,
107
+ oauthUser.providerAccountId
108
+ );
109
+
110
+ if (existingLink) {
111
+ if (existingLink.userId !== userId) {
112
+ throw new BadRequestError('This account is already linked to another user');
113
+ }
114
+ // Update existing link
115
+ const updated = await this.repository.update(existingLink.id, {
116
+ email: oauthUser.email || existingLink.email,
117
+ name: oauthUser.name || existingLink.name,
118
+ picture: oauthUser.picture || existingLink.picture,
119
+ accessToken: oauthUser.accessToken,
120
+ refreshToken: oauthUser.refreshToken || existingLink.refreshToken,
121
+ expiresAt: oauthUser.expiresAt ? new Date(oauthUser.expiresAt) : existingLink.expiresAt,
122
+ });
123
+
124
+ if (!updated) {
125
+ throw new NotFoundError('Linked account');
126
+ }
127
+
128
+ logger.info({ userId, provider: oauthUser.provider }, 'OAuth account updated');
129
+ return updated;
130
+ }
131
+
132
+ // Create new linked account
133
+ const linkedAccount = await this.repository.create({
134
+ userId,
135
+ provider: oauthUser.provider,
136
+ providerAccountId: oauthUser.providerAccountId,
137
+ email: oauthUser.email || undefined,
138
+ name: oauthUser.name || undefined,
139
+ picture: oauthUser.picture || undefined,
140
+ accessToken: oauthUser.accessToken,
141
+ refreshToken: oauthUser.refreshToken,
142
+ expiresAt: oauthUser.expiresAt ? new Date(oauthUser.expiresAt) : undefined,
143
+ });
144
+
145
+ logger.info({ userId, provider: oauthUser.provider }, 'OAuth account linked');
146
+ return linkedAccount;
147
+ }
148
+
149
+ /**
150
+ * Unlink an OAuth account from a user
151
+ */
152
+ async unlinkAccount(userId: string, provider: OAuthProvider): Promise<void> {
153
+ const deleted = await this.repository.deleteByUserAndProvider(userId, provider);
154
+
155
+ if (!deleted) {
156
+ throw new NotFoundError('Linked account');
157
+ }
158
+
159
+ logger.info({ userId, provider }, 'OAuth account unlinked');
160
+ }
161
+
162
+ /**
163
+ * Get user's linked accounts
164
+ */
165
+ async getUserLinkedAccounts(userId: string): Promise<LinkedAccount[]> {
166
+ return this.repository.getByUserId(userId);
167
+ }
168
+
169
+ /**
170
+ * Find linked account by provider and account ID
171
+ */
172
+ async findLinkedAccount(
173
+ provider: OAuthProvider,
174
+ providerAccountId: string
175
+ ): Promise<LinkedAccount | null> {
176
+ return this.repository.findByProviderAccount(provider, providerAccountId);
177
+ }
178
+
179
+ /**
180
+ * Find user by linked account
181
+ */
182
+ async findUserByOAuth(
183
+ provider: OAuthProvider,
184
+ providerAccountId: string
185
+ ): Promise<string | null> {
186
+ const account = await this.repository.findByProviderAccount(provider, providerAccountId);
187
+ return account?.userId || null;
188
+ }
189
+
190
+ /**
191
+ * Refresh OAuth tokens
192
+ */
193
+ async refreshTokens(linkedAccountId: string): Promise<LinkedAccount> {
194
+ const account = await this.repository.getById(linkedAccountId);
195
+ if (!account) {
196
+ throw new NotFoundError('Linked account');
197
+ }
198
+
199
+ if (!account.refreshToken) {
200
+ throw new BadRequestError('No refresh token available');
201
+ }
202
+
203
+ const provider = this.getProvider(account.provider);
204
+
205
+ if ('refreshToken' in provider) {
206
+ const tokens = await (provider as GoogleOAuthProvider).refreshToken(account.refreshToken);
207
+
208
+ const updated = await this.repository.update(linkedAccountId, {
209
+ accessToken: tokens.accessToken,
210
+ expiresAt: tokens.expiresIn ? new Date(Date.now() + tokens.expiresIn * 1000) : undefined,
211
+ });
212
+
213
+ if (!updated) {
214
+ throw new NotFoundError('Linked account');
215
+ }
216
+
217
+ return updated;
218
+ }
219
+
220
+ return account;
221
+ }
222
+
223
+ /**
224
+ * Get linked account by ID
225
+ */
226
+ async getLinkedAccount(id: string): Promise<LinkedAccount | null> {
227
+ return this.repository.getById(id);
228
+ }
229
+
230
+ /**
231
+ * Delete all linked accounts for a user
232
+ */
233
+ async deleteUserAccounts(userId: string): Promise<number> {
234
+ return this.repository.deleteByUserId(userId);
235
+ }
236
+
237
+ /**
238
+ * Get supported providers
239
+ */
240
+ getSupportedProviders(): OAuthProvider[] {
241
+ const providers: OAuthProvider[] = [];
242
+ if (this.googleProvider) providers.push('google');
243
+ if (this.facebookProvider) providers.push('facebook');
244
+ if (this.githubProvider) providers.push('github');
245
+ return providers;
246
+ }
247
+
248
+ /**
249
+ * Check if provider is enabled
250
+ */
251
+ isProviderEnabled(provider: OAuthProvider): boolean {
252
+ return this.getSupportedProviders().includes(provider);
253
+ }
254
+
255
+ // Private methods
256
+ private getProvider(
257
+ provider: OAuthProvider
258
+ ): GoogleOAuthProvider | FacebookOAuthProvider | GitHubOAuthProvider {
259
+ switch (provider) {
260
+ case 'google':
261
+ if (!this.googleProvider) {
262
+ throw new BadRequestError('Google OAuth not configured');
263
+ }
264
+ return this.googleProvider;
265
+ case 'facebook':
266
+ if (!this.facebookProvider) {
267
+ throw new BadRequestError('Facebook OAuth not configured');
268
+ }
269
+ return this.facebookProvider;
270
+ case 'github':
271
+ if (!this.githubProvider) {
272
+ throw new BadRequestError('GitHub OAuth not configured');
273
+ }
274
+ return this.githubProvider;
275
+ default:
276
+ throw new BadRequestError(`Unsupported OAuth provider: ${provider}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ let oauthService: OAuthService | null = null;
282
+
283
+ export function getOAuthService(): OAuthService {
284
+ if (!oauthService) {
285
+ throw new Error('OAuth service not initialized. Call createOAuthService first.');
286
+ }
287
+ return oauthService;
288
+ }
289
+
290
+ export function createOAuthService(config: OAuthConfig): OAuthService {
291
+ oauthService = new OAuthService(config);
292
+ return oauthService;
293
+ }
@@ -0,0 +1,250 @@
1
+ import { randomBytes, createSign } from 'crypto';
2
+ import { logger } from '../../../core/logger.js';
3
+ import type { AppleOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
4
+
5
+ const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
6
+ const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
7
+ const APPLE_KEYS_URL = 'https://appleid.apple.com/auth/keys';
8
+
9
+ const DEFAULT_SCOPES = ['name', 'email'];
10
+
11
+ interface AppleIdToken {
12
+ iss: string;
13
+ aud: string;
14
+ exp: number;
15
+ iat: number;
16
+ sub: string; // User ID
17
+ email?: string;
18
+ email_verified?: string;
19
+ is_private_email?: string;
20
+ auth_time: number;
21
+ nonce_supported: boolean;
22
+ }
23
+
24
+ export class AppleOAuthProvider {
25
+ private config: AppleOAuthConfig;
26
+ private callbackUrl: string;
27
+
28
+ constructor(config: AppleOAuthConfig, callbackBaseUrl: string) {
29
+ this.config = config;
30
+ this.callbackUrl = `${callbackBaseUrl}/auth/oauth/apple/callback`;
31
+ }
32
+
33
+ /**
34
+ * Generate authorization URL
35
+ */
36
+ generateAuthUrl(state?: string): { url: string; state: OAuthState } {
37
+ const stateValue = state || randomBytes(32).toString('hex');
38
+ const nonce = randomBytes(16).toString('hex');
39
+
40
+ const params = new URLSearchParams({
41
+ client_id: this.config.clientId,
42
+ redirect_uri: this.callbackUrl,
43
+ response_type: 'code id_token',
44
+ response_mode: 'form_post',
45
+ scope: DEFAULT_SCOPES.join(' '),
46
+ state: stateValue,
47
+ nonce,
48
+ });
49
+
50
+ const oauthState: OAuthState = {
51
+ state: stateValue,
52
+ redirectUri: this.callbackUrl,
53
+ createdAt: Date.now(),
54
+ };
55
+
56
+ return {
57
+ url: `${APPLE_AUTH_URL}?${params.toString()}`,
58
+ state: oauthState,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Generate client secret (JWT signed with private key)
64
+ */
65
+ private generateClientSecret(): string {
66
+ const now = Math.floor(Date.now() / 1000);
67
+ const exp = now + 3600; // 1 hour
68
+
69
+ const header = {
70
+ alg: 'ES256',
71
+ kid: this.config.keyId,
72
+ };
73
+
74
+ const payload = {
75
+ iss: this.config.teamId,
76
+ iat: now,
77
+ exp,
78
+ aud: 'https://appleid.apple.com',
79
+ sub: this.config.clientId,
80
+ };
81
+
82
+ const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
83
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
84
+ const signatureInput = `${headerB64}.${payloadB64}`;
85
+
86
+ const sign = createSign('SHA256');
87
+ sign.update(signatureInput);
88
+ const signature = sign.sign(this.config.privateKey, 'base64url');
89
+
90
+ return `${signatureInput}.${signature}`;
91
+ }
92
+
93
+ /**
94
+ * Exchange authorization code for tokens
95
+ */
96
+ async exchangeCode(code: string): Promise<OAuthTokens> {
97
+ const clientSecret = this.generateClientSecret();
98
+
99
+ const params = new URLSearchParams({
100
+ client_id: this.config.clientId,
101
+ client_secret: clientSecret,
102
+ code,
103
+ grant_type: 'authorization_code',
104
+ redirect_uri: this.callbackUrl,
105
+ });
106
+
107
+ const response = await fetch(APPLE_TOKEN_URL, {
108
+ method: 'POST',
109
+ headers: {
110
+ 'Content-Type': 'application/x-www-form-urlencoded',
111
+ },
112
+ body: params.toString(),
113
+ });
114
+
115
+ if (!response.ok) {
116
+ const error = await response.text();
117
+ logger.error({ error }, 'Apple token exchange failed');
118
+ throw new Error(`Failed to exchange code: ${error}`);
119
+ }
120
+
121
+ const data = (await response.json()) as {
122
+ access_token: string;
123
+ refresh_token?: string;
124
+ expires_in: number;
125
+ token_type: string;
126
+ id_token: string;
127
+ };
128
+
129
+ return {
130
+ accessToken: data.access_token,
131
+ refreshToken: data.refresh_token,
132
+ expiresIn: data.expires_in,
133
+ tokenType: data.token_type,
134
+ idToken: data.id_token,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Get user information from Apple ID token
140
+ * Note: Apple only provides user info on first authorization
141
+ */
142
+ async getUser(
143
+ accessToken: string,
144
+ idToken?: string,
145
+ userInfo?: { name?: { firstName?: string; lastName?: string }; email?: string }
146
+ ): Promise<OAuthUser> {
147
+ if (!idToken) {
148
+ throw new Error('ID token is required for Apple Sign In');
149
+ }
150
+
151
+ // Decode ID token (in production, verify signature with Apple's public keys)
152
+ const [, payloadB64] = idToken.split('.');
153
+ const payload = JSON.parse(Buffer.from(payloadB64 ?? '', 'base64').toString()) as AppleIdToken;
154
+
155
+ // Apple only sends user info on first authorization
156
+ // After that, you must store it yourself
157
+ const firstName = userInfo?.name?.firstName || null;
158
+ const lastName = userInfo?.name?.lastName || null;
159
+ const email = userInfo?.email || payload.email || null;
160
+
161
+ return {
162
+ id: payload.sub,
163
+ email,
164
+ emailVerified: payload.email_verified === 'true',
165
+ name: firstName && lastName ? `${firstName} ${lastName}` : null,
166
+ firstName,
167
+ lastName,
168
+ picture: null, // Apple doesn't provide profile pictures
169
+ provider: 'apple',
170
+ providerAccountId: payload.sub,
171
+ accessToken,
172
+ raw: { ...payload, userInfo },
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Refresh access token
178
+ */
179
+ async refreshToken(refreshToken: string): Promise<OAuthTokens> {
180
+ const clientSecret = this.generateClientSecret();
181
+
182
+ const params = new URLSearchParams({
183
+ client_id: this.config.clientId,
184
+ client_secret: clientSecret,
185
+ refresh_token: refreshToken,
186
+ grant_type: 'refresh_token',
187
+ });
188
+
189
+ const response = await fetch(APPLE_TOKEN_URL, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Content-Type': 'application/x-www-form-urlencoded',
193
+ },
194
+ body: params.toString(),
195
+ });
196
+
197
+ if (!response.ok) {
198
+ throw new Error('Failed to refresh token');
199
+ }
200
+
201
+ const data = (await response.json()) as {
202
+ access_token: string;
203
+ expires_in: number;
204
+ token_type: string;
205
+ id_token: string;
206
+ };
207
+
208
+ return {
209
+ accessToken: data.access_token,
210
+ expiresIn: data.expires_in,
211
+ tokenType: data.token_type,
212
+ idToken: data.id_token,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Revoke refresh token
218
+ */
219
+ async revokeToken(refreshToken: string): Promise<void> {
220
+ const clientSecret = this.generateClientSecret();
221
+
222
+ const params = new URLSearchParams({
223
+ client_id: this.config.clientId,
224
+ client_secret: clientSecret,
225
+ token: refreshToken,
226
+ token_type_hint: 'refresh_token',
227
+ });
228
+
229
+ await fetch('https://appleid.apple.com/auth/revoke', {
230
+ method: 'POST',
231
+ headers: {
232
+ 'Content-Type': 'application/x-www-form-urlencoded',
233
+ },
234
+ body: params.toString(),
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Get Apple's public keys for ID token verification
240
+ */
241
+ async getPublicKeys(): Promise<
242
+ Array<{ kty: string; kid: string; use: string; alg: string; n: string; e: string }>
243
+ > {
244
+ const response = await fetch(APPLE_KEYS_URL);
245
+ const data = (await response.json()) as {
246
+ keys: Array<{ kty: string; kid: string; use: string; alg: string; n: string; e: string }>;
247
+ };
248
+ return data.keys;
249
+ }
250
+ }