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,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
|
+
}
|