servcraft 0.1.0 → 0.1.3
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 +30 -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/LICENSE +21 -0
- package/README.md +1102 -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,181 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { logger } from '../../../core/logger.js';
|
|
3
|
+
import type { FacebookOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const FACEBOOK_AUTH_URL = 'https://www.facebook.com/v18.0/dialog/oauth';
|
|
6
|
+
const FACEBOOK_TOKEN_URL = 'https://graph.facebook.com/v18.0/oauth/access_token';
|
|
7
|
+
const FACEBOOK_USERINFO_URL = 'https://graph.facebook.com/v18.0/me';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SCOPES = ['email', 'public_profile'];
|
|
10
|
+
|
|
11
|
+
export class FacebookOAuthProvider {
|
|
12
|
+
private config: FacebookOAuthConfig;
|
|
13
|
+
private callbackUrl: string;
|
|
14
|
+
|
|
15
|
+
constructor(config: FacebookOAuthConfig, callbackBaseUrl: string) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.callbackUrl = `${callbackBaseUrl}/auth/oauth/facebook/callback`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate authorization URL
|
|
22
|
+
*/
|
|
23
|
+
generateAuthUrl(state?: string): { url: string; state: OAuthState } {
|
|
24
|
+
const stateValue = state || randomBytes(32).toString('hex');
|
|
25
|
+
const scopes = this.config.scopes || DEFAULT_SCOPES;
|
|
26
|
+
|
|
27
|
+
const params = new URLSearchParams({
|
|
28
|
+
client_id: this.config.clientId,
|
|
29
|
+
redirect_uri: this.callbackUrl,
|
|
30
|
+
response_type: 'code',
|
|
31
|
+
scope: scopes.join(','),
|
|
32
|
+
state: stateValue,
|
|
33
|
+
auth_type: 'rerequest', // Re-request declined permissions
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const oauthState: OAuthState = {
|
|
37
|
+
state: stateValue,
|
|
38
|
+
redirectUri: this.callbackUrl,
|
|
39
|
+
createdAt: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
url: `${FACEBOOK_AUTH_URL}?${params.toString()}`,
|
|
44
|
+
state: oauthState,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Exchange authorization code for tokens
|
|
50
|
+
*/
|
|
51
|
+
async exchangeCode(code: string): Promise<OAuthTokens> {
|
|
52
|
+
const params = new URLSearchParams({
|
|
53
|
+
client_id: this.config.clientId,
|
|
54
|
+
client_secret: this.config.clientSecret,
|
|
55
|
+
code,
|
|
56
|
+
redirect_uri: this.callbackUrl,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const response = await fetch(`${FACEBOOK_TOKEN_URL}?${params.toString()}`);
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const error = await response.text();
|
|
63
|
+
logger.error({ error }, 'Facebook token exchange failed');
|
|
64
|
+
throw new Error(`Failed to exchange code: ${error}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = (await response.json()) as {
|
|
68
|
+
access_token: string;
|
|
69
|
+
token_type: string;
|
|
70
|
+
expires_in: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
accessToken: data.access_token,
|
|
75
|
+
tokenType: data.token_type,
|
|
76
|
+
expiresIn: data.expires_in,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get user information from Facebook
|
|
82
|
+
*/
|
|
83
|
+
async getUser(accessToken: string): Promise<OAuthUser> {
|
|
84
|
+
const fields = 'id,email,name,first_name,last_name,picture.type(large)';
|
|
85
|
+
const url = `${FACEBOOK_USERINFO_URL}?fields=${fields}&access_token=${accessToken}`;
|
|
86
|
+
|
|
87
|
+
const response = await fetch(url);
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const error = await response.text();
|
|
91
|
+
logger.error({ error }, 'Failed to get Facebook user info');
|
|
92
|
+
throw new Error(`Failed to get user info: ${error}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const data = (await response.json()) as {
|
|
96
|
+
id: string;
|
|
97
|
+
email?: string;
|
|
98
|
+
name: string;
|
|
99
|
+
first_name: string;
|
|
100
|
+
last_name: string;
|
|
101
|
+
picture?: { data: { url: string } };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: data.id,
|
|
106
|
+
email: data.email || null,
|
|
107
|
+
emailVerified: !!data.email, // Facebook only returns verified emails
|
|
108
|
+
name: data.name,
|
|
109
|
+
firstName: data.first_name,
|
|
110
|
+
lastName: data.last_name,
|
|
111
|
+
picture: data.picture?.data?.url || null,
|
|
112
|
+
provider: 'facebook',
|
|
113
|
+
providerAccountId: data.id,
|
|
114
|
+
accessToken,
|
|
115
|
+
raw: data,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get long-lived access token (60 days instead of ~2 hours)
|
|
121
|
+
*/
|
|
122
|
+
async getLongLivedToken(shortLivedToken: string): Promise<OAuthTokens> {
|
|
123
|
+
const params = new URLSearchParams({
|
|
124
|
+
grant_type: 'fb_exchange_token',
|
|
125
|
+
client_id: this.config.clientId,
|
|
126
|
+
client_secret: this.config.clientSecret,
|
|
127
|
+
fb_exchange_token: shortLivedToken,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const response = await fetch(`${FACEBOOK_TOKEN_URL}?${params.toString()}`);
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error('Failed to get long-lived token');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const data = (await response.json()) as {
|
|
137
|
+
access_token: string;
|
|
138
|
+
token_type: string;
|
|
139
|
+
expires_in: number;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
accessToken: data.access_token,
|
|
144
|
+
tokenType: data.token_type,
|
|
145
|
+
expiresIn: data.expires_in,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Debug/inspect access token
|
|
151
|
+
*/
|
|
152
|
+
async inspectToken(
|
|
153
|
+
accessToken: string
|
|
154
|
+
): Promise<{ isValid: boolean; userId: string; expiresAt: number }> {
|
|
155
|
+
const appToken = `${this.config.clientId}|${this.config.clientSecret}`;
|
|
156
|
+
const url = `https://graph.facebook.com/debug_token?input_token=${accessToken}&access_token=${appToken}`;
|
|
157
|
+
|
|
158
|
+
const response = await fetch(url);
|
|
159
|
+
const data = (await response.json()) as {
|
|
160
|
+
data: {
|
|
161
|
+
is_valid: boolean;
|
|
162
|
+
user_id: string;
|
|
163
|
+
expires_at: number;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
isValid: data.data.is_valid,
|
|
169
|
+
userId: data.data.user_id,
|
|
170
|
+
expiresAt: data.data.expires_at,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Revoke access (user must re-authorize)
|
|
176
|
+
*/
|
|
177
|
+
async revokeAccess(userId: string, accessToken: string): Promise<void> {
|
|
178
|
+
const url = `https://graph.facebook.com/v18.0/${userId}/permissions?access_token=${accessToken}`;
|
|
179
|
+
await fetch(url, { method: 'DELETE' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { logger } from '../../../core/logger.js';
|
|
3
|
+
import type { GitHubOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
|
|
6
|
+
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
7
|
+
const GITHUB_API_URL = 'https://api.github.com';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
10
|
+
|
|
11
|
+
export class GitHubOAuthProvider {
|
|
12
|
+
private config: GitHubOAuthConfig;
|
|
13
|
+
private callbackUrl: string;
|
|
14
|
+
|
|
15
|
+
constructor(config: GitHubOAuthConfig, callbackBaseUrl: string) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.callbackUrl = `${callbackBaseUrl}/auth/oauth/github/callback`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate authorization URL
|
|
22
|
+
*/
|
|
23
|
+
generateAuthUrl(state?: string): { url: string; state: OAuthState } {
|
|
24
|
+
const stateValue = state || randomBytes(32).toString('hex');
|
|
25
|
+
const scopes = this.config.scopes || DEFAULT_SCOPES;
|
|
26
|
+
|
|
27
|
+
const params = new URLSearchParams({
|
|
28
|
+
client_id: this.config.clientId,
|
|
29
|
+
redirect_uri: this.callbackUrl,
|
|
30
|
+
scope: scopes.join(' '),
|
|
31
|
+
state: stateValue,
|
|
32
|
+
allow_signup: 'true',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const oauthState: OAuthState = {
|
|
36
|
+
state: stateValue,
|
|
37
|
+
redirectUri: this.callbackUrl,
|
|
38
|
+
createdAt: Date.now(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
url: `${GITHUB_AUTH_URL}?${params.toString()}`,
|
|
43
|
+
state: oauthState,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Exchange authorization code for tokens
|
|
49
|
+
*/
|
|
50
|
+
async exchangeCode(code: string): Promise<OAuthTokens> {
|
|
51
|
+
const response = await fetch(GITHUB_TOKEN_URL, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
Accept: 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
client_id: this.config.clientId,
|
|
59
|
+
client_secret: this.config.clientSecret,
|
|
60
|
+
code,
|
|
61
|
+
redirect_uri: this.callbackUrl,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const error = await response.text();
|
|
67
|
+
logger.error({ error }, 'GitHub token exchange failed');
|
|
68
|
+
throw new Error(`Failed to exchange code: ${error}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = (await response.json()) as {
|
|
72
|
+
access_token: string;
|
|
73
|
+
token_type: string;
|
|
74
|
+
scope: string;
|
|
75
|
+
error?: string;
|
|
76
|
+
error_description?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (data.error) {
|
|
80
|
+
throw new Error(data.error_description || data.error);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
accessToken: data.access_token,
|
|
85
|
+
tokenType: data.token_type,
|
|
86
|
+
scope: data.scope,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get user information from GitHub
|
|
92
|
+
*/
|
|
93
|
+
async getUser(accessToken: string): Promise<OAuthUser> {
|
|
94
|
+
// Get user profile
|
|
95
|
+
const userResponse = await fetch(`${GITHUB_API_URL}/user`, {
|
|
96
|
+
headers: {
|
|
97
|
+
Authorization: `Bearer ${accessToken}`,
|
|
98
|
+
Accept: 'application/vnd.github.v3+json',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!userResponse.ok) {
|
|
103
|
+
const error = await userResponse.text();
|
|
104
|
+
logger.error({ error }, 'Failed to get GitHub user info');
|
|
105
|
+
throw new Error(`Failed to get user info: ${error}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const userData = (await userResponse.json()) as {
|
|
109
|
+
id: number;
|
|
110
|
+
login: string;
|
|
111
|
+
name: string | null;
|
|
112
|
+
email: string | null;
|
|
113
|
+
avatar_url: string;
|
|
114
|
+
bio: string | null;
|
|
115
|
+
company: string | null;
|
|
116
|
+
location: string | null;
|
|
117
|
+
html_url: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Get user emails (email might be private)
|
|
121
|
+
let primaryEmail: string | null = userData.email;
|
|
122
|
+
let emailVerified = false;
|
|
123
|
+
|
|
124
|
+
if (!primaryEmail) {
|
|
125
|
+
const emailsResponse = await fetch(`${GITHUB_API_URL}/user/emails`, {
|
|
126
|
+
headers: {
|
|
127
|
+
Authorization: `Bearer ${accessToken}`,
|
|
128
|
+
Accept: 'application/vnd.github.v3+json',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (emailsResponse.ok) {
|
|
133
|
+
const emails = (await emailsResponse.json()) as Array<{
|
|
134
|
+
email: string;
|
|
135
|
+
primary: boolean;
|
|
136
|
+
verified: boolean;
|
|
137
|
+
}>;
|
|
138
|
+
|
|
139
|
+
const primary = emails.find((e) => e.primary && e.verified);
|
|
140
|
+
if (primary) {
|
|
141
|
+
primaryEmail = primary.email;
|
|
142
|
+
emailVerified = primary.verified;
|
|
143
|
+
} else {
|
|
144
|
+
const verified = emails.find((e) => e.verified);
|
|
145
|
+
if (verified) {
|
|
146
|
+
primaryEmail = verified.email;
|
|
147
|
+
emailVerified = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
emailVerified = true; // Public emails are verified
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse name into first/last
|
|
156
|
+
let firstName: string | null = null;
|
|
157
|
+
let lastName: string | null = null;
|
|
158
|
+
if (userData.name) {
|
|
159
|
+
const parts = userData.name.split(' ');
|
|
160
|
+
firstName = parts[0] ?? null;
|
|
161
|
+
lastName = parts.slice(1).join(' ') || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
id: userData.id.toString(),
|
|
166
|
+
email: primaryEmail,
|
|
167
|
+
emailVerified,
|
|
168
|
+
name: userData.name || userData.login,
|
|
169
|
+
firstName,
|
|
170
|
+
lastName,
|
|
171
|
+
picture: userData.avatar_url,
|
|
172
|
+
provider: 'github',
|
|
173
|
+
providerAccountId: userData.id.toString(),
|
|
174
|
+
accessToken,
|
|
175
|
+
raw: userData,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get user's repositories
|
|
181
|
+
*/
|
|
182
|
+
async getRepositories(
|
|
183
|
+
accessToken: string,
|
|
184
|
+
options: { page?: number; perPage?: number; sort?: 'created' | 'updated' | 'pushed' } = {}
|
|
185
|
+
): Promise<Array<{ id: number; name: string; fullName: string; private: boolean; url: string }>> {
|
|
186
|
+
const params = new URLSearchParams({
|
|
187
|
+
page: (options.page || 1).toString(),
|
|
188
|
+
per_page: (options.perPage || 30).toString(),
|
|
189
|
+
sort: options.sort || 'updated',
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const response = await fetch(`${GITHUB_API_URL}/user/repos?${params}`, {
|
|
193
|
+
headers: {
|
|
194
|
+
Authorization: `Bearer ${accessToken}`,
|
|
195
|
+
Accept: 'application/vnd.github.v3+json',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error('Failed to get repositories');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const repos = (await response.json()) as Array<{
|
|
204
|
+
id: number;
|
|
205
|
+
name: string;
|
|
206
|
+
full_name: string;
|
|
207
|
+
private: boolean;
|
|
208
|
+
html_url: string;
|
|
209
|
+
}>;
|
|
210
|
+
|
|
211
|
+
return repos.map((repo) => ({
|
|
212
|
+
id: repo.id,
|
|
213
|
+
name: repo.name,
|
|
214
|
+
fullName: repo.full_name,
|
|
215
|
+
private: repo.private,
|
|
216
|
+
url: repo.html_url,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Check if user has access to a specific repository
|
|
222
|
+
*/
|
|
223
|
+
async checkRepoAccess(accessToken: string, owner: string, repo: string): Promise<boolean> {
|
|
224
|
+
const response = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}`, {
|
|
225
|
+
headers: {
|
|
226
|
+
Authorization: `Bearer ${accessToken}`,
|
|
227
|
+
Accept: 'application/vnd.github.v3+json',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return response.ok;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Revoke access token
|
|
236
|
+
*/
|
|
237
|
+
async revokeToken(accessToken: string): Promise<void> {
|
|
238
|
+
await fetch(`https://api.github.com/applications/${this.config.clientId}/token`, {
|
|
239
|
+
method: 'DELETE',
|
|
240
|
+
headers: {
|
|
241
|
+
Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64')}`,
|
|
242
|
+
Accept: 'application/vnd.github.v3+json',
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify({ access_token: accessToken }),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'crypto';
|
|
2
|
+
import { logger } from '../../../core/logger.js';
|
|
3
|
+
import type { GoogleOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
6
|
+
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
7
|
+
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SCOPES = [
|
|
10
|
+
'openid',
|
|
11
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
12
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export class GoogleOAuthProvider {
|
|
16
|
+
private config: GoogleOAuthConfig;
|
|
17
|
+
private callbackUrl: string;
|
|
18
|
+
|
|
19
|
+
constructor(config: GoogleOAuthConfig, callbackBaseUrl: string) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.callbackUrl = `${callbackBaseUrl}/auth/oauth/google/callback`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate authorization URL with PKCE
|
|
26
|
+
*/
|
|
27
|
+
generateAuthUrl(state?: string): { url: string; state: OAuthState } {
|
|
28
|
+
const stateValue = state || randomBytes(32).toString('hex');
|
|
29
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
30
|
+
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
31
|
+
|
|
32
|
+
const scopes = this.config.scopes || DEFAULT_SCOPES;
|
|
33
|
+
|
|
34
|
+
const params = new URLSearchParams({
|
|
35
|
+
client_id: this.config.clientId,
|
|
36
|
+
redirect_uri: this.callbackUrl,
|
|
37
|
+
response_type: 'code',
|
|
38
|
+
scope: scopes.join(' '),
|
|
39
|
+
state: stateValue,
|
|
40
|
+
code_challenge: codeChallenge,
|
|
41
|
+
code_challenge_method: 'S256',
|
|
42
|
+
access_type: 'offline',
|
|
43
|
+
prompt: 'consent',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const oauthState: OAuthState = {
|
|
47
|
+
state: stateValue,
|
|
48
|
+
codeVerifier,
|
|
49
|
+
redirectUri: this.callbackUrl,
|
|
50
|
+
createdAt: Date.now(),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
url: `${GOOGLE_AUTH_URL}?${params.toString()}`,
|
|
55
|
+
state: oauthState,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Exchange authorization code for tokens
|
|
61
|
+
*/
|
|
62
|
+
async exchangeCode(code: string, codeVerifier?: string): Promise<OAuthTokens> {
|
|
63
|
+
const params = new URLSearchParams({
|
|
64
|
+
client_id: this.config.clientId,
|
|
65
|
+
client_secret: this.config.clientSecret,
|
|
66
|
+
code,
|
|
67
|
+
grant_type: 'authorization_code',
|
|
68
|
+
redirect_uri: this.callbackUrl,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (codeVerifier) {
|
|
72
|
+
params.append('code_verifier', codeVerifier);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
78
|
+
body: params.toString(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const error = await response.text();
|
|
83
|
+
logger.error({ error }, 'Google token exchange failed');
|
|
84
|
+
throw new Error(`Failed to exchange code: ${error}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const data = (await response.json()) as {
|
|
88
|
+
access_token: string;
|
|
89
|
+
refresh_token?: string;
|
|
90
|
+
expires_in: number;
|
|
91
|
+
token_type: string;
|
|
92
|
+
scope: string;
|
|
93
|
+
id_token?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
accessToken: data.access_token,
|
|
98
|
+
refreshToken: data.refresh_token,
|
|
99
|
+
expiresIn: data.expires_in,
|
|
100
|
+
tokenType: data.token_type,
|
|
101
|
+
scope: data.scope,
|
|
102
|
+
idToken: data.id_token,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get user information from Google
|
|
108
|
+
*/
|
|
109
|
+
async getUser(accessToken: string): Promise<OAuthUser> {
|
|
110
|
+
const response = await fetch(GOOGLE_USERINFO_URL, {
|
|
111
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const error = await response.text();
|
|
116
|
+
logger.error({ error }, 'Failed to get Google user info');
|
|
117
|
+
throw new Error(`Failed to get user info: ${error}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const data = (await response.json()) as {
|
|
121
|
+
id: string;
|
|
122
|
+
email: string;
|
|
123
|
+
verified_email: boolean;
|
|
124
|
+
name: string;
|
|
125
|
+
given_name: string;
|
|
126
|
+
family_name: string;
|
|
127
|
+
picture: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
id: data.id,
|
|
132
|
+
email: data.email,
|
|
133
|
+
emailVerified: data.verified_email,
|
|
134
|
+
name: data.name,
|
|
135
|
+
firstName: data.given_name,
|
|
136
|
+
lastName: data.family_name,
|
|
137
|
+
picture: data.picture,
|
|
138
|
+
provider: 'google',
|
|
139
|
+
providerAccountId: data.id,
|
|
140
|
+
accessToken,
|
|
141
|
+
raw: data,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Refresh access token
|
|
147
|
+
*/
|
|
148
|
+
async refreshToken(refreshToken: string): Promise<OAuthTokens> {
|
|
149
|
+
const params = new URLSearchParams({
|
|
150
|
+
client_id: this.config.clientId,
|
|
151
|
+
client_secret: this.config.clientSecret,
|
|
152
|
+
refresh_token: refreshToken,
|
|
153
|
+
grant_type: 'refresh_token',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
159
|
+
body: params.toString(),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
throw new Error('Failed to refresh token');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const data = (await response.json()) as {
|
|
167
|
+
access_token: string;
|
|
168
|
+
expires_in: number;
|
|
169
|
+
token_type: string;
|
|
170
|
+
scope: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
accessToken: data.access_token,
|
|
175
|
+
expiresIn: data.expires_in,
|
|
176
|
+
tokenType: data.token_type,
|
|
177
|
+
scope: data.scope,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Revoke access token
|
|
183
|
+
*/
|
|
184
|
+
async revokeToken(token: string): Promise<void> {
|
|
185
|
+
await fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|